105 lines
3.3 KiB
Python
105 lines
3.3 KiB
Python
# AI_CHANGELOG
|
||
# - 2026-02-19 | Prompt: 配置微信消息推送 | 新增微信消息推送回调接口,支持 GET 验签 + POST 消息接收
|
||
|
||
"""
|
||
微信消息推送回调接口
|
||
|
||
处理两类请求:
|
||
1. GET — 微信服务器验证(配置时触发一次)
|
||
2. POST — 接收微信推送的消息/事件
|
||
|
||
安全模式下需要解密消息体,当前先用明文模式跑通,后续切安全模式。
|
||
"""
|
||
|
||
import hashlib
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Query, Request, Response
|
||
|
||
from app.config import get
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/wx", tags=["微信回调"])
|
||
|
||
# Token 从环境变量读取,与微信后台填写的一致
|
||
# 放在 apps/backend/.env.local 中:WX_CALLBACK_TOKEN=你自定义的token
|
||
WX_CALLBACK_TOKEN: str = get("WX_CALLBACK_TOKEN", "")
|
||
|
||
|
||
def _check_signature(signature: str, timestamp: str, nonce: str) -> bool:
|
||
"""
|
||
验证请求是否来自微信服务器。
|
||
|
||
将 Token、timestamp、nonce 字典序排序后拼接,做 SHA1,
|
||
与 signature 比对。
|
||
"""
|
||
if not WX_CALLBACK_TOKEN:
|
||
logger.error("WX_CALLBACK_TOKEN 未配置")
|
||
return False
|
||
|
||
items = sorted([WX_CALLBACK_TOKEN, timestamp, nonce])
|
||
hash_str = hashlib.sha1("".join(items).encode("utf-8")).hexdigest()
|
||
return hash_str == signature
|
||
|
||
|
||
@router.get("/callback")
|
||
async def verify(
|
||
signature: str = Query(...),
|
||
timestamp: str = Query(...),
|
||
nonce: str = Query(...),
|
||
echostr: str = Query(...),
|
||
):
|
||
"""
|
||
微信服务器验证接口。
|
||
|
||
配置消息推送时微信会发 GET 请求,验签通过后原样返回 echostr。
|
||
"""
|
||
if _check_signature(signature, timestamp, nonce):
|
||
logger.info("微信回调验证通过")
|
||
# 必须原样返回 echostr(纯文本,不能包裹 JSON)
|
||
return Response(content=echostr, media_type="text/plain")
|
||
else:
|
||
logger.warning("微信回调验签失败: signature=%s", signature)
|
||
return Response(content="signature mismatch", status_code=403)
|
||
|
||
|
||
@router.post("/callback")
|
||
async def receive_message(
|
||
request: Request,
|
||
signature: str = Query(""),
|
||
timestamp: str = Query(""),
|
||
nonce: str = Query(""),
|
||
):
|
||
"""
|
||
接收微信推送的消息/事件。
|
||
|
||
当前为明文模式,直接解析 JSON 包体。
|
||
后续切安全模式时需增加 AES 解密逻辑。
|
||
"""
|
||
# 验签(POST 也带 signature 参数)
|
||
if not _check_signature(signature, timestamp, nonce):
|
||
logger.warning("消息推送验签失败")
|
||
return Response(content="signature mismatch", status_code=403)
|
||
|
||
# 解析消息体
|
||
body = await request.body()
|
||
content_type = request.headers.get("content-type", "")
|
||
|
||
if "json" in content_type:
|
||
import json
|
||
try:
|
||
data = json.loads(body)
|
||
except json.JSONDecodeError:
|
||
data = {"raw": body.decode("utf-8", errors="replace")}
|
||
else:
|
||
# XML 格式暂不解析,记录原文
|
||
data = {"raw_xml": body.decode("utf-8", errors="replace")}
|
||
|
||
logger.info("收到微信推送: MsgType=%s, Event=%s",
|
||
data.get("MsgType", "?"), data.get("Event", "?"))
|
||
|
||
# TODO: 根据 MsgType/Event 分发处理(客服消息、订阅事件等)
|
||
# 当前统一返回 success
|
||
return Response(content="success", media_type="text/plain")
|