dingdanquanliucheng/backend/app/api/wechat_callback.py
wsb1224 1d12d061e0 接入微信服务号模板消息,修复通知链路重复发送和静默失败
小程序订阅消息每订阅一次只能发一条,用户无法持续收到通知。改为接入微信
服务号模板消息,用户关注服务号后可无限次接收推送通知。

新增功能:
- ServiceAccountNotificationService:服务号模板消息发送服务
- 服务号事件回调(subscribe/unsubscribe)+ 手机号验证绑定流程
- PendingServiceBinding 模型:存储关注者的 openid 待绑定记录
- 小程序 bind-service 页面:输入手机号完成绑定
- 三个详情页(审批/任务/发厂)增加"绑定微信通知"入口
- web-sales NotificationBell 增加服务号关注引导
- web-admin SystemPage/ConfigPage 增加服务号绑定状态和模板配置展示

修复问题:
- EventBus 和 _notify_status_change 重复发送微信消息(业务员收到2条)
- EventBus order_status_changed 事件缺少模板映射导致微信通知静默丢失
- 所有微信发送失败被 except Exception: pass 静默吞掉,增加日志
- 定时任务(欠款/沉默客户/物流超时)不触发 WebSocket 实时通知
- reminder_method 配置未生效

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 17:17:06 +08:00

120 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""微信服务号事件回调模块。
接收微信服务号推送的事件消息(关注/取消关注等),
将关注者的 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)