5271d22d24
- 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 <noreply@anthropic.com>
107 lines
4.6 KiB
Python
107 lines
4.6 KiB
Python
"""微信服务号消息接收回调 /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)
|