fix(sync): 空对话「已扫」记忆——根除 2,468 个无消息对话的每轮全量翻史

前项审计的深挖:带窗验证发现活跃仅 27 对话,但访问集仍有 2,493 个——
2,468 个「从未扫过」的对话(纯语音/从未聊过客户)水位恒 0,拉完 0 条
仍是 unscanned,下一轮又被全量翻史(~2.6s/个,每 4h×每晚都翻)——
这才是消息任务时长的真正黑洞(昨夜 53,682 条绝大部分源于此)。

修:复用 sync_state 表记扫描标记(entity_type={channel}_conv_empty)——
- 拉到 0 条有效消息即打标(微信/WA 1:1)
- 发现层排除「已扫空且不活跃」的;客户一旦发消息(last_chat_time 进
  活跃窗)自动回到访问集——不会漏新消息
- 周日 full_history 强制全访问兜底(云客补历史场景)
- 空群(115 个,每轮 ~5min)本轮不动,量小

722 测试绿。预期效果:4h 序列访问集 2,493→~30,时长分钟级。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-10 11:24:59 +08:00
parent a9197f696a
commit 602bee8fdf
+64 -2
View File
@@ -514,8 +514,49 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
await sleep(1)
# ---------------------------------------------------------------------------
# 空对话/空群「已扫」记忆(2026-09-10 同步审计):从未有消息的对话(纯语音/
# 未聊过客户,WA 实测 2,468 个)水位恒 0,每轮都被当 unscanned 全量翻史
# (~2.6s/个)——时长黑洞本体。拉到 0 条有效消息即记 sync_state 扫描标记;
# 发现层排除「已扫空且不活跃」的——客户一旦开始发消息(last_chat_time 进
# 活跃窗)自动回到访问集;周日 full_history 强制全访问兜底(云客补历史)。
# ---------------------------------------------------------------------------
async def _mark_scan_empty(kind: str, scope: str) -> None:
"""记录「该对话/群已扫且无消息」。UPDATE 0 行则 INSERT(sync_state 无唯一键)。"""
import time as _t
from app.db.base import async_engine
from sqlalchemy import text as sa_text
now_ms = int(_t.time() * 1000)
async with async_engine.begin() as conn:
res = await conn.execute(sa_text(
"UPDATE sync_state SET last_timestamp = :ts "
"WHERE entity_type = :et AND scope_id = :sid"
), {"et": kind, "sid": scope, "ts": now_ms})
if not res.rowcount:
await conn.execute(sa_text(
"INSERT INTO sync_state (entity_type, channel, scope_id, last_timestamp) "
"VALUES (:et, NULL, :sid, :ts)"
), {"et": kind, "sid": scope, "ts": now_ms})
async def _load_scan_empty(kind: str) -> set[str]:
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
rows = (await conn.execute(sa_text(
"SELECT scope_id FROM sync_state WHERE entity_type = :et"
), {"et": kind})).fetchall()
return {r[0] for r in rows if r[0]}
async def _discover_conversations(
channel: str, voice_channel: str, active_since_ms: int | None = None
channel: str, voice_channel: str, active_since_ms: int | None = None,
*, exclude_scanned_empty: bool = True,
) -> list[tuple[str, str]]:
"""Conversation-pair discovery for the message scrapers (wechat + WA).
@@ -561,6 +602,9 @@ async def _discover_conversations(
).fetchall()
have = {(r[0], r[1]) for r in with_msgs}
scanned_empty = (
await _load_scan_empty(f"{channel}_conv_empty") if exclude_scanned_empty else set()
)
seen: set[tuple[str, str]] = set()
fresh: list[tuple[str, str]] = []
known: list[tuple[str, str]] = []
@@ -576,7 +620,18 @@ async def _discover_conversations(
return
seen.add(key)
if key not in have:
fresh.append(key) # 从未扫过:新覆盖优先,不受窗口限制
# 从未扫过:新覆盖优先,不受窗口限制。但「已扫空且不活跃」的除外
# (2026-09-10:WA 2,468 个无消息对话每轮全翻史的黑洞)——一旦
# 活跃(last_chat_time 进窗)自动回到访问集;full_history 强制全访。
scope = f"{key[0]}:{key[1]}"
if (
exclude_scanned_empty
and scope in scanned_empty
and active_since_ms is not None
and not _active(last_ts)
):
return
fresh.append(key)
elif _active(last_ts):
known.append(key)
# else: 已扫过且陈旧 → 本轮跳过
@@ -674,6 +729,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
# full_history(周日对齐)强制全访问:云客可能补出历史消息
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat")
@@ -815,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
continue
total_fetched += len(records)
if not records:
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
total_pages += pages
if records:
@@ -2201,6 +2260,7 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp")
@@ -2285,6 +2345,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
continue
total_fetched += len(records)
if not records:
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
if records:
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)