fix
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -89,6 +89,17 @@ def init_schema(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_sync_messages_key ON sync_messages(key)")
|
||||
# 回调原始 body 落库,便于回溯与统计
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS callback_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
raw_body TEXT
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_key ON callback_log(key)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_received ON callback_log(received_at)")
|
||||
# 模型配置
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
|
||||
282
backend/main.py
282
backend/main.py
@@ -3,6 +3,14 @@ import html
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# 优先加载项目根目录的 .env(不依赖当前工作目录),使 HTTP_PROXY/HTTPS_PROXY 等生效
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
load_dotenv(os.path.join(_root, ".env"))
|
||||
except ImportError:
|
||||
pass
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -29,7 +37,8 @@ CHECK_STATUS_BASE_URL = os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.18
|
||||
# 消息实时回调:设置后 7006 将新消息 POST 到该地址,作为主接收入口(与 SetCallback 一致)
|
||||
CALLBACK_BASE_URL = (os.getenv("CALLBACK_BASE_URL") or "").strip().rstrip("/")
|
||||
SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.180:7765").rstrip("/")
|
||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
|
||||
# 滑块服务 7765 的 key,与账号 key 无关,默认使用提供方 QQ(使用其公共服务时必填)
|
||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", "408449830")
|
||||
# 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
||||
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
|
||||
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
|
||||
@@ -228,7 +237,7 @@ async def _register_message_callback(key: str) -> bool:
|
||||
callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
|
||||
body = {"CallbackURL": callback_url, "Enabled": True}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("SetCallback %s key=%s: %s %s", url, key[-4:] if len(key) >= 4 else "****", resp.status_code, resp.text[:200])
|
||||
@@ -340,6 +349,16 @@ class QrCodeRequest(BaseModel):
|
||||
Proxy: Optional[str] = ""
|
||||
IpadOrmac: Optional[str] = ""
|
||||
Check: Optional[bool] = False
|
||||
"""仅当需滑块且为「无数字」时传 True,重新取码用 Mac 设备;其余一律 iPad,传 Mac 易封号。"""
|
||||
force_mac: Optional[bool] = False
|
||||
|
||||
|
||||
class WakeUpRequest(BaseModel):
|
||||
"""唤醒登录(只限扫码登录),仅调用 7006 WakeUpLogin,不拉取二维码。"""
|
||||
key: str
|
||||
Check: Optional[bool] = False
|
||||
IpadOrmac: Optional[str] = "ipad"
|
||||
Proxy: Optional[str] = ""
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -371,23 +390,74 @@ def _proxy_from_env() -> str:
|
||||
)
|
||||
|
||||
|
||||
@app.post("/auth/wake")
|
||||
async def wake_up_login(body: WakeUpRequest):
|
||||
"""唤醒登录:仅调用上游 /login/WakeUpLogin(只限扫码登录),不获取二维码。"""
|
||||
key = (body.key or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
proxy = (body.Proxy or "").strip()
|
||||
if not proxy:
|
||||
proxy = _proxy_from_env()
|
||||
if proxy:
|
||||
logger.info("WakeUpLogin: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(proxy))
|
||||
else:
|
||||
logger.info("WakeUpLogin: Proxy 为空,请在 .env 中设置 HTTP_PROXY/HTTPS_PROXY,或登录页填写代理后重试")
|
||||
payload = {
|
||||
"Check": body.Check,
|
||||
"IpadOrmac": "ipad",
|
||||
"Proxy": proxy,
|
||||
}
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}/login/WakeUpLogin"
|
||||
logger.info("WakeUpLogin: key=%s, payload=%s, url=%s", key, payload, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream WakeUpLogin: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "upstream_connect_error", "detail": str(exc)},
|
||||
) from exc
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:500]
|
||||
logger.warning("WakeUpLogin bad response: status=%s, body=%s", resp.status_code, body_preview)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "upstream_bad_response", "status_code": resp.status_code, "body": body_preview},
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": True, "text": resp.text[:200]}
|
||||
logger.info("WakeUpLogin success: status=%s", resp.status_code)
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/auth/qrcode")
|
||||
async def get_login_qrcode(body: QrCodeRequest):
|
||||
key = body.key
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
payload = body.dict(exclude={"key"})
|
||||
if not (payload.get("Proxy") or "").strip():
|
||||
env_proxy = _proxy_from_env()
|
||||
if env_proxy:
|
||||
payload["Proxy"] = env_proxy
|
||||
logger.info("GetLoginQrCodeNewDirect: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(env_proxy))
|
||||
proxy = (body.Proxy or "").strip()
|
||||
if not proxy:
|
||||
proxy = _proxy_from_env()
|
||||
if not proxy:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="必须配置代理(Proxy)。服务器在香港,不上代理必封号,请填写 socks5 代理后再取码。",
|
||||
)
|
||||
payload = body.dict(exclude={"key", "force_mac"})
|
||||
payload["Check"] = False
|
||||
payload["IpadOrmac"] = "mac" if body.force_mac else "ipad"
|
||||
payload["Proxy"] = proxy
|
||||
logger.info("GetLoginQrCodeNewDirect: proxy=yes, force_mac=%s, IpadOrmac=%s", body.force_mac, payload["IpadOrmac"])
|
||||
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginQrCodeNewDirect"
|
||||
logger.info("GetLoginQrCodeNewDirect: key=%s, payload=%s, url=%s", key, payload, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginQrCodeNewDirect: %s", exc)
|
||||
@@ -452,7 +522,7 @@ async def get_online_status(
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginStatus"
|
||||
logger.info("GetLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginStatus: %s", exc)
|
||||
@@ -545,7 +615,7 @@ async def check_scan_status(
|
||||
url = f"{CHECK_STATUS_BASE_URL}/login/CheckLoginStatus"
|
||||
logger.info("CheckLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream CheckLoginStatus: %s", exc)
|
||||
@@ -557,7 +627,12 @@ async def check_scan_status(
|
||||
resp.status_code,
|
||||
body_full[:2000] if len(body_full) > 2000 else body_full,
|
||||
)
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json() if body_full.strip() else {}
|
||||
except Exception:
|
||||
data = {"Code": resp.status_code, "Text": body_full[:500] or "Non-JSON response"}
|
||||
if not isinstance(data, dict):
|
||||
data = {"Code": resp.status_code, "Text": str(data)[:500]}
|
||||
ticket = _extract_clean_ticket(data)
|
||||
if ticket:
|
||||
# data62 使用完整原始数据,来自 GetLoginQrCodeNewDirect 的存储或本次响应的 Data62
|
||||
@@ -635,7 +710,7 @@ async def slider_asset_proxy(path: str):
|
||||
"""代理 7765 的 assets(如 N_jYM_2V.js),避免跨域加载被 CORS 拦截。"""
|
||||
url = f"{SLIDER_VERIFY_BASE_URL.rstrip('/')}/assets/{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200])
|
||||
@@ -678,7 +753,7 @@ async def api_slider_verify_get(
|
||||
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
|
||||
params = {"key": key, "data62": (data62 or "").strip(), "original_ticket": ticket_val}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
# 返回上游的 body;若为 JSON 则解析后返回
|
||||
try:
|
||||
@@ -706,7 +781,7 @@ async def api_slider_verify_post(body: SliderVerifyBody):
|
||||
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
|
||||
params = {"key": body.key, "data62": (body.data62 or "").strip(), "original_ticket": ticket_val}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -966,7 +1041,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
# 打印回调原始内容,便于排查结构与字段(截断避免日志过大)
|
||||
# 打印回调原始内容,便于排查(截断避免日志过大)
|
||||
try:
|
||||
logger.info("callback/wechat-message raw body: %s", str(body)[:1000])
|
||||
except Exception:
|
||||
@@ -975,6 +1050,11 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
if not k:
|
||||
logger.warning("callback/wechat-message: missing key in query and body")
|
||||
return JSONResponse(content={"ok": False, "error": "missing key"}, status_code=200)
|
||||
# 原始 body 落库,便于回溯与统计
|
||||
try:
|
||||
store.append_callback_log(k, body if isinstance(body, dict) else {"raw": str(body)})
|
||||
except Exception as le:
|
||||
logger.warning("callback_log append failed: %s", le)
|
||||
try:
|
||||
payload: Any = body
|
||||
# 7006 回调当前格式示例:{"key": "...", "message": {...}, "type": "message"}
|
||||
@@ -988,6 +1068,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
if isinstance(inner, (dict, list)):
|
||||
payload = inner
|
||||
_on_ws_message(k, payload)
|
||||
logger.info("callback message saved to sync_messages, key=%s", k[:8] + "..." if len(k) > 8 else k)
|
||||
except Exception as e:
|
||||
logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e)
|
||||
return {"ok": True}
|
||||
@@ -997,7 +1078,7 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
|
||||
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
|
||||
payload = {"MsgItem": [{"ToUserName": to_user_name, "MsgType": 1, "TextContent": content}]}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1026,7 +1107,7 @@ async def _send_batch_upstream(key: str, items: List[dict]) -> dict:
|
||||
if not msg_items:
|
||||
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
|
||||
payload = {"MsgItem": msg_items}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1056,7 +1137,7 @@ async def _send_image_upstream(key: str, to_user_name: str, image_content: str,
|
||||
"AtWxIDList": at_wxid_list or [],
|
||||
}
|
||||
payload = {"MsgItem": [item]}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1128,6 +1209,16 @@ def _log_contact_list_response_structure(raw: dict) -> None:
|
||||
for k, v in list(data.items())[:5]:
|
||||
preview = str(v)[:80] if v is not None else "null"
|
||||
logger.info(" Data.%s: %s", k, preview)
|
||||
# 7006 常见为 Data.ContactList 对象,内挂 contactUsernameList 数组
|
||||
cl = data.get("ContactList") or data.get("contactList")
|
||||
if isinstance(cl, dict):
|
||||
cl_keys = list(cl.keys())
|
||||
logger.info(" Data.ContactList keys: %s", cl_keys)
|
||||
for uk in ("contactUsernameList", "ContactUsernameList", "UserNameList", "userNameList", "usernameList"):
|
||||
arr = cl.get(uk)
|
||||
if isinstance(arr, list):
|
||||
logger.info(" Data.ContactList.%s length=%s, sample=%s", uk, len(arr), arr[:3] if arr else [])
|
||||
break
|
||||
|
||||
|
||||
def _unwrap_wechat_field(v: Any) -> Any:
|
||||
@@ -1215,12 +1306,14 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
or data.get("wxcontactList")
|
||||
or data.get("CachedContactList")
|
||||
)
|
||||
# 7006 格式:ContactList 为对象,联系人 id 在 contactUsernameList 字符串数组里
|
||||
# 7006 格式:ContactList 为对象,联系人 id 在 contactUsernameList 等数组里
|
||||
if isinstance(contact_list, dict):
|
||||
username_list = (
|
||||
contact_list.get("contactUsernameList")
|
||||
or contact_list.get("ContactUsernameList")
|
||||
or contact_list.get("UserNameList")
|
||||
or contact_list.get("userNameList")
|
||||
or contact_list.get("usernameList")
|
||||
or []
|
||||
)
|
||||
if isinstance(username_list, list) and username_list:
|
||||
@@ -1241,23 +1334,21 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
continue
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
wxid = (
|
||||
wxid = _unwrap_wechat_field(
|
||||
x.get("wxid")
|
||||
or x.get("Wxid")
|
||||
or x.get("UserName")
|
||||
or x.get("userName")
|
||||
or x.get("Alias")
|
||||
or ""
|
||||
)
|
||||
remark = (
|
||||
) or ""
|
||||
remark = _unwrap_wechat_field(
|
||||
x.get("remark_name")
|
||||
or x.get("RemarkName")
|
||||
or x.get("NickName")
|
||||
or x.get("nickName")
|
||||
or x.get("DisplayName")
|
||||
or wxid
|
||||
)
|
||||
result.append({"wxid": wxid, "remark_name": remark})
|
||||
) or wxid
|
||||
result.append({"wxid": str(wxid).strip(), "remark_name": str(remark).strip()})
|
||||
return result
|
||||
|
||||
|
||||
@@ -1272,7 +1363,7 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||
max_rounds = 50
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
for round_num in range(max_rounds):
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
@@ -1282,9 +1373,26 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
chunk = _normalize_contact_list(raw)
|
||||
if not chunk and isinstance(raw, dict):
|
||||
chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
|
||||
if round_num == 0 and not chunk and isinstance(raw, dict):
|
||||
_log_contact_list_response_structure(raw)
|
||||
for item in chunk:
|
||||
# 首轮无归一化结果时,直接从 Data.ContactList 下任意已知数组键取 id 列表(7006 格式)
|
||||
if not chunk and round_num == 0 and isinstance(raw, dict):
|
||||
data = raw.get("Data") or raw.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
cl = data.get("ContactList") or data.get("contactList")
|
||||
if isinstance(cl, dict):
|
||||
ul = (
|
||||
cl.get("contactUsernameList")
|
||||
or cl.get("ContactUsernameList")
|
||||
or cl.get("UserNameList")
|
||||
or cl.get("userNameList")
|
||||
or cl.get("usernameList")
|
||||
or []
|
||||
)
|
||||
if isinstance(ul, list) and ul:
|
||||
chunk = [{"wxid": (x if isinstance(x, str) else str(x)), "remark_name": ""} for x in ul]
|
||||
logger.info("GetContactList fallback from Data.ContactList.* list, count=%s", len(chunk))
|
||||
if not chunk:
|
||||
_log_contact_list_response_structure(raw)
|
||||
for item in chunk or []:
|
||||
wxid = (item.get("wxid") or "").strip()
|
||||
if wxid and wxid not in seen:
|
||||
seen.add(wxid)
|
||||
@@ -1306,14 +1414,15 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
return usernames
|
||||
|
||||
|
||||
async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
async def _build_contact_index(key: str, force_refresh: bool = False) -> Dict[str, dict]:
|
||||
"""
|
||||
通用联系人索引:
|
||||
- 先通过 GetContactList 拿到全部 UserName 列表;
|
||||
- 再通过 /friend/GetContactDetailsList 批量拉取详情;
|
||||
- 构建 name(微信号/昵称/备注) -> 联系人详情 的索引。
|
||||
force_refresh=True 时跳过内存缓存,重新请求上游。
|
||||
"""
|
||||
if key in _contact_index and _contact_index[key]:
|
||||
if not force_refresh and key in _contact_index and _contact_index[key]:
|
||||
return _contact_index[key]
|
||||
|
||||
usernames = await _fetch_all_contact_usernames(key)
|
||||
@@ -1323,83 +1432,68 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
|
||||
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactDetailsList"
|
||||
index: Dict[str, dict] = {}
|
||||
# 小批量遍历,多请求并发调用,直到全部返回(不把 contactUsernameList 整包当 UserNames 一次传)
|
||||
batch_size = 10
|
||||
max_concurrent = 6
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
chunk_size = 50
|
||||
for i in range(0, len(usernames), chunk_size):
|
||||
batch = usernames[i : i + chunk_size]
|
||||
body = {
|
||||
"RoomWxIDList": [],
|
||||
"UserNames": batch,
|
||||
}
|
||||
async def fetch_one_batch(client: httpx.AsyncClient, batch: List[str], batch_idx: int) -> List[dict]:
|
||||
body = {"RoomWxIDList": [], "UserNames": batch}
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
except Exception as e:
|
||||
logger.warning("GetContactDetailsList batch error: %s", e)
|
||||
continue
|
||||
logger.warning("GetContactDetailsList batch %s error: %s", batch_idx, e)
|
||||
return []
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("GetContactDetailsList %s: %s", resp.status_code, resp.text[:200])
|
||||
continue
|
||||
logger.warning("GetContactDetailsList batch %s %s: %s", batch_idx, resp.status_code, resp.text[:200])
|
||||
return []
|
||||
raw = resp.json()
|
||||
data = raw.get("Data") or raw.get("data") or raw
|
||||
# 打印一次结构,便于排查为何没有解析出联系人详情
|
||||
try:
|
||||
if i == 0:
|
||||
top_keys = list(raw.keys()) if isinstance(raw, dict) else type(raw).__name__
|
||||
data_keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
|
||||
logger.info(
|
||||
"GetContactDetailsList structure: top_keys=%s, Data_keys=%s, batch_size=%s",
|
||||
top_keys,
|
||||
data_keys,
|
||||
len(batch),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
# 7006 GetContactDetailsList 当前结构:Data.contactList 为联系人详情数组
|
||||
items = (
|
||||
data.get("List")
|
||||
or data.get("list")
|
||||
or data.get("ContactDetailsList")
|
||||
or data.get("contacts")
|
||||
or data.get("contactList")
|
||||
or data.get("ContactList") # 7006 可能用大写
|
||||
or []
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
items = data
|
||||
if not isinstance(items, list):
|
||||
# 结构不符时记录一条日志,帮助判断需要从哪里取联系人列表
|
||||
logger.info(
|
||||
"GetContactDetailsList no list items parsed, data_type=%s, sample=%s",
|
||||
type(data).__name__,
|
||||
str(data)[:200],
|
||||
)
|
||||
return []
|
||||
if not items and batch_idx == 0:
|
||||
logger.info("GetContactDetailsList batch 0: data keys=%s, no list parsed", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||
if batch_idx == 0 and items:
|
||||
sample = items[0]
|
||||
if isinstance(sample, dict):
|
||||
logger.info(
|
||||
"GetContactDetailsList first batch item keys=%s",
|
||||
list(sample.keys()),
|
||||
)
|
||||
return items
|
||||
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
batches = [usernames[i : i + batch_size] for i in range(0, len(usernames), batch_size)]
|
||||
tasks = [fetch_one_batch(client, b, i) for i, b in enumerate(batches)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, one in enumerate(results):
|
||||
if isinstance(one, BaseException):
|
||||
logger.warning("GetContactDetailsList batch %s exception: %s", i, one)
|
||||
continue
|
||||
# 追加一次示例项日志,便于确认字段名(UserName/NickName/RemarkName 等)
|
||||
try:
|
||||
if i == 0 and items:
|
||||
sample = items[0]
|
||||
if isinstance(sample, dict):
|
||||
logger.info(
|
||||
"GetContactDetailsList first item keys=%s, sample=%s",
|
||||
list(sample.keys()),
|
||||
str(sample)[:200],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
for d in items:
|
||||
for d in one or []:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
# 只保留 bitVal == 3 的联系人(如上游定义的「有效联系人」),其它忽略
|
||||
# 仅当上游明确返回 bitVal 且不为 3 时跳过(未返回或为 3 则保留,避免漏掉联系人)
|
||||
try:
|
||||
bit_val = int(d.get("bitVal") or 0)
|
||||
bv = d.get("bitVal")
|
||||
if bv is not None and int(bv) != 3:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
bit_val = 0
|
||||
if bit_val != 3:
|
||||
continue
|
||||
|
||||
# 7006 联系人详情字段为 userName/nickName/pyinitial/quanPin 等,内部多为 {'str': 'xxx'} 包装
|
||||
pass
|
||||
wxid = _unwrap_wechat_field(
|
||||
d.get("userName") or d.get("UserName") or d.get("user_name") or d.get("wxid")
|
||||
)
|
||||
@@ -1424,14 +1518,18 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
"quan_pin": quan_pin,
|
||||
"raw": d,
|
||||
}
|
||||
# 主键:wxid
|
||||
index[wxid] = info
|
||||
# 昵称 / 备注作为别名,便于按自然语言查找
|
||||
if nick and nick not in index:
|
||||
index[nick] = info
|
||||
if remark and remark not in index:
|
||||
index[remark] = info
|
||||
|
||||
if usernames and not index:
|
||||
logger.warning(
|
||||
"Contact index empty for key=***%s despite usernames count=%s: GetContactDetailsList may return different structure or all items filtered",
|
||||
key[-4:] if len(key) >= 4 else "****",
|
||||
len(usernames),
|
||||
)
|
||||
_contact_index[key] = index
|
||||
logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index))
|
||||
return index
|
||||
@@ -1484,10 +1582,14 @@ _NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate", "Pr
|
||||
|
||||
|
||||
@app.get("/api/contact-list")
|
||||
async def api_contact_list(key: str = Query(..., description="账号 key")):
|
||||
async def api_contact_list(
|
||||
key: str = Query(..., description="账号 key"),
|
||||
refresh: Optional[str] = Query(None, description="传 1/true/yes 时强制重新拉取,不用内存缓存"),
|
||||
):
|
||||
"""获取全部联系人详情:基于 GetContactList + GetContactDetailsList 构建的通用索引。禁止缓存。"""
|
||||
try:
|
||||
index = await _build_contact_index(key)
|
||||
force_refresh = (refresh or "").lower() in ("1", "true", "yes")
|
||||
index = await _build_contact_index(key, force_refresh=force_refresh)
|
||||
# 只返回去重后的联系人详情(以 wxid 主键)
|
||||
uniques: Dict[str, dict] = {}
|
||||
for name, info in index.items():
|
||||
@@ -1711,7 +1813,7 @@ async def logout(body: LogoutBody):
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut"
|
||||
logger.info("LogOut: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream LogOut: %s", exc)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx==0.27.0
|
||||
websockets>=12.0
|
||||
openai>=1.0.0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite,便于增删改查。"""
|
||||
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、回调原始日志、模型、AI 回复配置。使用 SQLite,便于增删改查。"""
|
||||
import datetime
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -403,6 +404,32 @@ def append_sent_message(key: str, to_user_name: str, content: str) -> None:
|
||||
append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}])
|
||||
|
||||
|
||||
def append_callback_log(key: str, raw_body: dict, max_raw_len: int = 51200) -> None:
|
||||
"""将 7006 回调的原始 body 落库,便于回溯与统计。raw_body 序列化后截断,避免单条过大。"""
|
||||
received_at = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
raw_str = json.dumps(raw_body, ensure_ascii=False)
|
||||
if len(raw_str) > max_raw_len:
|
||||
raw_str = raw_str[:max_raw_len] + "...[truncated]"
|
||||
with _LOCK:
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO callback_log (key, received_at, raw_body) VALUES (?,?,?)",
|
||||
(key, received_at, raw_str),
|
||||
)
|
||||
conn.commit()
|
||||
# 每个 key 仅保留最近 2000 条原始回调
|
||||
cur = conn.execute("SELECT id FROM callback_log WHERE key = ? ORDER BY id DESC", (key,))
|
||||
rows = cur.fetchall()
|
||||
if len(rows) > 2000:
|
||||
to_del = [r["id"] for r in rows[2000:]]
|
||||
placeholders = ",".join("?" * len(to_del))
|
||||
conn.execute(f"DELETE FROM callback_log WHERE id IN ({placeholders})", to_del)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- 模型 ----------
|
||||
def list_models() -> List[Dict]:
|
||||
with _LOCK:
|
||||
|
||||
@@ -37,7 +37,7 @@ async def _run_ws(key: str) -> None:
|
||||
while True:
|
||||
try:
|
||||
_ws_connected = False
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5) as ws:
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5, proxy=None) as ws:
|
||||
_ws_connected = True
|
||||
logger.info("WS connected for key=%s", key)
|
||||
while True:
|
||||
|
||||
Reference in New Issue
Block a user