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 hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback
import hmac as hmac_mod import hmac as hmac_mod
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import closing
from datetime import datetime from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
@@ -934,6 +935,38 @@ class SessionMonitor:
except OSError: except OSError:
pass 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): def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender):
"""检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等)
@@ -964,10 +997,10 @@ class SessionMonitor:
try: try:
conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True)
rows = conn.execute(f""" 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}] FROM [{table_name}]
WHERE create_time >= ? AND create_time <= ? WHERE create_time >= ? AND create_time <= ?
ORDER BY create_time ASC ORDER BY create_time ASC, local_id ASC
""", (prev_ts, curr_ts)).fetchall() """, (prev_ts, curr_ts)).fetchall()
conn.close() conn.close()
all_rows.extend(rows) all_rows.extend(rows)
@@ -976,7 +1009,8 @@ class SessionMonitor:
cache_failed = True cache_failed = True
break break
# 检查是否找到了 curr_ts 的消息(说明缓存是最新的) # 检查是否找到了 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: if has_curr or cache_failed:
break break
# 缓存可能还没更新到最新数据,短暂等待后重试 # 缓存可能还没更新到最新数据,短暂等待后重试
@@ -997,11 +1031,13 @@ class SessionMonitor:
print(f" [hidden] 缓存查到 {len(all_rows)}", flush=True) print(f" [hidden] 缓存查到 {len(all_rows)}", flush=True)
# 过滤出隐藏消息 # 过滤出隐藏消息
# 去重 key 用 local_id之前用 (username, ts, base) 太粗,同秒同类型多条会被
# 误判为重复,导致 issue #79 的 "10 丢 4"
hidden_msgs = [] 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 base = lt % 4294967296 if lt > 4294967296 else lt
# 跳过已显示的消息(精确匹配 username+timestamp+type # 跳过已显示的消息(按 local_id 精确去重
if (username, ts, base) in self._shown_keys: if (username, local_id) in self._shown_keys:
continue continue
# 解压 zstd # 解压 zstd
if isinstance(mc, bytes) and ct == 4: 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 '' mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else ''
elif isinstance(mc, bytes): elif isinstance(mc, bytes):
mc = mc.decode('utf-8', errors='replace') 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) print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True)
@@ -1019,8 +1055,8 @@ class SessionMonitor:
return return
global messages_log global messages_log
for ts, base, mc in hidden_msgs: for local_id, ts, base, mc in hidden_msgs:
self._shown_keys.add((username, ts, base)) self._shown_keys.add((username, local_id))
msg_data = { msg_data = {
'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'),
'timestamp': ts, 'timestamp': ts,
@@ -1444,7 +1480,13 @@ class SessionMonitor:
} }
new_msgs.append(msg_data) 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: if curr['msg_type'] == 3:
@@ -1495,9 +1537,12 @@ class SessionMonitor:
self.prev_state = curr_state self.prev_state = curr_state
# 清理过期的去重 key保留最近 5 分钟) # 清理 _shown_keys按数量上限local_id 不是时间戳不能按时间 prune。
cutoff = int(time.time()) - 300 # 超过 10000 时保留 local_id 最大的 5000 条(最新消息优先)。
self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff} # 实际触发频率:~几小时一次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): 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) mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map)