fix: 密集消息遗漏(issue #79)— 去重 key 加 local_id

根因:_shown_keys 之前用 (username, timestamp, msg_type) 当 key,导致
同秒同类型多条消息(如"逐条转发"10 条文字)的去重 key 完全相同。
SessionTable 触发 emit 第一条后把 key 加进 _shown_keys,
_check_hidden_messages 查到剩余 N-1 条时全部命中"已显示",全部跳过。
juneleung 实测 "10 丢 4"。

本地用解密后的 message_message_0.db 验证:
  - 真实数据存在 4 条同秒消息(local_id 72480..72483)
  - 旧逻辑:1/4 收到
  - 新逻辑:4/4 收到

改动:

1. _shown_keys 改用 (username, local_id) 精确去重
2. 新增 _lookup_latest_local_id(username, timestamp) — SessionTable 触发
   推送时查 message_N.db 拿对应 local_id
3. _check_hidden_messages 的 SQL 加 local_id 字段,过滤循环用 local_id
4. _shown_keys 清理逻辑改为按数量上限(local_id 不能按时间 prune)

时机风险:SessionTable 写入比 message DB 早几毫秒,_lookup_latest_local_id
可能查不到 → 返回 None,跳过加 key。_check_hidden_messages 1 秒后查到
该消息时自己加 key,结果是偶发轻微重复(比丢消息好)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ylytdeng
2026-05-12 13:39:44 +08:00
parent fe5cc633ff
commit c45c107f45

View File

@@ -9,6 +9,7 @@ http://localhost:5678
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback
import hmac as hmac_mod
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
@@ -934,6 +935,38 @@ class SessionMonitor:
except OSError:
pass
def _lookup_latest_local_id(self, username, timestamp):
"""从 message_N.db 查指定 username 在 timestamp 的最大 local_id。
SessionTable 触发推送时调用此方法拿到对应 local_id加到 _shown_keys 后
`_check_hidden_messages` 路径能用 (username, local_id) 精确去重,避免 issue #79
"同秒同类型多条消息 10 丢 4"
时机风险SessionTable 写入比 message DB 早几毫秒,可能查不到。查不到时返回 None
调用方应选择跳过加 key让 hidden 路径稍后补救并自己加 key
"""
if not self.db_cache or not self.username_db_map:
return None
db_keys = self.username_db_map.get(username, [])
if not db_keys:
return None
table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
for db_key in db_keys:
dec_path = self.db_cache.get(db_key)
if not dec_path:
continue
try:
with closing(sqlite3.connect(f"file:{dec_path}?mode=ro&immutable=1", uri=True)) as conn:
row = conn.execute(
f"SELECT MAX(local_id) FROM [{table_name}] WHERE create_time = ?",
(timestamp,),
).fetchone()
if row and row[0]:
return row[0]
except Exception:
continue
return None
def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender):
"""检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等)
@@ -964,10 +997,10 @@ class SessionMonitor:
try:
conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True)
rows = conn.execute(f"""
SELECT create_time, local_type, message_content, WCDB_CT_message_content
SELECT local_id, create_time, local_type, message_content, WCDB_CT_message_content
FROM [{table_name}]
WHERE create_time >= ? AND create_time <= ?
ORDER BY create_time ASC
ORDER BY create_time ASC, local_id ASC
""", (prev_ts, curr_ts)).fetchall()
conn.close()
all_rows.extend(rows)
@@ -976,7 +1009,8 @@ class SessionMonitor:
cache_failed = True
break
# 检查是否找到了 curr_ts 的消息(说明缓存是最新的)
has_curr = any(r[0] == curr_ts for r in all_rows)
# 注: r[1] 是 create_time新 schemalocal_id, create_time, local_type, ...
has_curr = any(r[1] == curr_ts for r in all_rows)
if has_curr or cache_failed:
break
# 缓存可能还没更新到最新数据,短暂等待后重试
@@ -997,11 +1031,13 @@ class SessionMonitor:
print(f" [hidden] 缓存查到 {len(all_rows)}", flush=True)
# 过滤出隐藏消息
# 去重 key 用 local_id之前用 (username, ts, base) 太粗,同秒同类型多条会被
# 误判为重复,导致 issue #79 的 "10 丢 4"
hidden_msgs = []
for ts, lt, mc, ct in all_rows:
for local_id, ts, lt, mc, ct in all_rows:
base = lt % 4294967296 if lt > 4294967296 else lt
# 跳过已显示的消息(精确匹配 username+timestamp+type
if (username, ts, base) in self._shown_keys:
# 跳过已显示的消息(按 local_id 精确去重
if (username, local_id) in self._shown_keys:
continue
# 解压 zstd
if isinstance(mc, bytes) and ct == 4:
@@ -1011,7 +1047,7 @@ class SessionMonitor:
mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else ''
elif isinstance(mc, bytes):
mc = mc.decode('utf-8', errors='replace')
hidden_msgs.append((ts, base, mc or ''))
hidden_msgs.append((local_id, ts, base, mc or ''))
print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True)
@@ -1019,8 +1055,8 @@ class SessionMonitor:
return
global messages_log
for ts, base, mc in hidden_msgs:
self._shown_keys.add((username, ts, base))
for local_id, ts, base, mc in hidden_msgs:
self._shown_keys.add((username, local_id))
msg_data = {
'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'),
'timestamp': ts,
@@ -1444,7 +1480,13 @@ class SessionMonitor:
}
new_msgs.append(msg_data)
self._shown_keys.add((username, curr['timestamp'], curr['msg_type']))
# _shown_keys 改用 (username, local_id) 精确去重issue #79
# SessionTable 不带 local_id去 message_N.db 查 max(local_id) WHERE create_time=curr_ts。
# 查不到时message DB 写入滞后于 SessionTable跳过加 key让 _check_hidden_messages
# 1 秒后查到时自己 emit 并加 key。这种情况下偶发轻微重复但比丢消息好。
latest_local_id = self._lookup_latest_local_id(username, curr['timestamp'])
if latest_local_id is not None:
self._shown_keys.add((username, latest_local_id))
# 图片消息: 后台异步解密(不阻塞轮询)
if curr['msg_type'] == 3:
@@ -1495,9 +1537,12 @@ class SessionMonitor:
self.prev_state = curr_state
# 清理过期的去重 key保留最近 5 分钟)
cutoff = int(time.time()) - 300
self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff}
# 清理 _shown_keys按数量上限local_id 不是时间戳不能按时间 prune。
# 超过 10000 时保留 local_id 最大的 5000 条(最新消息优先)。
# 实际触发频率:~几小时一次set lookup 仍是 O(1)。
if len(self._shown_keys) > 10000:
by_local_id = sorted(self._shown_keys, key=lambda k: k[1], reverse=True)
self._shown_keys = set(by_local_id[:5000])
def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map)