This commit is contained in:
eric
2026-03-31 10:56:15 -05:00
parent d695f87e33
commit c98bd91fb9
5 changed files with 209 additions and 56 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -2,8 +2,22 @@ APP_ID = "fXYWvGfq04uMScP"
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn" TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx" ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
# PocketBase默认直连本机与 payjsapi 项目保持同风格) import os
PB_URL = "http://127.0.0.1:8090"
# PocketBase 自动切换策略:
# - 本地调试Windows优先使用数据库域名
# - 线上(非 Windows优先使用内网 IP+端口
# 你也可以通过环境变量 PB_URL 强制覆盖。
PB_DOMAIN_URL = "https://pocketbase.hackrobot.cn"
PB_INTERNAL_URL = "http://127.0.0.1:8090"
_pb_force = (os.getenv("PB_URL") or "").strip()
if _pb_force:
PB_URL = _pb_force
elif os.name == "nt":
PB_URL = PB_DOMAIN_URL
else:
PB_URL = PB_INTERNAL_URL
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com" PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
PB_ADMIN_PASSWORD = "Xiao4669805@" PB_ADMIN_PASSWORD = "Xiao4669805@"
PB_COLLECTION = "wechat_private_users" PB_COLLECTION = "wechat_private_users"

247
main.py
View File

