diff --git a/server/internal/handler/permission.go b/server/internal/handler/permission.go index bb9f064..486193d 100644 --- a/server/internal/handler/permission.go +++ b/server/internal/handler/permission.go @@ -384,7 +384,12 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) { // 归档检查必须在 DecidePermission **之前**:否则决策已落库、回执邮件却因为 // 会话已归档建不出来 —— 发起方那边永远等不到回执(任务挂死), // 而人在界面上看到的却是"已处理"。宁可不决策,让人看见失败。 - if err := repo.SessionOpenFor(r.Context(), user.Username, perm.SessionID); err != nil { + // + // 这里刻意用 `EnsureSessionOpen`(只判存在+未归档)而不是 + // `SessionOpenFor`(多一格参与方):上面刚放行本请求的是 + // 「该邮件收件人本人 **或** 管理员」,而管理员**本来就可以**给任何线索做决策 + // —— 那不是越权。再叠一格参与方会把管理员挡在门外。 + if err := repo.EnsureSessionOpen(r.Context(), perm.SessionID); err != nil { if errors.Is(err, repo.ErrSessionArchived) { Error(w, http.StatusNotFound, "该会话已归档,无法回执决策") } else { diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index ebb4f96..bdedd73 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -1131,9 +1131,17 @@ func DecidePermission(ctx context.Context, mailID uuid.UUID, decider, decision s } json.Unmarshal(optsJSON, &pr.Options) - // Also update the mail + // 冗余列**不得覆盖 archived**,与 MarkMailRead 同一处守卫 + // (pi 2026-09-28 指出:这里 `status='read'` 无 archived 守卫是同族反向洞, + // 只堵 MarkMailRead 一处等于堵一半)。 + // + // 下面那条 `INSERT INTO mail_reads` 同样**不加**守卫,理由与 MarkMailRead 一致: + // `mail_reads` 是**事实表**(谁读过,不可撤销),行级那列是**派生列** + // (全局可见性,可重算)。一起挡会让「谁读过」变得不可审计 —— + // 而「事实与派生分居两表」正是这轮反复出现的坑。 _, _ = db.DB.ExecContext(context.Background(), - `UPDATE mails SET permission_result = $1, status = 'read' WHERE mail_id = $2`, + `UPDATE mails SET permission_result = $1, status = 'read' + WHERE mail_id = $2 AND status <> 'archived'`, decision, mailID) if decider != "" { // 记到决策人名下(按读者记,见 repo.markReadFor) diff --git a/server/internal/repo/session_status_invariant_test.go b/server/internal/repo/session_status_invariant_test.go index f1db678..654392b 100644 --- a/server/internal/repo/session_status_invariant_test.go +++ b/server/internal/repo/session_status_invariant_test.go @@ -210,6 +210,47 @@ func TestArchivedStaysInvisibleInBothTables(t *testing.T) { assertNoDivergence(t) } +// 权限决策同样不得把归档邮件改回 read —— 与 MarkMailRead 同一族的反向写入。 +// +// `DecidePermission`(repo.go:DecidePermission)那句 +// `UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2` +// 与 MarkMailRead 只差一个字段,守卫也该同形:只堵 MarkMailRead 一处, +// 权限决策这条路仍能把归档邮件的语义在行级抹掉(pi 2026-09-28 指出)。 +func TestDecidePermissionDoesNotUnarchiveMail(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + _, id := seedArchivedSession(t) + if _, err := db.DB.ExecContext(ctx, + `INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind) + SELECT mail_id, session_id, 'sender', 'q', '["同意"]', 'permission' FROM mails WHERE mail_id = $1`, + id); err != nil { + t.Fatal(err) + } + if _, err := DecidePermission(ctx, id, "bob", "同意"); err != nil { + t.Fatalf("DecidePermission 读数 %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("★ DecidePermission 把已归档邮件改成了 %q —— 与 MarkMailRead 同一个洞,只堵一半会让归档语义仍能被抹掉", mstat) + } + // 决策事实**必须**记下来:守卫只挡派生列,不挡事实(见 DecidePermission 的注释) + var n int + if err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = 'bob'`, id).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("决策人 bob 的已读事实没记进 mail_reads(实际 %d 行)—— 守卫误伤了事实表", n) + } + assertNoDivergence(t) +} + // 归档**之后**再走一遍 TouchSession(投递路径的唯一副作用)也不该分叉。 // 单测按「入口」写,这条按「时序」写:先归档、后投递,是线上真实发生的顺序。 func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) {