"""微信服务号消息接收回调 /wx/mp/callback(裸路径, 非 /api/v1)。 对应公众平台"设置与开发 → 基本配置 → 服务器配置"里填的 URL。 GET : URL 接入验证 → 校验 signature → 原样返回 echostr(明文) POST : 收用户消息(安全模式密文) → msg_signature 验签 → AES 解密 → 解析 XML 图片消息(MsgType=image): 打日志(openid/PicUrl/MediaId) + 用 PicUrl 下载落盘 其余消息/事件: 记一条日志忽略。一律返回 "success"(微信据此不再重试)。 MVP 只做"收到图片并落盘"证明链路通;后续接:识别平台+订单 → 打 pending_compare 标记 → 心跳带回前端弹窗,以及 openid↔device 绑定。任何异常都返回 "success"、不让微信重试轰炸。 """ from __future__ import annotations import logging import xml.etree.ElementTree as ET from pathlib import Path import httpx 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_mp_crypto logger = logging.getLogger("shagua.wx_mp") router = APIRouter(prefix="/wx/mp", tags=["wx-mp"]) # 收到的截图暂存目录(MVP 验证用;接上识别后可改为不落盘或定期清理) _INBOX = Path(settings.MEDIA_ROOT) / "wx_inbox" @router.get("/callback", include_in_schema=False) async def verify( signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = "" ) -> PlainTextResponse: """微信 URL 接入验证:校验 signature 通过则原样返回 echostr。""" if not settings.WX_MP_TOKEN: logger.warning("wx_mp verify: WX_MP_TOKEN 未配置") return PlainTextResponse("", status_code=503) if wx_mp_crypto.verify_url_signature( settings.WX_MP_TOKEN, timestamp, nonce, signature ): return PlainTextResponse(echostr) logger.warning("wx_mp verify: signature 校验失败 ts=%s nonce=%s", timestamp, nonce) return PlainTextResponse("invalid signature", status_code=403) @router.post("/callback", include_in_schema=False) async def receive(request: Request) -> PlainTextResponse: """收用户消息(安全模式)。任何异常都吞掉返回 success,不让微信重试轰炸,靠日志排查。""" if not settings.wx_mp_callback_configured: logger.warning("wx_mp receive: 回调凭证未配齐(Token/AESKey/AppID)") 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_MP_TOKEN, qp.get("timestamp", ""), qp.get("nonce", ""), encrypt, qp.get("msg_signature", ""), ): logger.warning("wx_mp receive: msg_signature 校验失败") return PlainTextResponse("success") xml = wx_mp_crypto.decrypt_message( settings.WX_MP_AES_KEY, settings.WX_MP_APPID, encrypt ) await _handle_message(ET.fromstring(xml)) except Exception: logger.exception("wx_mp receive: 处理异常") return PlainTextResponse("success") async def _handle_message(root: ET.Element) -> None: openid = root.findtext("FromUserName") or "" msg_type = root.findtext("MsgType") or "" if msg_type == "image": pic_url = root.findtext("PicUrl") or "" media_id = root.findtext("MediaId") or "" logger.info( "wx_mp 收到图片: openid=%s media_id=%s pic_url=%s", openid, media_id, pic_url ) # PoC: 写死"该测试设备要从美团比价", 下次心跳带回前端弹选平台窗 poc_dev = settings.WX_POC_TEST_DEVICE_ID if poc_dev: wx_poc_signal.set_pending(poc_dev, "meituan") logger.info("wx_mp PoC: 已给测试设备 %s 打比价信号 source=meituan", poc_dev) await _download(pic_url, openid, media_id) else: logger.info("wx_mp 收到消息(暂忽略): openid=%s type=%s", openid, msg_type) async def _download(pic_url: str, openid: str, media_id: str) -> None: """用 PicUrl 直接下载图片落盘。PicUrl 是临时公网链接,无需 access_token / IP 白名单。""" if not pic_url: return try: _INBOX.mkdir(parents=True, exist_ok=True) async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(pic_url) resp.raise_for_status() dest = _INBOX / f"{openid[:12]}_{media_id[:16]}.jpg" dest.write_bytes(resp.content) logger.info("wx_mp 图片已落盘: %s (%d bytes)", dest, len(resp.content)) except Exception: logger.exception("wx_mp 图片下载失败 pic_url=%s", pic_url)