"""企业微信回调:GET 校验 + POST 消息回调(验签、解密、echo 回复、会话入库)。""" import time import logging import random import string from fastapi import APIRouter, Request, Query, Depends from fastapi.responses import PlainTextResponse from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.services.session_service import get_or_create_session, add_message from app.services.wecom_crypto import ( verify_and_decrypt_echostr, verify_signature, parse_encrypted_body, decrypt, parse_decrypted_xml, build_reply_xml, encrypt, make_reply_signature, build_encrypted_response, ) from app.logging_config import get_trace_id logger = logging.getLogger(__name__) router = APIRouter() @router.get("/callback") def wecom_verify( request: Request, signature: str = Query(None, alias="signature"), msg_signature: str = Query(None, alias="msg_signature"), timestamp: str = Query(..., alias="timestamp"), nonce: str = Query(..., alias="nonce"), echostr: str = Query(..., alias="echostr"), ): """企业微信 GET 验签:校验签名并解密 echostr,原样返回明文。 兼容 signature 和 msg_signature 两种参数名。 """ trace_id = get_trace_id() # 兼容 signature 和 msg_signature 两种参数名 sig = msg_signature or signature if not sig: logger.warning( "wecom verify missing signature", extra={"trace_id": trace_id, "query_params": dict(request.query_params)}, ) return PlainTextResponse("", status_code=400) plain = verify_and_decrypt_echostr(sig, timestamp, nonce, echostr) if plain is None: logger.warning( "wecom verify failed", extra={"trace_id": trace_id, "timestamp": timestamp, "nonce": nonce}, ) return PlainTextResponse("", status_code=400) logger.info( "wecom verify success", extra={"trace_id": trace_id, "echostr_length": len(echostr)}, ) return PlainTextResponse(plain) @router.post("/callback") async def wecom_callback( request: Request, signature: str = Query(None, alias="signature"), msg_signature: str = Query(None, alias="msg_signature"), timestamp: str = Query(..., alias="timestamp"), nonce: str = Query(..., alias="nonce"), db: AsyncSession = Depends(get_db), ): """POST 消息回调:验签、解密、会话与消息入库、文本 echo 回复。 兼容 signature 和 msg_signature 两种参数名。 """ trace_id = get_trace_id() # 兼容 signature 和 msg_signature 两种参数名 sig = msg_signature or signature if not sig: logger.warning( "wecom post missing signature", extra={"trace_id": trace_id, "query_params": dict(request.query_params)}, ) return PlainTextResponse("", status_code=400) body = await request.body() encrypt_raw, err = parse_encrypted_body(body) if err: logger.warning( "wecom post parse error", extra={"trace_id": trace_id, "error": err}, ) return PlainTextResponse("", status_code=400) if not verify_signature(sig, timestamp, nonce, encrypt_raw): logger.warning( "wecom post verify failed", extra={"trace_id": trace_id, "timestamp": timestamp}, ) return PlainTextResponse("", status_code=400) try: plain_xml = decrypt(encrypt_raw) except Exception as e: logger.warning( "wecom decrypt error", extra={"trace_id": trace_id, "error": str(e)}, ) return PlainTextResponse("", status_code=400) msg = parse_decrypted_xml(plain_xml) if not msg: logger.warning( "wecom xml parse failed", extra={"trace_id": trace_id}, ) return PlainTextResponse("", status_code=400) to_user = msg.get("ToUserName", "") from_user = msg.get("FromUserName", "") # external_userid msg_id = msg.get("MsgId", "") msg_type = msg.get("MsgType", "") content = (msg.get("Content") or "").strip() content_summary = content[:50] + "..." if len(content) > 50 else content # 记录日志:trace_id + external_userid + msgid + 内容摘要 logger.info( "wecom message received", extra={ "trace_id": trace_id, "external_userid": from_user, "msgid": msg_id, "msg_type": msg_type, "content_summary": content_summary or "(empty)", }, ) # 会话入库:external_user_id = from_user(客户) session = await get_or_create_session(db, from_user, msg.get("Contact")) await add_message(db, session.id, "user", content or "(非文本消息)") # Echo 文本:回复"已收到:{用户消息}" if msg_type == "text" and content: reply_content = f"已收到:{content}" else: reply_content = "已收到" await add_message(db, session.id, "assistant", reply_content) # 回复给客户(被动回复 XML) reply_xml = build_reply_xml(from_user, to_user, reply_content) enc = encrypt(reply_xml) ts = str(int(time.time())) reply_nonce = "".join(random.choices(string.ascii_letters + string.digits, k=16)) sig = make_reply_signature(enc, ts, reply_nonce) resp_xml = build_encrypted_response(enc, sig, ts, reply_nonce) logger.info( "wecom reply sent", extra={ "trace_id": trace_id, "external_userid": from_user, "msgid": msg_id, "reply_summary": reply_content[:50] + "..." if len(reply_content) > 50 else reply_content, }, ) return PlainTextResponse(resp_xml, media_type="application/xml")