feat: 内置同步配置并增强回调匹配

将 PocketBase 与 B 接口同步参数改为代码内默认配置,避免部署时额外手动配置环境变量;同时补充回调幂等、用户匹配增强与调试接口,提升私信回调链路稳定性。

Made-with: Cursor
This commit is contained in:
eric
2026-03-31 10:29:12 -05:00
parent 1f7894c768
commit 256ba2c52b
2 changed files with 522 additions and 19 deletions

View File

@@ -1,3 +1,16 @@
APP_ID = "fXYWvGfq04uMScP"
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
# PocketBase默认直连本机与 payjsapi 项目保持同风格)
PB_URL = "http://127.0.0.1:8090"
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
PB_ADMIN_PASSWORD = "Xiao4669805@"
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",
}

528
main.py
View File

@@ -1,6 +1,7 @@
import json
import logging
import base64
import time
from datetime import datetime, timezone
from typing import Optional
@@ -8,7 +9,18 @@ import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import PlainTextResponse, HTMLResponse
from config import APP_ID, TOKEN, ENCODING_AES_KEY
from config import (
APP_ID,
TOKEN,
ENCODING_AES_KEY,
PB_URL,
PB_ADMIN_EMAIL,
PB_ADMIN_PASSWORD,
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
@@ -17,6 +29,294 @@ logger = logging.getLogger(__name__)
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 字符串)。
"""
q = (query or "").strip()
if not q:
return None
if "电子书" in q:
msg = {
"news": {
"articles": [
{
"title": "数字游民",
"description": "地理套利与自动化杠杆",
"url": f"https://www.hackrobot.cn?userid={user_id}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
return json.dumps(msg, ensure_ascii=False)
if "" in q:
msg = {
"news": {
"articles": [
{
"title": "异度星球",
"description": "电子书、视频教程、私密社群",
"url": f"https://vip.hackrobot.cn?userid={user_id}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
return json.dumps(msg, ensure_ascii=False)
return None
def _safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
def _to_int(value) -> Optional[int]:
try:
return int(str(value).strip())
except Exception:
return None
def _normalize_text(value) -> str:
return str(value or "").strip()
def _build_callback_dedupe_key(data: dict) -> str:
userid = _normalize_text(data.get("UserId") or data.get("userId"))
msg_id = _normalize_text(data.get("MsgId") or data.get("MessageId") or data.get("msgId"))
ts = _normalize_text(data.get("Timestamp") or data.get("MessageTime") or data.get("msgTime"))
query = _normalize_text(data.get("Query"))
return "|".join([userid, msg_id, ts, query])
def _is_duplicate_callback(data: dict, ttl_seconds: int = 3600) -> bool:
"""
回调幂等(进程内):同 userid/msg_id/time/query 在 TTL 内仅处理一次同步逻辑。
不影响主回复。
"""
now_ts = int(time.time())
# 清理过期 key避免缓存无限增长
expired = [k for k, exp in _processed_callback_cache.items() if exp <= now_ts]
for k in expired:
_processed_callback_cache.pop(k, None)
key = _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
return True
_processed_callback_cache[key] = now_ts + ttl_seconds
return False
def _extract_b_last_msg(contact: dict) -> dict:
return (contact.get("lastMsg") or {}) if isinstance(contact, dict) else {}
def _pick_best_contact_match(contacts: list, target_userid: str, source_query: str, source_msg_id: str, source_msg_time) -> Optional[dict]:
"""
在同 userid 的候选中做二次匹配:
- msg_id 一致优先
- 文本一致次优
- 时间最接近再次优先
"""
if not target_userid:
return None
same_user = [
c for c in contacts
if _normalize_text(c.get("userId") or c.get("userid")) == target_userid
]
if not same_user:
return None
query_norm = _normalize_text(source_query)
source_msg_id_norm = _normalize_text(source_msg_id)
src_ts = _to_int(source_msg_time)
def score(contact: dict) -> tuple[int, int, int]:
last_msg = _extract_b_last_msg(contact)
last_msg_id = _normalize_text(last_msg.get("msgId") or last_msg.get("id"))
last_content = _normalize_text(last_msg.get("content"))
last_active = _to_int(contact.get("lastActiveTime"))
msg_id_hit = 1 if source_msg_id_norm and last_msg_id and source_msg_id_norm == last_msg_id else 0
content_hit = 1 if query_norm and last_content and query_norm == last_content else 0
# 时间越接近分越高(负值用于降序)
if src_ts is None or last_active is None:
time_delta = 10**12
else:
time_delta = abs(src_ts - last_active)
return (msg_id_hit, content_hit, -time_delta)
return sorted(same_user, key=score, reverse=True)[0]
async def _pb_upsert_user_profile(record: dict) -> None:
"""
使用 PocketBase Admin API upsert 用户资料。
需要环境变量:
- PB_URL
- PB_ADMIN_EMAIL
- PB_ADMIN_PASSWORD
可选:
- PB_COLLECTION默认 wechat_private_users
"""
pb_url = (PB_URL or "").rstrip("/")
pb_admin_email = PB_ADMIN_EMAIL or ""
pb_admin_password = PB_ADMIN_PASSWORD or ""
pb_collection = PB_COLLECTION or "wechat_private_users"
if not pb_url or not pb_admin_email or not pb_admin_password:
logger.warning("PocketBase 配置不完整,跳过资料同步")
return
userid = str(record.get("userid", "")).strip()
channel_id = str(record.get("channel_id", "")).strip()
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,
)
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()
async def _sync_user_profile_from_b(data: dict) -> None:
"""
A 接口收到消息后,立即调用 B 接口拉取会话并同步用户资料到 PocketBase。
环境变量:
- PM_SYNC_AUTHTOKEN必填
- PM_SYNC_DEFAULT_WXBOT_BID可选
- PM_SYNC_BID_BY_CHANNEL_JSON可选形如 {"wx5cee...":"4888..."}
"""
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
if not authtoken:
logger.info("未配置 PM_SYNC_AUTHTOKEN跳过 B 接口同步")
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()
if not wxbot_bid:
logger.warning("未配置可用 wxbot_bid跳过 B 接口同步")
return
target_userid = str(data.get("UserId", "") or data.get("userId", "")).strip()
if not target_userid:
return
if _is_duplicate_callback(data):
logger.info("命中回调幂等缓存跳过重复同步userid=%s", target_userid)
return
msg_id = str(data.get("MsgId", "") or data.get("MessageId", "") or data.get("msgId", "")).strip()
msg_time = data.get("Timestamp", "") or data.get("MessageTime", "") or data.get("msgTime", "")
query = str(data.get("Query", "")).strip()
b_resp = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=0,
size=100,
filter_value=0,
request_id=f"thirdapi-{target_userid}",
)
contacts = b_resp.get("contacts") or []
target = _pick_best_contact_match(
contacts=contacts,
target_userid=target_userid,
source_query=query,
source_msg_id=msg_id,
source_msg_time=msg_time,
)
if not target:
logger.info("B 接口未匹配到用户:%s", target_userid)
return
last_msg = target.get("lastMsg") or {}
record = {
"userid": target_userid,
"channel_id": channel_id,
"nick": target.get("nick", ""),
"avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") or "",
"source_query": query,
"source_message_id": msg_id,
"source_message_time": str(msg_time),
"matched_last_msg": last_msg.get("content", ""),
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
"raw_contact": json.dumps(target, ensure_ascii=False),
}
await _pb_upsert_user_profile(record)
@app.post("/debug/sync-test")
async def debug_sync_test(payload: dict):
"""
手动调试 A->B->PocketBase 同步链路。
body 示例:
{
"UserId": "...",
"ChannelId": "...",
"Query": "电子书",
"MsgId": "...",
"Timestamp": 1774933444
}
"""
await _sync_user_profile_from_b(payload or {})
return {
"ok": True,
"message": "sync triggered",
"dedupe_key": _build_callback_dedupe_key(payload or {}),
}
async def handle_business(data: dict) -> str:
"""
@@ -39,22 +339,10 @@ async def handle_business(data: dict) -> str:
# ]
# return "".join(parts)
# 当前示例:返回一个 H5 富文本结构(平台会对 short_answer 做 JSON.parse
h5_msg = {
"news": {
"articles": [
{
"title": "实时更新:新型肺炎疫情最新动态",
"description": "腾讯新闻第一时间同步全国新型肺炎疫情动态,欢迎关注、转发",
"url": "https://news.qq.com/zt2020/page/feiyan.htm",
"picurl": "http://mmbiz.qpic.cn/mmbiz_jpg/W3gQtpV3j8D8kZRqfpTJlfVqubwgFQf47H0GWlGV6leaDF80ZpdtuFhQVsCsM3YKmwkujXzdjR2k6aWfA41ic7Q/0?wx_fmt=jpeg",
"type": "h5",
}
]
}
}
# 按文档要求answer/short_answer 是字符串,内部再 JSON.parse 才是对象
return json.dumps(h5_msg, ensure_ascii=False)
keyword_reply = _build_keyword_h5_reply(query, user_id)
if keyword_reply:
return keyword_reply
return "已收到你的消息,输入“电子书”或“群”可获取对应卡片。"
def _decode_jwt_payload_without_verify(token: str) -> dict:
@@ -187,6 +475,13 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
logger.exception("签名校验异常(忽略或按需处理)")
# 7. 根据业务逻辑构造应答
# 7.1 先做用户资料同步(异常不影响主回复)
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
# 7.2 再生成业务回复
try:
answer_text = await handle_business(data)
except Exception:
@@ -314,7 +609,7 @@ def private_message_page():
color: #cbd5e1;
font-size: 13px;
}
textarea, input {
textarea, input, select {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.75);
@@ -325,7 +620,7 @@ def private_message_page():
outline: none;
transition: border-color .2s, box-shadow .2s;
}
textarea:focus, input:focus {
textarea:focus, input:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
}
@@ -362,6 +657,87 @@ def private_message_page():
color: var(--ok);
}
.status.warn { color: var(--warn); }
.panel-title {
margin: 0 0 8px;
font-size: 15px;
color: #dbeafe;
}
.token-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.token-kpi {
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 12px;
background: rgba(15, 23, 42, 0.55);
padding: 10px 12px;
}
.token-kpi .label {
color: var(--muted);
font-size: 12px;
margin-bottom: 4px;
}
.token-kpi .value {
font-size: 20px;
font-weight: 700;
color: #bfdbfe;
}
.user-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
margin-top: 6px;
}
.user-item {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
background: rgba(15, 23, 42, 0.55);
padding: 10px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 999px;
object-fit: cover;
background: rgba(148, 163, 184, 0.2);
border: 1px solid rgba(148, 163, 184, 0.35);
}
.avatar-placeholder {
width: 36px;
height: 36px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.35);
color: #e0e7ff;
font-size: 14px;
font-weight: 700;
border: 1px solid rgba(148, 163, 184, 0.35);
}
.user-meta {
min-width: 0;
display: grid;
gap: 2px;
}
.user-nick {
font-size: 14px;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-id {
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
pre {
margin: 0;
background: #090d18;
@@ -375,6 +751,7 @@ def private_message_page():
}
@media (max-width: 768px) {
.row { grid-template-columns: 1fr; }
.token-grid { grid-template-columns: 1fr; }
}
</style>
</head>
@@ -383,6 +760,8 @@ def private_message_page():
<section class="card">
<h2>私信查询调试台</h2>
<p class="sub">用于调试微信对话开放平台私信列表接口支持一键查询、Token 过期分析与结果可视化。</p>
<label>默认配置</label>
<select id="presetSelect"></select>
<label>authtoken</label>
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea>
<label>wxbot_bid</label>
@@ -406,6 +785,20 @@ def private_message_page():
<section class="card">
<p id="status" class="status">等待操作...</p>
<h3 class="panel-title">Token 过期信息</h3>
<div id="tokenReport" class="token-grid">
<div class="token-kpi">
<div class="label">状态</div>
<div id="tokenStatus" class="value">-</div>
</div>
<div class="token-kpi">
<div class="label">剩余时间</div>
<div id="tokenRemain" class="value">-</div>
</div>
</div>
<h3 class="panel-title" style="margin-top: 12px;">用户列表userid / 头像 / 昵称)</h3>
<div id="userList" class="user-list"></div>
<h3 class="panel-title" style="margin-top: 12px;">原始返回</h3>
<pre id="output">{
"message": "点击上方按钮开始查询"
}</pre>
@@ -413,6 +806,47 @@ def private_message_page():
</div>
<script>
const defaultPresets = [
{
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() {
const select = document.getElementById('presetSelect');
select.innerHTML = defaultPresets
.map((item) => `<option value="${item.id}">${item.name}</option>`)
.join('');
select.addEventListener('change', () => {
const preset = defaultPresets.find((x) => x.id === select.value);
if (!preset) return;
document.getElementById('authtoken').value = preset.authtoken;
document.getElementById('wxbot_bid').value = preset.wxbot_bid;
});
if (defaultPresets.length > 0) {
select.value = defaultPresets[0].id;
select.dispatchEvent(new Event('change'));
}
}
function updatePresetAuthtoken() {
const select = document.getElementById('presetSelect');
const selectedId = select.value;
const idx = defaultPresets.findIndex((x) => x.id === selectedId);
if (idx >= 0) {
defaultPresets[idx].authtoken = document.getElementById('authtoken').value.trim();
}
}
function setLoading(loading, text) {
document.getElementById('queryBtn').disabled = loading;
document.getElementById('analyzeBtn').disabled = loading;
@@ -426,7 +860,47 @@ def private_message_page():
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
}
function formatRemain(seconds) {
if (typeof seconds !== 'number') return '-';
if (seconds <= 0) return '已过期';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${days}天 ${hours}小时 ${mins}分钟`;
}
function renderTokenReport(report) {
const statusEl = document.getElementById('tokenStatus');
const remainEl = document.getElementById('tokenRemain');
const statusText = report?.status || '-';
const remainText = formatRemain(report?.analysis?.expires_in_seconds);
statusEl.textContent = statusText;
remainEl.textContent = remainText;
statusEl.style.color = statusText.includes('过期') ? '#fca5a5' : '#86efac';
remainEl.style.color = remainText === '已过期' ? '#fca5a5' : '#bfdbfe';
}
function renderUsers(users) {
const list = document.getElementById('userList');
if (!Array.isArray(users) || users.length === 0) {
list.innerHTML = '<div class="hint">当前暂无用户数据,先点击“查询私信”。</div>';
return;
}
list.innerHTML = users.map((u) => {
const nick = u.nick || '未命名用户';
const uid = u.userId || '未知 userid';
const avatar = u.avatar || '';
const safeNick = String(nick).replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeUid = String(uid).replace(/</g, '&lt;').replace(/>/g, '&gt;');
const avatarHtml = avatar
? `<img class="avatar" src="${avatar}" alt="${safeNick}" referrerpolicy="no-referrer" />`
: `<div class="avatar-placeholder">${safeNick.slice(0, 1)}</div>`;
return `<div class="user-item">${avatarHtml}<div class="user-meta"><div class="user-nick">${safeNick}</div><div class="user-id">${safeUid}</div></div></div>`;
}).join('');
}
async function queryPm() {
updatePresetAuthtoken();
const payload = {
authtoken: document.getElementById('authtoken').value.trim(),
wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
@@ -447,6 +921,7 @@ def private_message_page():
} else {
document.getElementById('status').textContent = '查询成功';
}
renderUsers(data.users);
render(data);
} catch (err) {
document.getElementById('status').classList.add('warn');
@@ -458,6 +933,7 @@ def private_message_page():
}
async function analyzeToken() {
updatePresetAuthtoken();
const payload = {
authtoken: document.getElementById('authtoken').value.trim(),
};
@@ -475,6 +951,7 @@ def private_message_page():
} else {
document.getElementById('status').textContent = '分析成功';
}
renderTokenReport(data);
render(data);
} catch (err) {
document.getElementById('status').classList.add('warn');
@@ -484,6 +961,9 @@ def private_message_page():
setLoading(false);
}
}
initPresetSelector();
renderUsers([]);
</script>
</body>
</html>
@@ -522,6 +1002,16 @@ async def api_private_message_query(payload: dict):
return {
"ok": True,
"count": result.get("count"),
"users": [
{
"nick": c.get("nick"),
"userId": c.get("userId") or c.get("userid"),
"avatar": c.get("avatar") or c.get("headImg") or c.get("headimgurl") or c.get("headImgUrl"),
"lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"),
}
for c in contacts
],
"contacts_preview": [
{
"nick": c.get("nick"),