19a8310b23
在企业微信「微信客服」侧复刻服务号「发图 → 弹比价窗」的触发链路。下游(心跳下发 pending_compare → 前端弹窗 → 走比价)完全复用服务号 PoC, 本次仅新增上游入口。 - /wx/kf/callback: GET 接入验证(验签 + 解密 echostr 返回明文)+ POST 收 kf_msg_or_event 事件 → sync_msg 增量拉消息 → 客户(origin=3)发来的图片 / 小程序卡片 → set_pending(测试设备, "meituan") - wx_kf_client: access_token 进程内缓存 + sync_msg(按 open_kfid 游标续拉)+ 临时素材下载 - 回调加解密与公众号安全模式同源, 直接复用 wx_mp_crypto(receiveid 传 corpid), 未新写 crypto - config 增 WX_KF_CORP_ID/SECRET/TOKEN/AES_KEY + wx_kf_callback_configured(全空=拒收, 上线零风险) - 无新依赖、无 migration(信号进程内存、图片落文件) 依赖服务号 PoC 的下游(wx_poc_signal / device 心跳 / wx_mp_crypto), 故基于 poc/wx-screenshot-compare。 external_userid↔device 绑定 / 信号落库 / 平台识别沿用服务号 PoC 遗留(正式化用 unionid 绑定)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
7.3 KiB
Python
160 lines
7.3 KiB
Python
"""微信客服(企业微信)消息接收回调 /wx/kf/callback(裸路径, 非 /api/v1)。
|
|
|
|
对应企业微信「微信客服」→ 客服账号 → 接收消息回调里填的 URL。是服务号版(wx_mp.py)的
|
|
平行实现:下游(set_pending → 心跳下发 → 前端弹窗)完全一致, 只有上游"如何拿到用户消息"不同 ——
|
|
服务号是微信把消息直接 POST 给你;微信客服是 POST 一个事件通知, 你再用 <Token> 主动 sync_msg 拉。
|
|
|
|
GET : URL 接入验证 → msg_signature 验签(含密文 echostr) → 解密 echostr → 返回明文。
|
|
⚠️ 与公众号明文模式不同:企业微信 echostr 是密文, 必须解密后返回。
|
|
POST : 收事件(密文) → msg_signature 验签 → AES 解密 → 解析 XML。
|
|
Event=kf_msg_or_event → 取 <Token>/<OpenKfId> → 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)
|