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"
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
# PocketBase默认直连本机与 payjsapi 项目保持同风格)
PB_URL = "http://127.0.0.1:8090"
import os
# 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_PASSWORD = "Xiao4669805@"
PB_COLLECTION = "wechat_private_users"

247
main.py
View File

@@ -2,6 +2,8 @@ import json
import logging
import base64
import time
from html import escape
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Optional
@@ -31,16 +33,12 @@ app = FastAPI(title="WeChat Dialog ThirdAPI Demo")
_processed_callback_cache: dict[str, int] = {}
def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]:
"""
按关键词返回 H5 卡片answer/short_answer 需为 JSON 字符串)。
"""
def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
q = (query or "").strip()
if not q:
return None
if "电子书" in q:
msg = {
return {
"news": {
"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:
msg = {
return {
"news": {
"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
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:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
@@ -183,46 +296,55 @@ async def _pb_upsert_user_profile(record: dict) -> None:
if not userid:
return
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
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", "")
if not token:
logger.warning("PocketBase 登录无 token跳过资料同步")
return
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,
try:
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
f"{pb_url}/api/admins/auth-with-password",
json={"identity": pb_admin_email, "password": pb_admin_password},
)
# 兼容新旧 PocketBase旧版本 admins新版本 _superusers
if auth_resp.status_code == 404:
auth_resp = await client.post(
f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": pb_admin_email, "password": pb_admin_password},
)
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
create_resp = await client.post(
f"{pb_url}/api/collections/{pb_collection}/records",
json=record,
headers=headers,
)
create_resp.raise_for_status()
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()
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:
@@ -294,6 +416,16 @@ async def _sync_user_profile_from_b(data: dict) -> None:
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")
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")
# 4. 解析 JSON
# 4. 解析明文(兼容 JSON/XML
try:
data = json.loads(decrypted_json_str)
except json.JSONDecodeError:
logger.error(f"解密结果不是合法 JSON: {decrypted_json_str}")
raise HTTPException(status_code=400, detail="invalid json in decrypted body")
data = _parse_decrypted_payload(decrypted_json_str)
except Exception:
logger.error(f"解密结果无法解析为 JSON/XML: {decrypted_json_str}")
raise HTTPException(status_code=400, detail="invalid decrypted payload")
# 5. 打印请求内容到终端日志(分析、显示,带中文注释)
logger.info("收到微信第三方服务请求(已解密)")
@@ -478,8 +610,15 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
logger.exception("业务处理异常,将返回兜底文案")
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 同步,避免影响回复链路耗时
background_tasks.add_task(_sync_user_profile_from_b, data)
background_tasks.add_task(_safe_sync_user_profile_from_b, data)
resp_obj = {
# 文本类型,内容为可被 JSON.parse 的 H5 结构字符串