"""微信客服(企业微信)消息接收回调 /wx/kf/callback(裸路径, 非 /api/v1)。 对应企业微信「微信客服」→ 客服账号 → 接收消息回调里填的 URL。是服务号版(wx_mp.py)的 平行实现:下游(set_pending → 心跳下发 → 前端弹窗)完全一致, 只有上游"如何拿到用户消息"不同 —— 服务号是微信把消息直接 POST 给你;微信客服是 POST 一个事件通知, 你再用 主动 sync_msg 拉。 GET : URL 接入验证 → msg_signature 验签(含密文 echostr) → 解密 echostr → 返回明文。 ⚠️ 与公众号明文模式不同:企业微信 echostr 是密文, 必须解密后返回。 POST : 收事件(密文) → msg_signature 验签 → AES 解密 → 解析 XML。 Event=kf_msg_or_event → 取 / → sync_msg 增量拉消息 → 客户(origin=3)发来的图片(image) → set_pending(测试设备, "meituan") + 下载落盘。 其余消息/事件记日志忽略。一律返回 "success"(异常也吞掉, 不让企业微信重试轰炸)。 回调加密与公众号「安全模式」同源, 直接复用 integrations/wx_mp_crypto(验签 + AES-256-CBC 解密), 差别只在 receiveid 用 corpid(公众号是 appid)。MVP 只做"收图打信号 + 落盘证明链路通"。 """ from __future__ import annotations import logging import xml.etree.ElementTree as ET from pathlib import Path from typing import Any from fastapi import APIRouter, Request from fastapi.responses import PlainTextResponse from app.core import wx_poc_signal from app.core.config import settings from app.integrations import wx_kf_client, wx_mp_crypto logger = logging.getLogger("shagua.wx_kf") router = APIRouter(prefix="/wx/kf", tags=["wx-kf"]) # 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理) _INBOX = Path(settings.MEDIA_ROOT) / "wx_kf_inbox" @router.get("/callback", include_in_schema=False) async def verify( msg_signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = "" ) -> PlainTextResponse: """企业微信 URL 接入验证:验签(密文 echostr 参与)→ 解密 echostr → 返回明文。""" if not settings.wx_kf_callback_configured: logger.warning("wx_kf verify: 回调凭证未配齐(CorpId/Token/AESKey/Secret)") return PlainTextResponse("", status_code=503) if not wx_mp_crypto.verify_msg_signature( settings.WX_KF_TOKEN, timestamp, nonce, echostr, msg_signature ): logger.warning("wx_kf verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce) return PlainTextResponse("invalid signature", status_code=403) try: plain = wx_mp_crypto.decrypt_message( settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, echostr ) except Exception: logger.exception("wx_kf verify: echostr 解密失败") return PlainTextResponse("decrypt failed", status_code=403) return PlainTextResponse(plain) @router.post("/callback", include_in_schema=False) async def receive(request: Request) -> PlainTextResponse: """收事件通知(安全模式)。任何异常都吞掉返回 success, 不让企业微信重试轰炸, 靠日志排查。""" if not settings.wx_kf_callback_configured: logger.warning("wx_kf receive: 回调凭证未配齐") return PlainTextResponse("success") try: body = (await request.body()).decode("utf-8") qp = request.query_params # 安全模式外层 XML 只有 ToUserName + Encrypt。来源=企业微信服务器(HTTPS)+ 下面验签, # 且 stdlib ET 不扩展外部实体, XXE 不适用。 encrypt = ET.fromstring(body).findtext("Encrypt") or "" if not wx_mp_crypto.verify_msg_signature( settings.WX_KF_TOKEN, qp.get("timestamp", ""), qp.get("nonce", ""), encrypt, qp.get("msg_signature", ""), ): logger.warning("wx_kf receive: msg_signature 校验失败") return PlainTextResponse("success") xml = wx_mp_crypto.decrypt_message( settings.WX_KF_AES_KEY, settings.WX_KF_CORP_ID, encrypt ) await _handle_event(ET.fromstring(xml)) except Exception: logger.exception("wx_kf receive: 处理异常") return PlainTextResponse("success") async def _handle_event(root: ET.Element) -> None: """客服事件:MsgType=event 且 Event=kf_msg_or_event → 用 Token 增量拉消息。""" event = root.findtext("Event") or "" if event != "kf_msg_or_event": logger.info("wx_kf 收到事件(暂忽略): event=%s", event) return token = root.findtext("Token") or "" open_kfid = root.findtext("OpenKfId") or "" msgs = await wx_kf_client.sync_messages(token, open_kfid) logger.info("wx_kf sync_msg 拉到 %d 条 open_kfid=%s", len(msgs), open_kfid) for msg in msgs: await _handle_message(msg) async def _handle_message(msg: dict[str, Any]) -> None: """处理单条客服消息。只关心【客户发来的】(origin=3)图片 / 小程序卡片:打比价信号(+ 落盘)。 origin:3=客户发的 / 4=系统推送 / 5=接待人员发的。接待人员或系统消息一律不触发比价。 两种触发物对应场景:image=用户发结算页截图;miniprogram=用户分享美团小程序卡片(见需求截图)。 """ if msg.get("origin") != 3: return msgtype = msg.get("msgtype") or "" external_userid = msg.get("external_userid") or "" if msgtype == "image": media_id = (msg.get("image") or {}).get("media_id", "") logger.info( "wx_kf 收到图片: external_userid=%s media_id=%s", external_userid, media_id ) _fire_pending("image") await _save_media(media_id, external_userid) elif msgtype == "miniprogram": mp = msg.get("miniprogram") or {} logger.info( "wx_kf 收到小程序卡片: external_userid=%s title=%s appid=%s", external_userid, mp.get("title"), mp.get("appid"), ) # 卡片无可下载媒体, 只打信号;源平台/订单识别(可据 appid 判平台)留后续, 现写死美团。 _fire_pending("miniprogram") else: logger.info( "wx_kf 收到消息(暂忽略): external_userid=%s type=%s", external_userid, msgtype ) def _fire_pending(kind: str) -> None: """PoC:给写死的测试设备打"从美团比价"信号(下次心跳带回前端弹窗, 与服务号版完全一致)。""" poc_dev = settings.WX_POC_TEST_DEVICE_ID if not poc_dev: return wx_poc_signal.set_pending(poc_dev, "meituan") logger.info("wx_kf PoC: 已给测试设备 %s 打比价信号 source=meituan (触发=%s)", poc_dev, kind) async def _save_media(media_id: str, external_userid: str) -> None: """图片消息只给 media_id, 走临时素材下载接口拉回落盘(PoC 验证用)。""" content = await wx_kf_client.download_media(media_id) if not content: return try: _INBOX.mkdir(parents=True, exist_ok=True) dest = _INBOX / f"{external_userid[:16]}_{media_id[:16]}.jpg" dest.write_bytes(content) logger.info("wx_kf 图片已落盘: %s (%d bytes)", dest, len(content)) except Exception: logger.exception("wx_kf 图片落盘失败 media_id=%s", media_id)