From 5271d22d2447b7cf5cc57f461f5106faa3e22249 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 16 Jul 2026 00:44:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(wx):=20=E5=BE=AE=E4=BF=A1=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=8F=B7=E6=B6=88=E6=81=AF=E6=8E=A5=E6=94=B6=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=20/wx/mp/callback=EF=BC=88=E6=88=AA=E5=9B=BE=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=E5=85=A5=E5=8F=A3=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET 接入验证(校验 signature → 原样返回 echostr),供公众平台配置服务器 URL - POST 收消息(安全模式:msg_signature 验签 + AES-256-CBC 解密),图片消息用 PicUrl 落盘 - config 加 WX_MP_TOKEN / WX_MP_AES_KEY + wx_mp_callback_configured - 识别平台订单/pending标记/openid↔device 绑定留后续 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/wx_mp.py | 106 +++++++++++++++++++++++++++++++ app/core/config.py | 11 ++++ app/integrations/wx_mp_crypto.py | 69 ++++++++++++++++++++ app/main.py | 3 + 4 files changed, 189 insertions(+) create mode 100644 app/api/v1/wx_mp.py create mode 100644 app/integrations/wx_mp_crypto.py diff --git a/app/api/v1/wx_mp.py b/app/api/v1/wx_mp.py new file mode 100644 index 0000000..f0a6d93 --- /dev/null +++ b/app/api/v1/wx_mp.py @@ -0,0 +1,106 @@ +"""微信服务号消息接收回调 /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.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 + ) + 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) diff --git a/app/core/config.py b/app/core/config.py index bf27932..d9e4f1e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -136,6 +136,12 @@ class Settings(BaseSettings): # (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。 WX_MP_OAUTH_ENABLED: bool = False + # ===== 微信服务号(消息接收回调) ===== + # 用户发消息给服务号 → 微信 POST 到 /wx/mp/callback(安全模式:Token 验签 + AESKey 解密)。 + # 区别于上面的网页授权(那是落地页拿 openid);这里是被动收用户主动发来的消息(截图比价入口)。 + WX_MP_TOKEN: str = "" # 公众平台"服务器配置"的 Token(URL 验签 + 消息验签) + WX_MP_AES_KEY: str = "" # EncodingAESKey(43 位, 消息 AES-256-CBC 加解密) + @property def wx_mp_configured(self) -> bool: """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" @@ -146,6 +152,11 @@ class Settings(BaseSettings): """落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。""" return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED + @property + def wx_mp_callback_configured(self) -> bool: + """消息接收回调凭证齐全(Token + AESKey + AppID)。缺则回调端点拒绝处理消息。""" + return bool(self.WX_MP_TOKEN and self.WX_MP_AES_KEY and self.WX_MP_APPID) + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 diff --git a/app/integrations/wx_mp_crypto.py b/app/integrations/wx_mp_crypto.py new file mode 100644 index 0000000..2e89968 --- /dev/null +++ b/app/integrations/wx_mp_crypto.py @@ -0,0 +1,69 @@ +"""微信服务号消息接收回调的验签与解密(安全模式)。 + +服务号"服务器配置"选安全模式后: + - URL 接入验证(GET): sha1(sort(token, timestamp, nonce)) == signature → 原样返回 echostr + - 消息(POST): body 里 是密文; + msg_signature = sha1(sort(token, timestamp, nonce, encrypt)) + 密文 AES-256-CBC 解出: random(16B) + msg_len(4B big-endian) + msg + appid, 去 PKCS7(块=32) padding + +只做验签 + 解密(收消息)。被动回复(加密)MVP 不需要 —— 收到后走客服消息异步回执。 +密钥/口令来自 settings(WX_MP_TOKEN / WX_MP_AES_KEY / WX_MP_APPID),本模块只做纯算法、不读配置。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def verify_url_signature(token: str, timestamp: str, nonce: str, signature: str) -> bool: + """GET 接入验证:token/timestamp/nonce 三者字典序排序拼接后 sha1。""" + return _consteq(_sha1(token, timestamp, nonce), signature) + + +def verify_msg_signature( + token: str, timestamp: str, nonce: str, encrypt: str, msg_signature: str +) -> bool: + """POST 消息验签:四者(含密文 encrypt)字典序排序拼接后 sha1。""" + return _consteq(_sha1(token, timestamp, nonce, encrypt), msg_signature) + + +def decrypt_message(aes_key_b64: str, expected_appid: str, encrypt_b64: str) -> str: + """解密 密文, 返回明文消息 XML。appid 不符抛 ValueError。 + + aes_key_b64: EncodingAESKey(43 位, 不含结尾 '='), 补 '=' 后 base64 解出 32 字节 AES-256 key。 + """ + aes_key = base64.b64decode(aes_key_b64 + "=") # 43 → 32 bytes + iv = aes_key[:16] + decryptor = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor() + plain = decryptor.update(base64.b64decode(encrypt_b64)) + decryptor.finalize() + plain = _pkcs7_unpad(plain) + + # random(16) + msg_len(4, big-endian) + msg(msg_len) + from_appid + content = plain[16:] + msg_len = int.from_bytes(content[:4], "big") + msg = content[4 : 4 + msg_len] + from_appid = content[4 + msg_len :].decode("utf-8") + if expected_appid and from_appid != expected_appid: + raise ValueError(f"appid mismatch: {from_appid!r} != {expected_appid!r}") + return msg.decode("utf-8") + + +def _sha1(*parts: str) -> str: + return hashlib.sha1("".join(sorted(parts)).encode("utf-8")).hexdigest() + + +def _consteq(a: str, b: str) -> bool: + return hmac.compare_digest(a, b) + + +def _pkcs7_unpad(data: bytes) -> bytes: + """微信用块大小 32 的 PKCS7,末字节即 padding 长度(1..32)。越界则原样返回(容错)。""" + if not data: + return data + pad = data[-1] + if pad < 1 or pad > 32: + return data + return data[:-pad] diff --git a/app/main.py b/app/main.py index 3b3b6ff..5c31f9f 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.api.v1.signin import router as signin_router from app.api.v1.tasks import router as tasks_router from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router +from app.api.v1.wx_mp import router as wx_mp_router from app.api.v1.wxpay import router as wxpay_router from app.core.config import settings from app.core.daily_exchange_worker import ( @@ -140,6 +141,8 @@ app.include_router(internal_launch_confirm_router) app.include_router(platform_router) # CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) app.include_router(cps_redirect_router) +# 微信服务号消息接收回调 /wx/mp/callback(公网无鉴权:微信服务器验签, 截图比价入口) +app.include_router(wx_mp_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT)