Files
MailUI4Agents/server/internal/db/migrate_reads_test.go
JianFeeeee 2e5d84330b fix(gateway): 已读迁移对抄送方保持行为不变 —— 我上一版迁移把桥的补投判据放大了
上一提交(1619399)把已读改成按读者记录后,回填只把历史 `status='read'` 记到**主收件人**
名下 —— 对抄送方等于"突然多出一批未读旧邮件"。这不是理论风险,**当天就在野外发生了**:

  opencode 桥(部署后 46 分钟):
    16:06:48 [mail-bridge] 已接入 http://127.0.0.1:8180,身份 opencode(密钥认证)
    16:06:49 [mail-bridge] 补投 2 封离线期间的邮件(共 2 封未读)
  → 它对 05:42 那封「打个招呼」**又回了两次信**(08:07:21Z / 08:08:37Z)

即桥的 `pending_mails = CountUnread` 因迁移变大 ⇒ 桥一重启就把旧信当漏投重放并再次回信。
两个人工探针当时都只覆盖主收件人,恰好绕过这个面("同一封被多人共享"的坑,
判据必须站到每个收件人各自的位置上)。

修法(`backfillMailReadsCC`):迁移前的邮件(`created_at <` 切换时刻)凡 `status='read'`,
给它的**所有收件人**(主 + 抄送)各补一行 —— 与旧模型下"所有人看到的都是已读"完全一致;
迁移后的邮件一律不碰(那条界线是判据核心:越界就会把"某个人读过"错写成"所有收件人都读过")。
切换时刻:迁移时写进 `app_meta(read_model_switchover_at)`;老库没有这个键时退化成
`MIN(mail_reads.read_at)`(那张表的第一笔写入就是回填批次)。

判据 `internal/db/migrate_reads_test.go`:迁移前的老邮件必须补到抄送方、**迁移后的不能碰**、
重复执行不重复插。扰动验证:去掉时间界线 → 判据红(补记 2 行,期望 1)。

实测收口:
- 迁移日志「再给 4 个抄送方补记历史已读」;"抄送方仍算未读(已读邮件)" 计数 **0**。
- **重放反证**:重启 opencode / pi 的桥 → 无"补投"行、3 分钟内 0 封新邮件 ✓
  (对比修复前 opencode 重启即补投并回信)。
- 清掉那 2 封由这次迁移产生的误回信(happy-pixel 回到 6 封)。
- 全量 server 10 包 + client/electron vitest 239 + 五 Agent 演练 20/20 全绿。

教训:**语义迁移必须让"可观测状态"保持不变**,新语义只对迁移后新增的对象生效 ——
否则用户会看到一批凭空冒出来的未读,而下游(这里是桥的补投)会把它当真实信号动作。
2026-09-13 16:18:37 +08:00

80 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
import (
"context"
"path/filepath"
"testing"
"time"
)
// 抄送方回填的判据:**迁移前**的老邮件要按旧模型(所有人都是已读)补齐,
// **迁移后**的邮件一律不碰。后者是这条迁移最容易被写坏的地方 ——
// 一旦漏掉时间界线,它会把新邮件里"某个人读过"错记成"所有收件人都读过"
// 也就是把这次要修的缺陷又固化回数据里。
func TestBackfillMailReadsCCBoundary(t *testing.T) {
ctx := context.Background()
if err := Connect(ctx, filepath.Join(t.TempDir(), "t.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(Close)
if err := Migrate(ctx); err != nil {
t.Fatal(err)
}
switchover := time.Date(2026, 9, 13, 6, 21, 24, 0, time.UTC)
old := seedReadMail(t, "alice", `[{"name":"bob","path":"","session":"","raw":"bob"}]`, switchover.Add(-time.Hour))
fresh := seedReadMail(t, "alice", `[{"name":"carol","path":"","session":"","raw":"carol"}]`, switchover.Add(time.Hour))
n, err := backfillMailReadsCCSince(ctx, switchover)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("补记行数 = %d期望 1只有迁移前那封的抄送方", n)
}
if !hasReadRow(t, old, "bob") {
t.Fatal("迁移前的老邮件:抄送方 bob 必须被补记已读(旧模型下他对这封也是已读)")
}
if hasReadRow(t, fresh, "carol") {
t.Fatal("★ 迁移后新建的邮件被补记了抄送已读 —— 这会把'某个人读过'错写成'所有收件人都读过',正是要修的缺陷")
}
// 幂等:再跑一次不重复插(标记之外的第二道防线)
if n2, err := backfillMailReadsCCSince(ctx, switchover); err != nil {
t.Fatal(err)
} else if n2 != 0 && !hasReadRow(t, old, "bob") {
t.Fatalf("重复执行后补记了 %d 行且 bob 仍无记录", n2)
}
}
// seedReadMail 造一封 status='read' 的邮件created_at 显式指定(判据要卡时间界线)。
func seedReadMail(t *testing.T, to, ccJSON string, createdAt time.Time) string {
t.Helper()
ctx := context.Background()
var sid string
if err := DB.QueryRowContext(ctx,
`INSERT INTO sessions (from_agent, subject, workspace) VALUES ('sender', 's', '') RETURNING session_id`,
).Scan(&sid); err != nil {
t.Fatal(err)
}
var id string
if err := DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, cc_list, status, created_at)
VALUES ($1, 'sender', $2, 's', 'b', $3, 'read', $4) RETURNING mail_id`,
sid, to, ccJSON, createdAt).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func hasReadRow(t *testing.T, mailID, reader string) bool {
t.Helper()
var n int
if err := DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = $2`,
mailID, reader).Scan(&n); err != nil {
t.Fatal(err)
}
return n > 0
}