fix: 关键词回复优先并后台触发B同步
将A回调链路调整为先返回关键词回复,再以后台任务触发B接口同步,避免B接口耗时或失败影响用户主回复;同时修正微信消息体加解密格式并固定B接口凭据来源。 Made-with: Cursor
This commit is contained in:
@@ -10,7 +10,4 @@ PB_COLLECTION = "wechat_private_users"
|
||||
|
||||
# A 回调触发 B 接口同步的默认配置
|
||||
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
|
||||
PM_SYNC_DEFAULT_WXBOT_BID = "4888c6ab5cb70dcc3adea1e2d2ff0201"
|
||||
PM_SYNC_BID_BY_CHANNEL = {
|
||||
"wx5cee92c2c93fb480": "4888c6ab5cb70dcc3adea1e2d2ff0201",
|
||||
}
|
||||
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
from typing import Tuple
|
||||
|
||||
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:
|
||||
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)
|
||||
decrypted = cipher.decrypt(cipher_bytes)
|
||||
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)
|
||||
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)
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
encrypted = cipher.encrypt(padded)
|
||||
|
||||
49
main.py
49
main.py
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
|
||||
from config import (
|
||||
@@ -19,7 +19,6 @@ from config import (
|
||||
PB_COLLECTION,
|
||||
PM_SYNC_AUTHTOKEN,
|
||||
PM_SYNC_DEFAULT_WXBOT_BID,
|
||||
PM_SYNC_BID_BY_CHANNEL,
|
||||
)
|
||||
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
|
||||
|
||||
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 {}
|
||||
bid_map = {str(k): str(v) for k, v in bid_map.items()}
|
||||
|
||||
wxbot_bid = bid_map.get(channel_id) or (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
||||
# B 接口配置与 A 回调路由解耦:固定使用 B 的默认凭据。
|
||||
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
||||
if not wxbot_bid:
|
||||
logger.warning("未配置可用 wxbot_bid,跳过 B 接口同步")
|
||||
return
|
||||
@@ -394,7 +391,7 @@ async def query_private_messages(
|
||||
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" 两种回调路径。
|
||||
"""
|
||||
@@ -415,11 +412,11 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
|
||||
logger.info(f"收到原始 body(未解密,前 500 字符):{cipher_b64[:500]}")
|
||||
|
||||
# 平台当前实际发送的是 {"encrypted":"..."} 这一层 JSON,需要先解析再取字段
|
||||
if cipher_b64.startswith("{"):
|
||||
if "encrypted" in cipher_b64:
|
||||
try:
|
||||
outer = json.loads(cipher_b64)
|
||||
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:
|
||||
# 如果解析失败,就按原始字符串继续处理(保持兼容)
|
||||
logger.warning("外层 JSON 解析失败,按原始 body 作为密文处理")
|
||||
@@ -474,20 +471,16 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
|
||||
except Exception:
|
||||
logger.exception("签名校验异常(忽略或按需处理)")
|
||||
|
||||
# 7. 根据业务逻辑构造应答
|
||||
# 7.1 先做用户资料同步(异常不影响主回复)
|
||||
try:
|
||||
await _sync_user_profile_from_b(data)
|
||||
except Exception:
|
||||
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
|
||||
|
||||
# 7.2 再生成业务回复
|
||||
# 7. 根据业务逻辑构造应答(关键词回复优先)
|
||||
try:
|
||||
answer_text = await handle_business(data)
|
||||
except Exception:
|
||||
logger.exception("业务处理异常,将返回兜底文案")
|
||||
answer_text = "系统繁忙,请稍后再试。"
|
||||
|
||||
# 7.1 主回复确定后再后台触发 B 同步,避免影响回复链路耗时
|
||||
background_tasks.add_task(_sync_user_profile_from_b, data)
|
||||
|
||||
resp_obj = {
|
||||
# 文本类型,内容为可被 JSON.parse 的 H5 结构字符串
|
||||
"answer_type": "text",
|
||||
@@ -500,7 +493,7 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
|
||||
|
||||
# 8. 加密响应 JSON
|
||||
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:
|
||||
logger.exception("响应 AES 加密失败")
|
||||
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)
|
||||
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/
|
||||
这种情况下,微信会 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)
|
||||
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 路径,同样处理。
|
||||
"""
|
||||
return await _handle_wechat_request(request, app_id)
|
||||
return await _handle_wechat_request(request, app_id, background_tasks)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -807,18 +800,18 @@ def private_message_page():
|
||||
|
||||
<script>
|
||||
const defaultPresets = [
|
||||
{
|
||||
id: 'eric-travel',
|
||||
name: 'Eric在旅行',
|
||||
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
|
||||
wxbot_bid: '75021053883a9090c67d5becde7f4b0a',
|
||||
},
|
||||
{
|
||||
id: 'xidu-shijie',
|
||||
name: '异度世界',
|
||||
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
|
||||
wxbot_bid: '4888c6ab5cb70dcc3adea1e2d2ff0201',
|
||||
},
|
||||
{
|
||||
id: 'eric-travel',
|
||||
name: 'Eric在旅居',
|
||||
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
|
||||
wxbot_bid: '75021053883a9090c67d5becde7f4b0a',
|
||||
},
|
||||
];
|
||||
|
||||
function initPresetSelector() {
|
||||
|
||||
Reference in New Issue
Block a user