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 全绿。

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

View File

@ -0,0 +1,79 @@
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
}