@@ -2,6 +2,8 @@ import json
import logging import logging
import base64 import base64
import time import time
from html import escape
import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -31,16 +33,12 @@ app = FastAPI(title="WeChat Dialog ThirdAPI Demo")
_processed_callback_cache: dict[str, int] = {} _processed_callback_cache: dict[str, int] = {}
def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]: def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
"""
按关键词返回 H5 卡片answer/short_answer 需为 JSON 字符串)。
"""
q = (query or "").strip() q = (query or "").strip()
if not q: if not q:
return None return None
if "电子书" in q: if "电子书" in q:
msg = { return {
"news": { "news": {
"articles": [ "articles": [
{ {
@@ -53,10 +51,8 @@ def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]:
] ]
} }
} }
return json.dumps(msg, ensure_ascii=False)
if "" in q: if "" in q:
msg = { return {
"news": { "news": {
"articles": [ "articles": [
{ {
@@ -69,11 +65,128 @@ def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]:
] ]
} }
} }
return json.dumps(msg, ensure_ascii=False)
return None return None
def _parse_decrypted_payload(decrypted_text: str) -> dict:
"""
同时兼容 JSON 与 XML 的回调明文,统一成业务侧可读字段。
"""
text = (decrypted_text or "").strip()
if not text:
raise ValueError("empty decrypted payload")
# 1) 优先 JSON
if text.startswith("{"):
obj = json.loads(text)
if not isinstance(obj, dict):
raise ValueError("invalid json payload")
obj["_payload_format"] = "json"
return obj
# 2) XML第三方客服回调常见
if text.startswith("<"):
root = ET.fromstring(text)
def get_text(tag: str, default: str = "") -> str:
node = root.find(tag)
return (node.text or "").strip() if node is not None and node.text is not None else default
content_node = root.find("content")
query = ""
msg_type = get_text("msgtype", "")
if content_node is not None:
msg_node = content_node.find("msg")
type_node = content_node.find("msgtype")
if msg_node is not None and msg_node.text:
query = msg_node.text.strip()
if type_node is not None and type_node.text:
msg_type = type_node.text.strip()
return {
# 统一成原业务逻辑使用的字段
"UserId": get_text("userid", ""),
"Query": query,
"Timestamp": get_text("createtime", ""),
"MsgId": get_text("msgid", ""),
"ChannelId": get_text("appid", ""),
"MessageType": msg_type,
# 保留原始字段便于日志与后续扩展
"appid": get_text("appid", ""),
"channel": get_text("channel", ""),
"from": get_text("from", ""),
"status": get_text("status", ""),
"kfstate": get_text("kfstate", ""),
"_payload_format": "xml",
}
raise ValueError("unsupported decrypted payload format")
def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]:
"""
按关键词返回 H5 卡片answer/short_answer 需为 JSON 字符串)。
"""
msg = _build_keyword_h5_obj(query, user_id)
return json.dumps(msg, ensure_ascii=False) if msg else None
async def _send_kefu_msg_for_xml_callback(data: dict) -> None:
"""
XML 回调渠道下,主动调用 sendmsg 下发消息(回调响应体不会直接展示给用户)。
"""
query = str(data.get("Query", "")).strip()
user_id = str(data.get("UserId", "")).strip()
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
channel = int(str(data.get("channel", "0") or "0"))
msg = _build_keyword_h5_obj(query, user_id)
if not msg:
return
url = f"https://chatbot.weixin.qq.com/openapi/sendmsg/{TOKEN}"
body = {
"appid": appid,
"openid": user_id,
"msg": msg,
"channel": channel,
}
async with httpx.AsyncClient(timeout=10, verify=False) as client:
# 1) 先尝试明文 JSON 发送(多数场景可直接工作)
resp = await client.post(url, json=body, headers={"content-type": "application/json"})
resp.raise_for_status()
payload = resp.json() if resp.content else {}
errcode = int(payload.get("errcode", -1)) if isinstance(payload, dict) else -1
if errcode == 0:
logger.info("关键词命中sendmsg 明文发送成功")
return
logger.warning("sendmsg 明文返回非成功,准备加密重试:%s", payload)
# 2) 按文档加密格式重试body = {"encrypt": "..."}
msg_text = json.dumps(msg, ensure_ascii=False)
xml_data = (
"<xml>"
f"<appid><![CDATA[{escape(appid)}]]></appid>"
f"<openid><![CDATA[{escape(user_id)}]]></openid>"
f"<msg><![CDATA[{msg_text}]]></msg>"
f"<channel>{channel}</channel>"
"</xml>"
)
encrypted = aes_encrypt_base64(xml_data, ENCODING_AES_KEY, APP_ID)
enc_resp = await client.post(
url,
json={"encrypt": encrypted},
headers={"content-type": "application/json"},
)
enc_resp.raise_for_status()
enc_payload = enc_resp.json() if enc_resp.content else {}
enc_errcode = int(enc_payload.get("errcode", -1)) if isinstance(enc_payload, dict) else -1
if enc_errcode == 0:
logger.info("关键词命中sendmsg 加密发送成功")
return
logger.error("sendmsg 加密发送失败:%s", enc_payload)
def _safe_filter_value(value: str) -> str: def _safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"') return (value or "").replace("\\", "\\\\").replace('"', '\\"')
@@ -183,46 +296,55 @@ async def _pb_upsert_user_profile(record: dict) -> None:
if not userid: if not userid:
return return
async with httpx.AsyncClient(timeout=10, verify=False) as client: try:
auth_resp = await client.post( async with httpx.AsyncClient(timeout=10, verify=False) as client:
f"{pb_url}/api/admins/auth-with-password", auth_resp = await client.post(
json={"identity": pb_admin_email, "password": pb_admin_password}, f"{pb_url}/api/admins/auth-with-password",
) json={"identity": pb_admin_email, "password": pb_admin_password},
auth_resp.raise_for_status() )
token = (auth_resp.json() or {}).get("token", "") # 兼容新旧 PocketBase旧版本 admins新版本 _superusers
if not token: if auth_resp.status_code == 404:
logger.warning("PocketBase 登录无 token跳过资料同步") auth_resp = await client.post(
return f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": pb_admin_email, "password": pb_admin_password},
headers = {"Authorization": f"Bearer {token}"}
user_expr = _safe_filter_value(userid)
chan_expr = _safe_filter_value(channel_id)
filter_exp = f'userid="{user_expr}" && channel_id="{chan_expr}"' if channel_id else f'userid="{user_expr}"'
list_resp = await client.get(
f"{pb_url}/api/collections/{pb_collection}/records",
params={"page": 1, "perPage": 1, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
list_resp.raise_for_status()
items = ((list_resp.json() or {}).get("items")) or []
if items:
rid = items[0].get("id")
if rid:
patch_resp = await client.patch(
f"{pb_url}/api/collections/{pb_collection}/records/{rid}",
json=record,
headers=headers,
) )
patch_resp.raise_for_status() auth_resp.raise_for_status()
token = (auth_resp.json() or {}).get("token", "")
if not token:
logger.warning("PocketBase 登录无 token跳过资料同步")
return return
create_resp = await client.post( headers = {"Authorization": f"Bearer {token}"}
f"{pb_url}/api/collections/{pb_collection}/records", user_expr = _safe_filter_value(userid)
json=record, chan_expr = _safe_filter_value(channel_id)
headers=headers, filter_exp = f'userid="{user_expr}" && channel_id="{chan_expr}"' if channel_id else f'userid="{user_expr}"'
) list_resp = await client.get(
create_resp.raise_for_status() f"{pb_url}/api/collections/{pb_collection}/records",
params={"page": 1, "perPage": 1, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
list_resp.raise_for_status()
items = ((list_resp.json() or {}).get("items")) or []
if items:
rid = items[0].get("id")
if rid:
patch_resp = await client.patch(
f"{pb_url}/api/collections/{pb_collection}/records/{rid}",
json=record,
headers=headers,
)
patch_resp.raise_for_status()
return
create_resp = await client.post(
f"{pb_url}/api/collections/{pb_collection}/records",
json=record,
headers=headers,
)
create_resp.raise_for_status()
except httpx.HTTPError as e:
logger.error("PocketBase 同步失败:%s", str(e))
async def _sync_user_profile_from_b(data: dict) -> None: async def _sync_user_profile_from_b(data: dict) -> None:
@@ -294,6 +416,16 @@ async def _sync_user_profile_from_b(data: dict) -> None:
await _pb_upsert_user_profile(record) await _pb_upsert_user_profile(record)
async def _safe_sync_user_profile_from_b(data: dict) -> None:
"""
后台任务安全包装B/PocketBase 失败只记日志,不抛异常影响 ASGI。
"""
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.error("后台同步失败已忽略不影响A接口主回复")
@app.post("/debug/sync-test") @app.post("/debug/sync-test")
async def debug_sync_test(payload: dict): async def debug_sync_test(payload: dict):
""" """
@@ -431,12 +563,12 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
) )
raise HTTPException(status_code=400, detail="decrypt error") raise HTTPException(status_code=400, detail="decrypt error")
# 4. 解析 JSON # 4. 解析明文(兼容 JSON/XML
try: try:
data = json.loads(decrypted_json_str) data = _parse_decrypted_payload(decrypted_json_str)
except json.JSONDecodeError: except Exception:
logger.error(f"解密结果不是合法 JSON: {decrypted_json_str}") logger.error(f"解密结果无法解析为 JSON/XML: {decrypted_json_str}")
raise HTTPException(status_code=400, detail="invalid json in decrypted body") raise HTTPException(status_code=400, detail="invalid decrypted payload")
# 5. 打印请求内容到终端日志(分析、显示,带中文注释) # 5. 打印请求内容到终端日志(分析、显示,带中文注释)
logger.info("收到微信第三方服务请求(已解密)") logger.info("收到微信第三方服务请求(已解密)")
@@ -478,8 +610,15 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
logger.exception("业务处理异常,将返回兜底文案") logger.exception("业务处理异常,将返回兜底文案")
answer_text = "系统繁忙,请稍后再试。" answer_text = "系统繁忙,请稍后再试。"
# XML 回调场景需要主动 sendmsg 才能给用户展示消息
if str(data.get("_payload_format", "")) == "xml":
try:
await _send_kefu_msg_for_xml_callback(data)
except Exception:
logger.error("sendmsg 下发失败不影响回调200")
# 7.1 主回复确定后再后台触发 B 同步,避免影响回复链路耗时 # 7.1 主回复确定后再后台触发 B 同步,避免影响回复链路耗时
background_tasks.add_task(_sync_user_profile_from_b, data) background_tasks.add_task(_safe_sync_user_profile_from_b, data)
resp_obj = { resp_obj = {
# 文本类型,内容为可被 JSON.parse 的 H5 结构字符串 # 文本类型,内容为可被 JSON.parse 的 H5 结构字符串