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)
|