小程序订阅消息每订阅一次只能发一条,用户无法持续收到通知。改为接入微信 服务号模板消息,用户关注服务号后可无限次接收推送通知。 新增功能: - 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>
120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""微信服务号事件回调模块。
|
||
|
||
接收微信服务号推送的事件消息(关注/取消关注等),
|
||
将关注者的 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)
|