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:
@@ -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(新 schema:local_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user