"""微信服务号事件回调模块。 接收微信服务号推送的事件消息(关注/取消关注等), 将关注者的 openid 存入 pending_service_binding 表, 等待用户在小程序中输入手机号完成绑定。 需要在微信服务号后台配置: - 服务器地址 URL: https://dda.gaowenbu.cn/api/wechat/callback - Token: 与 .env 中 WECHAT_SA_TOKEN 一致 """ import hashlib import logging import xml.etree.ElementTree as ET from fastapi import APIRouter, Query, Request, Response from sqlalchemy import select from sqlalchemy.orm import Session from backend.app.core.config import get_settings from backend.app.db import SessionLocal from backend.app.models.system import User, PendingServiceBinding logger = logging.getLogger(__name__) router = APIRouter(tags=["wechat-callback"]) def _check_signature(signature: str, timestamp: str, nonce: str, token: str) -> bool: """验证微信签名。""" items = sorted([token, timestamp, nonce]) s = "".join(items).encode("utf-8") return hashlib.sha1(s).hexdigest() == signature def _parse_xml_message(xml_str: str) -> dict: """解析微信推送的 XML 消息。""" result = {} try: root = ET.fromstring(xml_str) for child in root: result[child.tag] = child.text or "" except ET.ParseError: pass return result @router.get("/api/wechat/callback") def verify_callback( signature: str = Query(...), timestamp: str = Query(...), nonce: str = Query(...), echostr: str = Query(...), ): """微信服务器验证接口(GET 请求)。""" settings = get_settings() if _check_signature(signature, timestamp, nonce, settings.wechat_sa_token): return Response(content=echostr, media_type="text/plain") return Response(content="invalid", media_type="text/plain", status_code=403) @router.post("/api/wechat/callback") async def handle_callback(request: Request): """微信事件推送接口(POST 请求)。""" xml_str = (await request.body()).decode("utf-8") msg = _parse_xml_message(xml_str) msg_type = msg.get("MsgType", "") event = msg.get("Event", "").lower() from_user = msg.get("FromUserName", "") if msg_type == "event" and event in ("subscribe", "unsubscribe"): session = SessionLocal() try: if event == "subscribe": _handle_subscribe(session, from_user) elif event == "unsubscribe": _handle_unsubscribe(session, from_user) session.commit() except Exception: session.rollback() logger.exception("处理服务号事件失败: event=%s, openid=%s", event, from_user) finally: session.close() return Response(content="success", media_type="text/plain") def _handle_subscribe(session: Session, openid: str) -> None: """处理关注事件:存入 pending_service_binding 表。""" existing = session.execute( select(PendingServiceBinding).where(PendingServiceBinding.service_open_id == openid) ).scalar_one_or_none() if existing: existing.bound = False session.add(existing) logger.info("服务号重新关注: openid=%s, 更新 pending binding", openid) else: binding = PendingServiceBinding(service_open_id=openid, bound=False) session.add(binding) logger.info("服务号新关注: openid=%s, 创建 pending binding", openid) def _handle_unsubscribe(session: Session, openid: str) -> None: """处理取消关注事件:清空用户 service_open_id,删除 pending binding。""" user = session.execute( select(User).where(User.service_open_id == openid) ).scalar_one_or_none() if user is not None: user.service_open_id = None session.add(user) logger.info("用户 %s (id=%s) 取消关注服务号", user.real_name, user.id) pending = session.execute( select(PendingServiceBinding).where(PendingServiceBinding.service_open_id == openid) ).scalar_one_or_none() if pending: session.delete(pending)