fix: 关键词回复优先并后台触发B同步

将A回调链路调整为先返回关键词回复,再以后台任务触发B接口同步,避免B接口耗时或失败影响用户主回复;同时修正微信消息体加解密格式并固定B接口凭据来源。

Made-with: Cursor
This commit is contained in:
eric
2026-03-31 10:45:13 -05:00
parent 256ba2c52b
commit d695f87e33
3 changed files with 43 additions and 36 deletions

View File

@@ -10,7 +10,4 @@ PB_COLLECTION = "wechat_private_users"
# A 回调触发 B 接口同步的默认配置 # A 回调触发 B 接口同步的默认配置
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4" PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
PM_SYNC_DEFAULT_WXBOT_BID = "4888c6ab5cb70dcc3adea1e2d2ff0201" PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
PM_SYNC_BID_BY_CHANNEL = {
"wx5cee92c2c93fb480": "4888c6ab5cb70dcc3adea1e2d2ff0201",
}

View File

@@ -1,5 +1,7 @@
import base64 import base64
import hashlib import hashlib
import os
import struct
from typing import Tuple from typing import Tuple
from Crypto.Cipher import AES from Crypto.Cipher import AES
@@ -34,16 +36,31 @@ def get_aes_key_and_iv(encoding_aes_key: str) -> Tuple[bytes, bytes]:
def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str: def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str:
aes_key, iv = get_aes_key_and_iv(encoding_aes_key) aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
cipher_bytes = base64.b64decode(cipher_b64) # 回调密文偶尔会带换行/空白,先规整再补齐 base64 padding。
compact = "".join(str(cipher_b64 or "").split())
compact += "=" * (-len(compact) % 4)
cipher_bytes = base64.b64decode(compact)
cipher = AES.new(aes_key, AES.MODE_CBC, iv) cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(cipher_bytes) decrypted = cipher.decrypt(cipher_bytes)
decrypted = pkcs5_unpadding(decrypted) decrypted = pkcs5_unpadding(decrypted)
return decrypted.decode("utf-8") # 微信消息体格式16B随机串 + 4B网络序长度 + 明文 + appid
if len(decrypted) < 20:
raise ValueError("解密后消息长度非法")
content = decrypted[16:]
msg_len = struct.unpack("!I", content[:4])[0]
msg_start = 4
msg_end = msg_start + msg_len
if msg_end > len(content):
raise ValueError("解密后消息长度字段越界")
msg = content[msg_start:msg_end]
return msg.decode("utf-8")
def aes_encrypt_base64(plaintext: str, encoding_aes_key: str) -> str: def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str) -> str:
aes_key, iv = get_aes_key_and_iv(encoding_aes_key) aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
data = plaintext.encode("utf-8") raw = plaintext.encode("utf-8")
# 微信消息体格式16B随机串 + 4B网络序长度 + 明文 + appid
data = os.urandom(16) + struct.pack("!I", len(raw)) + raw + app_id.encode("utf-8")
padded = pkcs5_padding(data) padded = pkcs5_padding(data)
cipher = AES.new(aes_key, AES.MODE_CBC, iv) cipher = AES.new(aes_key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(padded) encrypted = cipher.encrypt(padded)

49
main.py
View File

@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
import httpx import httpx
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import PlainTextResponse, HTMLResponse from fastapi.responses import PlainTextResponse, HTMLResponse
from config import ( from config import (
@@ -19,7 +19,6 @@ from config import (
PB_COLLECTION, PB_COLLECTION,
PM_SYNC_AUTHTOKEN, PM_SYNC_AUTHTOKEN,
PM_SYNC_DEFAULT_WXBOT_BID, PM_SYNC_DEFAULT_WXBOT_BID,
PM_SYNC_BID_BY_CHANNEL,
) )
from crypto_utils import aes_decrypt_base64, aes_encrypt_base64, calc_signature from crypto_utils import aes_decrypt_base64, aes_encrypt_base64, calc_signature
@@ -240,10 +239,8 @@ async def _sync_user_profile_from_b(data: dict) -> None:
return return
channel_id = str(data.get("ChannelId", "") or data.get("channelId", "")).strip() channel_id = str(data.get("ChannelId", "") or data.get("channelId", "")).strip()
bid_map = PM_SYNC_BID_BY_CHANNEL if isinstance(PM_SYNC_BID_BY_CHANNEL, dict) else {} # B 接口配置与 A 回调路由解耦:固定使用 B 的默认凭据。
bid_map = {str(k): str(v) for k, v in bid_map.items()} wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
wxbot_bid = bid_map.get(channel_id) or (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
if not wxbot_bid: if not wxbot_bid:
logger.warning("未配置可用 wxbot_bid跳过 B 接口同步") logger.warning("未配置可用 wxbot_bid跳过 B 接口同步")
return return
@@ -394,7 +391,7 @@ async def query_private_messages(
return resp.json() return resp.json()
async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str: async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
""" """
公共处理逻辑:便于同时支持 "/""/wechat/thirdapi" 两种回调路径。 公共处理逻辑:便于同时支持 "/""/wechat/thirdapi" 两种回调路径。
""" """
@@ -415,11 +412,11 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
logger.info(f"收到原始 body未解密前 500 字符):{cipher_b64[:500]}") logger.info(f"收到原始 body未解密前 500 字符):{cipher_b64[:500]}")
# 平台当前实际发送的是 {"encrypted":"..."} 这一层 JSON需要先解析再取字段 # 平台当前实际发送的是 {"encrypted":"..."} 这一层 JSON需要先解析再取字段
if cipher_b64.startswith("{"): if "encrypted" in cipher_b64:
try: try:
outer = json.loads(cipher_b64) outer = json.loads(cipher_b64)
if isinstance(outer, dict) and "encrypted" in outer: if isinstance(outer, dict) and "encrypted" in outer:
cipher_b64 = str(outer["encrypted"]).replace("\n", "").strip() cipher_b64 = "".join(str(outer["encrypted"]).split()).strip()
except Exception: except Exception:
# 如果解析失败,就按原始字符串继续处理(保持兼容) # 如果解析失败,就按原始字符串继续处理(保持兼容)
logger.warning("外层 JSON 解析失败,按原始 body 作为密文处理") logger.warning("外层 JSON 解析失败,按原始 body 作为密文处理")
@@ -474,20 +471,16 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
except Exception: except Exception:
logger.exception("签名校验异常(忽略或按需处理)") logger.exception("签名校验异常(忽略或按需处理)")
# 7. 根据业务逻辑构造应答 # 7. 根据业务逻辑构造应答(关键词回复优先)
# 7.1 先做用户资料同步(异常不影响主回复)
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
# 7.2 再生成业务回复
try: try:
answer_text = await handle_business(data) answer_text = await handle_business(data)
except Exception: except Exception:
logger.exception("业务处理异常,将返回兜底文案") logger.exception("业务处理异常,将返回兜底文案")
answer_text = "系统繁忙,请稍后再试。" answer_text = "系统繁忙,请稍后再试。"
# 7.1 主回复确定后再后台触发 B 同步,避免影响回复链路耗时
background_tasks.add_task(_sync_user_profile_from_b, data)
resp_obj = { resp_obj = {
# 文本类型,内容为可被 JSON.parse 的 H5 结构字符串 # 文本类型,内容为可被 JSON.parse 的 H5 结构字符串
"answer_type": "text", "answer_type": "text",
@@ -500,7 +493,7 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
# 8. 加密响应 JSON # 8. 加密响应 JSON
try: try:
encrypted_resp = aes_encrypt_base64(resp_json_str, ENCODING_AES_KEY) encrypted_resp = aes_encrypt_base64(resp_json_str, ENCODING_AES_KEY, APP_ID)
except Exception as e: except Exception as e:
logger.exception("响应 AES 加密失败") logger.exception("响应 AES 加密失败")
raise HTTPException(status_code=500, detail="encrypt error") from e raise HTTPException(status_code=500, detail="encrypt error") from e
@@ -510,21 +503,21 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
@app.post("/", response_class=PlainTextResponse) @app.post("/", response_class=PlainTextResponse)
async def wechat_root(request: Request, app_id: Optional[str] = None): async def wechat_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
""" """
有些配置示例直接写根路径,例如: 有些配置示例直接写根路径,例如:
url: http://example.com/ url: http://example.com/
这种情况下,微信会 POST 到 "/",所以这里也做同样处理。 这种情况下,微信会 POST 到 "/",所以这里也做同样处理。
""" """
return await _handle_wechat_request(request, app_id) return await _handle_wechat_request(request, app_id, background_tasks)
@app.post("/wechat/thirdapi", response_class=PlainTextResponse) @app.post("/wechat/thirdapi", response_class=PlainTextResponse)
async def wechat_thirdapi(request: Request, app_id: Optional[str] = None): async def wechat_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
""" """
显式的 /wechat/thirdapi 路径,同样处理。 显式的 /wechat/thirdapi 路径,同样处理。
""" """
return await _handle_wechat_request(request, app_id) return await _handle_wechat_request(request, app_id, background_tasks)
@app.get("/health") @app.get("/health")
@@ -807,18 +800,18 @@ def private_message_page():
<script> <script>
const defaultPresets = [ const defaultPresets = [
{
id: 'eric-travel',
name: 'Eric在旅行',
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
wxbot_bid: '75021053883a9090c67d5becde7f4b0a',
},
{ {
id: 'xidu-shijie', id: 'xidu-shijie',
name: '异度世界', name: '异度世界',
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4', authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
wxbot_bid: '4888c6ab5cb70dcc3adea1e2d2ff0201', wxbot_bid: '4888c6ab5cb70dcc3adea1e2d2ff0201',
}, },
{
id: 'eric-travel',
name: 'Eric在旅居',
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
wxbot_bid: '75021053883a9090c67d5becde7f4b0a',
},
]; ];
function initPresetSelector() { function initPresetSelector() {