feat: BUG-00011 P2 Message-ID 去重防止重复推送
- 新增 telegram_push_log 表 (schema v5),UNIQUE(account_id, message_id) - IMAP fetch 提取 Message-ID header; Graph fetch 提取 internetMessageId - 推送前查 push_log 跳过已推送邮件,推送后记录 - 每次 Job 结束清理 >7 天的去重记录 - 新增 4 个 TestMessageIdDedup 测试 + 更新 _make_email 含 message_id - 228 tests passing (游标防重 + Message-ID 去重双保险) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
d6af769401
commit
71b5cc0293
3 files changed
+257
-6
No files matched your search
+27
-2
@@ -18,8 +18,8 @@ from outlook_web.security.crypto import (
|
||||
|
||||
# 数据库 Schema 版本(用于升级可验证/可诊断)
|
||||
# v3:对齐 PRD-00005 / FD-00005 / TDD-00005(accounts 表新增多邮箱字段:account_type/provider/imap_host/imap_port/imap_password)
|
||||
# v4:对齐 PRD-00007 / FD-00007(accounts 表新增 telegram_push_enabled/telegram_last_checked_at)
|
||||
DB_SCHEMA_VERSION = 4
|
||||
# v5:BUG-00011 P2 — Message-ID 去重防止重复推送
|
||||
DB_SCHEMA_VERSION = 5
|
||||
DB_SCHEMA_VERSION_KEY = "db_schema_version"
|
||||
DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id"
|
||||
DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error"
|
||||
@@ -556,6 +556,31 @@ def init_db(database_path: Optional[str] = None):
|
||||
"""
|
||||
)
|
||||
|
||||
# v5: Telegram 推送去重日志(BUG-00011 P2)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS telegram_push_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
pushed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
|
||||
UNIQUE(account_id, message_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_telegram_push_log_account_id
|
||||
ON telegram_push_log(account_id)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_telegram_push_log_pushed_at
|
||||
ON telegram_push_log(pushed_at)
|
||||
"""
|
||||
)
|
||||
|
||||
# 迁移现有明文数据为加密数据
|
||||
migrate_sensitive_data(conn)
|
||||
|
||||
|
||||
@@ -183,6 +183,10 @@ def _fetch_new_emails_imap(account: dict, since: str) -> List[dict]:
|
||||
)
|
||||
|
||||
sender = msg.get("From", "")
|
||||
# 提取 Message-ID(BUG-00011 P2 去重用)
|
||||
raw_message_id = msg.get("Message-ID", "") or msg.get("Message-Id", "")
|
||||
if not raw_message_id:
|
||||
raw_message_id = f"imap:{account.get('email', '')}:{mid.decode() if isinstance(mid, bytes) else mid}"
|
||||
date_str = msg.get("Date", "")
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
@@ -226,6 +230,7 @@ def _fetch_new_emails_imap(account: dict, since: str) -> List[dict]:
|
||||
|
||||
results.append(
|
||||
{
|
||||
"message_id": raw_message_id.strip(),
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"received_at": received_iso,
|
||||
@@ -267,7 +272,7 @@ def _fetch_new_emails_graph(account: dict, since: str) -> List[dict]:
|
||||
params = {
|
||||
"$filter": f"receivedDateTime gt {since_z}",
|
||||
"$top": MAX_EMAILS_PER_FETCH,
|
||||
"$select": "subject,from,receivedDateTime,bodyPreview",
|
||||
"$select": "id,subject,from,receivedDateTime,bodyPreview,internetMessageId",
|
||||
"$orderby": "receivedDateTime asc",
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
@@ -285,8 +290,11 @@ def _fetch_new_emails_graph(account: dict, since: str) -> List[dict]:
|
||||
received_raw = item.get("receivedDateTime", "")
|
||||
received_iso = received_raw.replace("Z", "").split(".")[0] if received_raw else ""
|
||||
preview = (item.get("bodyPreview", "") or "")[:MAX_PREVIEW_LENGTH]
|
||||
# BUG-00011 P2: 提取 Message-ID(优先 internetMessageId,回退 Graph id)
|
||||
msg_id = item.get("internetMessageId", "") or item.get("id", "")
|
||||
results.append(
|
||||
{
|
||||
"message_id": msg_id,
|
||||
"subject": item.get("subject", ""),
|
||||
"sender": sender,
|
||||
"received_at": received_iso,
|
||||
@@ -300,6 +308,45 @@ def _fetch_new_emails_graph(account: dict, since: str) -> List[dict]:
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message-ID 去重(BUG-00011 P2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PUSH_LOG_RETENTION_DAYS = 7 # 去重记录保留天数
|
||||
|
||||
|
||||
def _is_message_pushed(db, account_id: int, message_id: str) -> bool:
|
||||
"""检查该邮件是否已推送过。"""
|
||||
row = db.execute(
|
||||
"SELECT 1 FROM telegram_push_log WHERE account_id = ? AND message_id = ?",
|
||||
(account_id, message_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _record_pushed_message(db, account_id: int, message_id: str) -> None:
|
||||
"""记录已推送的邮件 Message-ID。"""
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO telegram_push_log (account_id, message_id, pushed_at) VALUES (?, ?, ?)",
|
||||
(account_id, message_id, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")),
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass # 去重失败不影响推送流程
|
||||
|
||||
|
||||
def _cleanup_push_log(db) -> None:
|
||||
"""清理超过 PUSH_LOG_RETENTION_DAYS 天的去重记录。"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=PUSH_LOG_RETENTION_DAYS)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute("DELETE FROM telegram_push_log WHERE pushed_at < ?", (cutoff,))
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口(调度器调用)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -328,7 +375,7 @@ def _fetch_account_emails(account: dict) -> tuple:
|
||||
|
||||
|
||||
def run_telegram_push_job(app) -> None:
|
||||
"""主入口:并行轮询 → 推送 → 更新游标。由调度器调用。"""
|
||||
"""主入口:并行轮询 → 去重 → 推送 → 更新游标。由调度器调用。"""
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
@@ -336,6 +383,7 @@ def run_telegram_push_job(app) -> None:
|
||||
logger.info("[telegram_push] job started")
|
||||
|
||||
with app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
from outlook_web.repositories.accounts import (
|
||||
get_telegram_push_accounts,
|
||||
update_telegram_cursor,
|
||||
@@ -361,6 +409,9 @@ def run_telegram_push_job(app) -> None:
|
||||
from datetime import timedelta
|
||||
recency_cutoff = (datetime.now(timezone.utc) - timedelta(hours=PUSH_RECENCY_HOURS)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
sent_count = 0
|
||||
dedup_skipped = 0
|
||||
|
||||
db = get_db()
|
||||
|
||||
# 并行获取所有账号邮件
|
||||
fetch_results = []
|
||||
@@ -381,7 +432,6 @@ def run_telegram_push_job(app) -> None:
|
||||
continue
|
||||
|
||||
# BUG-00011: 游标取 max(job_start_time, 最大 received_at)
|
||||
# 防止邮件服务器时钟偏差 / fetch 窗口期间到达的邮件被重复推送
|
||||
max_received = max(
|
||||
(em.get("received_at", "") for em in emails),
|
||||
default="",
|
||||
@@ -392,20 +442,33 @@ def run_telegram_push_job(app) -> None:
|
||||
update_telegram_cursor(account["id"], new_cursor)
|
||||
continue
|
||||
|
||||
account_id = account["id"]
|
||||
for em in sorted(emails, key=lambda e: e.get("received_at", "")):
|
||||
if sent_count >= MAX_SENT_PER_JOB:
|
||||
break
|
||||
# 跳过超过 PUSH_RECENCY_HOURS 的旧邮件
|
||||
if em.get("received_at", "") < recency_cutoff:
|
||||
continue
|
||||
# BUG-00011 P2: Message-ID 去重
|
||||
msg_id = em.get("message_id", "")
|
||||
if msg_id and _is_message_pushed(db, account_id, msg_id):
|
||||
dedup_skipped += 1
|
||||
continue
|
||||
|
||||
msg = _build_telegram_message(account["email"], em)
|
||||
if _send_telegram_message(bot_token, chat_id, msg):
|
||||
sent_count += 1
|
||||
if msg_id:
|
||||
_record_pushed_message(db, account_id, msg_id)
|
||||
# 消息间延迟,防止 Telegram API 限流
|
||||
if TELEGRAM_PUSH_DELAY_SEC > 0:
|
||||
time.sleep(TELEGRAM_PUSH_DELAY_SEC)
|
||||
|
||||
update_telegram_cursor(account["id"], new_cursor)
|
||||
|
||||
# 定期清理过期去重记录(>7 天)
|
||||
_cleanup_push_log(db)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info("[telegram_push] job finished: sent=%d elapsed=%.1fs", sent_count, elapsed)
|
||||
logger.info("[telegram_push] job finished: sent=%d dedup_skipped=%d elapsed=%.1fs",
|
||||
sent_count, dedup_skipped, elapsed)
|
||||
@@ -361,6 +361,7 @@ class TestRunTelegramPushJob(unittest.TestCase):
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@tgjob.com'")
|
||||
# 确保其他测试遗留的账号不干扰推送 job(关闭所有推送开关)
|
||||
db.execute("UPDATE accounts SET telegram_push_enabled = 0")
|
||||
db.execute("DELETE FROM telegram_push_log")
|
||||
db.commit()
|
||||
|
||||
def _set_settings(self, bot_token="test_token_12345678", chat_id="-12345"):
|
||||
@@ -377,7 +378,9 @@ class TestRunTelegramPushJob(unittest.TestCase):
|
||||
run_telegram_push_job(self.app)
|
||||
|
||||
def _make_email(self, received_at="2026-03-04T14:31:00"):
|
||||
import uuid
|
||||
return {
|
||||
"message_id": f"<{uuid.uuid4().hex}@test.com>",
|
||||
"subject": "Test",
|
||||
"sender": "s@test.com",
|
||||
"received_at": received_at,
|
||||
@@ -963,6 +966,7 @@ class TestParallelJobBehavior(unittest.TestCase):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@parallel.com'")
|
||||
db.execute("UPDATE accounts SET telegram_push_enabled = 0")
|
||||
db.execute("DELETE FROM telegram_push_log")
|
||||
db.commit()
|
||||
|
||||
def _set_settings(self, bot_token="test_token_12345678", chat_id="-12345"):
|
||||
@@ -979,7 +983,9 @@ class TestParallelJobBehavior(unittest.TestCase):
|
||||
run_telegram_push_job(self.app)
|
||||
|
||||
def _make_email(self, received_at="2026-03-04T14:31:00"):
|
||||
import uuid
|
||||
return {
|
||||
"message_id": f"<{uuid.uuid4().hex}@test.com>",
|
||||
"subject": "Test",
|
||||
"sender": "s@test.com",
|
||||
"received_at": received_at,
|
||||
@@ -1147,6 +1153,7 @@ class TestCursorClockSkew(unittest.TestCase):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@clockskew.com'")
|
||||
db.execute("UPDATE accounts SET telegram_push_enabled = 0")
|
||||
db.execute("DELETE FROM telegram_push_log")
|
||||
db.commit()
|
||||
|
||||
def _set_settings(self):
|
||||
@@ -1172,6 +1179,7 @@ class TestCursorClockSkew(unittest.TestCase):
|
||||
from datetime import timedelta
|
||||
future_time = (datetime.now(timezone.utc) + timedelta(seconds=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [{
|
||||
"message_id": "<future@example.com>",
|
||||
"subject": "Clock skew email",
|
||||
"sender": "s@test.com",
|
||||
"received_at": future_time,
|
||||
@@ -1204,6 +1212,7 @@ class TestCursorClockSkew(unittest.TestCase):
|
||||
from datetime import timedelta
|
||||
past_time = (datetime.now(timezone.utc) - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [{
|
||||
"message_id": "<past@example.com>",
|
||||
"subject": "Normal email",
|
||||
"sender": "s@test.com",
|
||||
"received_at": past_time,
|
||||
@@ -1238,6 +1247,7 @@ class TestCursorClockSkew(unittest.TestCase):
|
||||
from datetime import timedelta
|
||||
future_time = (datetime.now(timezone.utc) + timedelta(seconds=3)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [{
|
||||
"message_id": "<skew-e2e@example.com>",
|
||||
"subject": "Skew E2E",
|
||||
"sender": "s@test.com",
|
||||
"received_at": future_time,
|
||||
@@ -1269,5 +1279,158 @@ class TestCursorClockSkew(unittest.TestCase):
|
||||
mock_send2.assert_not_called()
|
||||
|
||||
|
||||
class TestMessageIdDedup(unittest.TestCase):
|
||||
"""BUG-00011 P2: Message-ID 去重测试"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = _get_app()
|
||||
|
||||
def setUp(self):
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@dedup.com'")
|
||||
db.execute("UPDATE accounts SET telegram_push_enabled = 0")
|
||||
db.execute("DELETE FROM telegram_push_log")
|
||||
db.commit()
|
||||
|
||||
def _set_settings(self):
|
||||
from outlook_web.repositories.settings import set_setting
|
||||
from outlook_web.security.crypto import encrypt_data
|
||||
set_setting("telegram_bot_token", encrypt_data("test_token_12345678"))
|
||||
set_setting("telegram_chat_id", "-12345")
|
||||
|
||||
def _run_job(self):
|
||||
from outlook_web.services.telegram_push import run_telegram_push_job
|
||||
run_telegram_push_job(self.app)
|
||||
|
||||
def test_same_message_id_not_pushed_twice(self):
|
||||
"""同一 message_id 的邮件在两轮 Job 中只推送 1 次"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
db = get_db()
|
||||
_insert_test_account(db, "dup@dedup.com", enabled=1,
|
||||
last_checked="2026-03-01T00:00:00")
|
||||
self._set_settings()
|
||||
|
||||
from datetime import timedelta
|
||||
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [{
|
||||
"message_id": "<unique123@mail.com>",
|
||||
"subject": "Test dedup",
|
||||
"sender": "s@test.com",
|
||||
"received_at": recent,
|
||||
"preview": "body",
|
||||
}]
|
||||
|
||||
# 第一轮:推送成功
|
||||
with patch("outlook_web.services.telegram_push._fetch_new_emails_imap", return_value=emails), \
|
||||
patch("outlook_web.services.telegram_push._send_telegram_message", return_value=True) as send1, \
|
||||
patch("outlook_web.services.telegram_push.TELEGRAM_PUSH_DELAY_SEC", 0):
|
||||
self._run_job()
|
||||
self.assertEqual(send1.call_count, 1)
|
||||
|
||||
# 验证 push_log 记录
|
||||
row = db.execute(
|
||||
"SELECT * FROM telegram_push_log WHERE message_id = ?", ("<unique123@mail.com>",)
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
# 第二轮:同一封邮件再次被 fetch(模拟游标未正确推进的极端场景)
|
||||
with patch("outlook_web.services.telegram_push._fetch_new_emails_imap", return_value=emails), \
|
||||
patch("outlook_web.services.telegram_push._send_telegram_message", return_value=True) as send2, \
|
||||
patch("outlook_web.services.telegram_push.TELEGRAM_PUSH_DELAY_SEC", 0):
|
||||
self._run_job()
|
||||
# Message-ID 去重:不应再推送
|
||||
send2.assert_not_called()
|
||||
|
||||
def test_different_message_ids_both_pushed(self):
|
||||
"""不同 message_id 的邮件各推送 1 次"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
db = get_db()
|
||||
_insert_test_account(db, "multi@dedup.com", enabled=1,
|
||||
last_checked="2026-03-01T00:00:00")
|
||||
self._set_settings()
|
||||
|
||||
from datetime import timedelta
|
||||
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [
|
||||
{"message_id": "<aaa@mail.com>", "subject": "A", "sender": "s@t.com",
|
||||
"received_at": recent, "preview": "a"},
|
||||
{"message_id": "<bbb@mail.com>", "subject": "B", "sender": "s@t.com",
|
||||
"received_at": recent, "preview": "b"},
|
||||
]
|
||||
|
||||
with patch("outlook_web.services.telegram_push._fetch_new_emails_imap", return_value=emails), \
|
||||
patch("outlook_web.services.telegram_push._send_telegram_message", return_value=True) as mock_send, \
|
||||
patch("outlook_web.services.telegram_push.TELEGRAM_PUSH_DELAY_SEC", 0):
|
||||
self._run_job()
|
||||
self.assertEqual(mock_send.call_count, 2)
|
||||
|
||||
def test_push_log_cleanup(self):
|
||||
"""过期记录(>7 天)应被清理"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
db = get_db()
|
||||
_insert_test_account(db, "clean@dedup.com", enabled=1,
|
||||
last_checked="2026-03-01T00:00:00")
|
||||
self._set_settings()
|
||||
|
||||
# 插入一条旧记录和一条新记录
|
||||
db.execute(
|
||||
"INSERT INTO telegram_push_log (account_id, message_id, pushed_at) VALUES (?, ?, ?)",
|
||||
(999, "<old@mail.com>", "2026-01-01T00:00:00"),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telegram_push_log (account_id, message_id, pushed_at) VALUES (?, ?, ?)",
|
||||
(999, "<new@mail.com>", datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 运行 job 触发 cleanup
|
||||
with patch("outlook_web.services.telegram_push._fetch_new_emails_imap", return_value=[]), \
|
||||
patch("outlook_web.services.telegram_push._send_telegram_message", return_value=True), \
|
||||
patch("outlook_web.services.telegram_push.TELEGRAM_PUSH_DELAY_SEC", 0):
|
||||
self._run_job()
|
||||
|
||||
# 旧记录应被清理
|
||||
old = db.execute(
|
||||
"SELECT 1 FROM telegram_push_log WHERE message_id = ?", ("<old@mail.com>",)
|
||||
).fetchone()
|
||||
self.assertIsNone(old, "超过 7 天的记录应被清理")
|
||||
|
||||
# 新记录应保留
|
||||
new = db.execute(
|
||||
"SELECT 1 FROM telegram_push_log WHERE message_id = ?", ("<new@mail.com>",)
|
||||
).fetchone()
|
||||
self.assertIsNotNone(new, "7 天内的记录应保留")
|
||||
|
||||
def test_email_without_message_id_still_pushed(self):
|
||||
"""缺少 message_id 的邮件仍然正常推送(降级到仅游标防重)"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
db = get_db()
|
||||
_insert_test_account(db, "nomsgid@dedup.com", enabled=1,
|
||||
last_checked="2026-03-01T00:00:00")
|
||||
self._set_settings()
|
||||
|
||||
from datetime import timedelta
|
||||
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
emails = [{
|
||||
"subject": "No Message-ID",
|
||||
"sender": "s@test.com",
|
||||
"received_at": recent,
|
||||
"preview": "body",
|
||||
}] # 注意:没有 message_id 字段
|
||||
|
||||
with patch("outlook_web.services.telegram_push._fetch_new_emails_imap", return_value=emails), \
|
||||
patch("outlook_web.services.telegram_push._send_telegram_message", return_value=True) as mock_send, \
|
||||
patch("outlook_web.services.telegram_push.TELEGRAM_PUSH_DELAY_SEC", 0):
|
||||
self._run_job()
|
||||
mock_send.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user