From 2b77b17e651a2e799fd2a91605b6fa9e2a871d34 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 11:07:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=BD=92=E6=A1=A3):=20=E6=A0=87=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E4=B8=8D=E5=BE=97=E6=8A=8A=E5=BD=92=E6=A1=A3=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E5=9B=9E=20read=20=E2=80=94=E2=80=94=20e7888?= =?UTF-8?q?8b=20=E7=9A=84=E4=B8=8D=E5=8F=98=E9=87=8F=E6=9C=89=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-28 复核 e78888b 时指出:EnsureSessionOpen 堵住了「往归档会话里建邮件」, 但 MarkMailRead 能**反向**打破同一个不变量。复核成立。 POST /api/v1/mail/{id}/read(对归档会话里的一封) → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方) → UPDATE mails SET status='read' WHERE mail_id=$1 ← 不查 status 实测:mails.status `archived → read`,而 sessions.status 仍是 archived ⇒ 两表分叉,且 readStateFor 返回 'read' 而非 'archived', **归档语义在行级被抹掉**。已加 TestMarkReadDoesNotUnarchiveMail, 先确认它在修之前转红(不是改完就绿的装饰)。 同族的批量路径 MarkAllInboxRead 本来就有守卫 (`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126 断言了它)—— 缺的只有单封这一处,所以这是漏网而非设计如此。修法与批量那条同形。 ★ mail_reads 的 INSERT 刻意**不加**守卫:已读是按读者记的事实, 人确实读过,归档不该改写它。行级那列是「全局可见性」的冗余、mail_reads 是 「谁读过」的事实,两者语义不同 —— 一起挡会把事实也丢掉。 读侧清册仍为 repo.go=13:新增的那 1 处命中在注释里(散文里拼了列名字面量), 改写措辞而不改数字 —— 让数字 +1 会给未来新增读取凭空送出 1 格余量。 --- server/internal/repo/repo.go | 18 +++++++- .../repo/session_status_invariant_test.go | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index 2a7f1db..ebb4f96 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -853,7 +853,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error { id, reader); err != nil { return err } - _, err := db.DB.ExecContext(ctx, `UPDATE mails SET status = 'read' WHERE mail_id = $1`, id) + // 冗余列**不得覆盖 archived**(pi 2026-09-28 复核时指出的反向洞)。 + // + // 缺这一格时:对归档会话里的一封调 `POST /api/v1/mail/{id}/read`, + // 会把那行从 archived 改成 read,而会话那列仍是 archived + // ⇒ 两表分叉,且 `readStateFor` 返回 'read' 而非 'archived', + // **归档语义在行级被抹掉**(实测 archived → read)。 + // + // 批量那条 `MarkAllInboxRead` 本来就有等价守卫 + // (`session_id IN (SELECT ... WHERE status <> 'archived')`)—— + // 缺的只有单封这一处,所以这不是设计如此,是漏网。判据: + // session_status_invariant_test.go 的 TestMarkReadDoesNotUnarchiveMail。 + // + // 注意上面那条 `INSERT INTO mail_reads` **不加**守卫:已读是按读者记的事实, + // 人确实读过,归档不该改写这个事实(行级那列是"全局可见性"的冗余, + // mail_reads 是"谁读过"的事实,两者语义不同,不要一起挡)。 + _, err := db.DB.ExecContext(ctx, + `UPDATE mails SET status = 'read' WHERE mail_id = $1 AND status <> 'archived'`, id) return err } diff --git a/server/internal/repo/session_status_invariant_test.go b/server/internal/repo/session_status_invariant_test.go index 8b02726..f1db678 100644 --- a/server/internal/repo/session_status_invariant_test.go +++ b/server/internal/repo/session_status_invariant_test.go @@ -227,3 +227,45 @@ func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) { t.Fatalf("重复投递把会话状态推成了 %q,期望仍是 archived", got) } } + +// 标已读不得把归档邮件改回 read —— 与 TestTouchSessionDoesNotUnarchive 对称。 +// +// ★ 这条是 pi 2026-09-28 复核 e78888b 时指出的**反向**洞:EnsureSessionOpen 堵住了 +// 「往归档会话里建邮件」(会话 active?/邮件行不该非 archived),但 +// `MarkMailRead`(repo.go:MarkMailRead)那句 `UPDATE mails SET status='read' +// WHERE mail_id=$1` **不查 status**,于是 +// +// POST /api/v1/mail/{id}/read(对归档会话里的一封) +// → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方) +// → UPDATE ⇒ mails.status: archived → read,而 sessions.status 仍是 archived +// +// 实测读数:`archived → read`(会话仍 archived)。此时 `readStateFor` 返回 +// 'read' 而非 'archived' ⇒ **归档语义在行级被抹掉**,正是本文件要禁止的分叉。 +// +// 同族的批量路径 `MarkAllInboxRead` **本来就有**守卫 +// (`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126 +// 断言了它)—— 所以缺的只有单封这一处,这也说明它不是"设计如此"而是漏网。 +func TestMarkReadDoesNotUnarchiveMail(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + _, id := seedArchivedSession(t) + + if err := MarkMailRead(ctx, id, "bob"); err != nil { + t.Fatalf("MarkMailRead 读数 %v(对归档邮件标已读应当被拒或无害)", err) + } + + var mstat string + if err := db.DB.QueryRowContext(ctx, + `SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&mstat); err != nil { + t.Fatal(err) + } + if mstat != "archived" { + t.Fatalf("★ MarkMailRead 把已归档邮件改成了 %q —— 归档是全局属性,标已读不该抹掉它。\n"+ + " sessions.status 仍是 archived 而这一行变成 %q,两表分叉(readStateFor 会返回 %q\n"+ + " 而不是 'archived',归档语义在行级被抹掉)。\n"+ + " 批量那条 MarkAllInboxRead 有 `session_id IN (... status <> 'archived')` 守卫,\n"+ + " 单封这条漏了 —— 对称的洞,别只堵一半。", mstat, mstat, mstat) + } + assertNoDivergence(t) +}