's'
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
config.py
18
config.py
@@ -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
247
main.py
@@ -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 结构字符串
|
||||||
|
|||||||
Reference in New Issue
Block a user