feat: 内置同步配置并增强回调匹配
将 PocketBase 与 B 接口同步参数改为代码内默认配置,避免部署时额外手动配置环境变量;同时补充回调幂等、用户匹配增强与调试接口,提升私信回调链路稳定性。 Made-with: Cursor
This commit is contained in:
13
config.py
13
config.py
@@ -1,3 +1,16 @@
|
|||||||
APP_ID = "fXYWvGfq04uMScP"
|
APP_ID = "fXYWvGfq04uMScP"
|
||||||
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
|
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
|
||||||
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
|
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
528
main.py
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -8,7 +9,18 @@ import httpx
|
|||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
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
|
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")
|
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:
|
async def handle_business(data: dict) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -39,22 +339,10 @@ async def handle_business(data: dict) -> str:
|
|||||||
# ]
|
# ]
|
||||||
# return ";".join(parts)
|
# return ";".join(parts)
|
||||||
|
|
||||||
# 当前示例:返回一个 H5 富文本结构(平台会对 short_answer 做 JSON.parse)
|
keyword_reply = _build_keyword_h5_reply(query, user_id)
|
||||||
h5_msg = {
|
if keyword_reply:
|
||||||
"news": {
|
return keyword_reply
|
||||||
"articles": [
|
return "已收到你的消息,输入“电子书”或“群”可获取对应卡片。"
|
||||||
{
|
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_jwt_payload_without_verify(token: str) -> dict:
|
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("签名校验异常(忽略或按需处理)")
|
logger.exception("签名校验异常(忽略或按需处理)")
|
||||||
|
|
||||||
# 7. 根据业务逻辑构造应答
|
# 7. 根据业务逻辑构造应答
|
||||||
|
# 7.1 先做用户资料同步(异常不影响主回复)
|
||||||
|
try:
|
||||||
|
await _sync_user_profile_from_b(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
|
||||||
|
|
||||||
|
# 7.2 再生成业务回复
|
||||||
try:
|
try:
|
||||||
answer_text = await handle_business(data)
|
answer_text = await handle_business(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -314,7 +609,7 @@ def private_message_page():
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
textarea, input {
|
textarea, input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
background: rgba(15, 23, 42, 0.75);
|
background: rgba(15, 23, 42, 0.75);
|
||||||
@@ -325,7 +620,7 @@ def private_message_page():
|
|||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color .2s, box-shadow .2s;
|
transition: border-color .2s, box-shadow .2s;
|
||||||
}
|
}
|
||||||
textarea:focus, input:focus {
|
textarea:focus, input:focus, select:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
|
||||||
}
|
}
|
||||||
@@ -362,6 +657,87 @@ def private_message_page():
|
|||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.status.warn { color: var(--warn); }
|
.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 {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #090d18;
|
background: #090d18;
|
||||||
@@ -375,6 +751,7 @@ def private_message_page():
|
|||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.row { grid-template-columns: 1fr; }
|
.row { grid-template-columns: 1fr; }
|
||||||
|
.token-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -383,6 +760,8 @@ def private_message_page():
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>私信查询调试台</h2>
|
<h2>私信查询调试台</h2>
|
||||||
<p class="sub">用于调试微信对话开放平台私信列表接口,支持一键查询、Token 过期分析与结果可视化。</p>
|
<p class="sub">用于调试微信对话开放平台私信列表接口,支持一键查询、Token 过期分析与结果可视化。</p>
|
||||||
|
<label>默认配置</label>
|
||||||
|
<select id="presetSelect"></select>
|
||||||
<label>authtoken</label>
|
<label>authtoken</label>
|
||||||
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea>
|
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea>
|
||||||
<label>wxbot_bid</label>
|
<label>wxbot_bid</label>
|
||||||
@@ -406,6 +785,20 @@ def private_message_page():
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p id="status" class="status">等待操作...</p>
|
<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">{
|
<pre id="output">{
|
||||||
"message": "点击上方按钮开始查询"
|
"message": "点击上方按钮开始查询"
|
||||||
}</pre>
|
}</pre>
|
||||||
@@ -413,6 +806,47 @@ def private_message_page():
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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) {
|
function setLoading(loading, text) {
|
||||||
document.getElementById('queryBtn').disabled = loading;
|
document.getElementById('queryBtn').disabled = loading;
|
||||||
document.getElementById('analyzeBtn').disabled = loading;
|
document.getElementById('analyzeBtn').disabled = loading;
|
||||||
@@ -426,7 +860,47 @@ def private_message_page():
|
|||||||
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
|
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, '<').replace(/>/g, '>');
|
||||||
|
const safeUid = String(uid).replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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() {
|
async function queryPm() {
|
||||||
|
updatePresetAuthtoken();
|
||||||
const payload = {
|
const payload = {
|
||||||
authtoken: document.getElementById('authtoken').value.trim(),
|
authtoken: document.getElementById('authtoken').value.trim(),
|
||||||
wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
|
wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
|
||||||
@@ -447,6 +921,7 @@ def private_message_page():
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('status').textContent = '查询成功';
|
document.getElementById('status').textContent = '查询成功';
|
||||||
}
|
}
|
||||||
|
renderUsers(data.users);
|
||||||
render(data);
|
render(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('status').classList.add('warn');
|
document.getElementById('status').classList.add('warn');
|
||||||
@@ -458,6 +933,7 @@ def private_message_page():
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeToken() {
|
async function analyzeToken() {
|
||||||
|
updatePresetAuthtoken();
|
||||||
const payload = {
|
const payload = {
|
||||||
authtoken: document.getElementById('authtoken').value.trim(),
|
authtoken: document.getElementById('authtoken').value.trim(),
|
||||||
};
|
};
|
||||||
@@ -475,6 +951,7 @@ def private_message_page():
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('status').textContent = '分析成功';
|
document.getElementById('status').textContent = '分析成功';
|
||||||
}
|
}
|
||||||
|
renderTokenReport(data);
|
||||||
render(data);
|
render(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('status').classList.add('warn');
|
document.getElementById('status').classList.add('warn');
|
||||||
@@ -484,6 +961,9 @@ def private_message_page():
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initPresetSelector();
|
||||||
|
renderUsers([]);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -522,6 +1002,16 @@ async def api_private_message_query(payload: dict):
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"count": result.get("count"),
|
"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": [
|
"contacts_preview": [
|
||||||
{
|
{
|
||||||
"nick": c.get("nick"),
|
"nick": c.get("nick"),
|
||||||
|
|||||||
Reference in New Issue
Block a user