修复: 已读**权威列**静默少行 —— markReadFor 占位符整体错位一格(最后一封永远标不上)
★ 这是我自己那封"重投"的根因。我先在自己的收件箱上量到症状:
`read_inbox` 列了 **5 封**,落库只有 **4 封**变成已读,**少的正是最后一封**。
顺着症状读 `repo.go` 才看到机制,不是先读代码再猜。
## 根因:reader 的占位符与调用方的 `$1` 撞号
`markReadFor` 原把 reader **前置**成 `$1`:
append([]any{reader}, args...) // → $1=reader, $2=args[0], …
而两个调用方的 `where` 早把 `$1` 用成了 recipient:
MarkMailsReadFor : $1=recipient,$2..$(N+1)=ids,args=[recipient, ids...]
MarkAllInboxReadForSession : $1=recipient,$2=sessionID,args=[recipient, …]
⇒ 绑定表整体**右移一格**,`IN ($2 … $(N+1))` 实际收到 `(recipient, id1 … idN-1)`:
· **最后一封永远插不进**(idN 从没被绑定)
· **只传 1 封时一封都不插**(`$2` = recipient)
· 带 session 那条 `$2=recipient` 被当成 `session_id` ⇒ 同样一行不插
## ★ 为什么长期零痕迹(比 bug 本身重要)
调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的 args
⇒ **计数正确**、冗余列也正确,**只有权威列 `mail_reads` 静默少行**。
而未读判据是 `mail_reads`(`unreadFor`)⇒ 邮件**看起来已读、实际仍算未读**
⇒ 重启补投(`catchUp`)时被当新信**重投**。
**原有的 4 个测试全都守着这个 bug**:它们断言的是 `statusOf()` —— 读的正是那列
**冗余**。判据读错了列 ⇒ 守的是"看起来对"的值,不是**决定行为**的那个值。
## 实测(探针 + 变异,两边都跑)
修复前:单封 mail_reads=0(期望 1);传 4 封 → n=4 但只插 3 行(丢第 4 封)
修复后:单封 mail_reads=1;传 4 封 → 插 4 行;抄送、幂等、MarkAllInbox 各自 1 行
新判据 5 条在**旧实现上变异验证**:4 条红(含指名"漏标第 [5] 封"),
第 5 条「别人的邮件不该留下行」**正确地不红**(鉴权本来就没坏)。
★ **我中途假绿过一次,如实记**:第一版探针只有 `t.Logf` 没有 `t.Errorf`,
变异态 `go test` 仍 **rc=0** —— 读数(0/3 vs 1/4)确实变了,但**判据没断言**。
这正是本仓那条「判据在,但走不到」。改成 `t.Fatalf` 后才真抓住。
## 我另外自伤了一次(同一个判据抓到我)
`mail_status_readers_test.go` 数的是**原始源码**里 `mails.status` 的出现次数(不剥注释)。
我新写的注释里就有一句"…冗余列 `mails.status` 正确",把清册从 **13 撑到 14** ⇒
`TestMailsStatusReadersAreRegistered` 红。改掉那句措辞后回到 13。
⇒ 记一条:**在那个文件里写注释也会被记账**,说明它数的是"字面出现"而非"真读列"。
(我没有改那个判据 —— 它数得紧是有意的,我改的是自己的措辞。)
## 状态
`go test ./...`:`internal/repo` 仍有一条红 `TestStaleLunarRecurringDoesNotFlood`
(「农历日从 30 变成 29」= **日期相关**)。**我把 `repo.go` 还原成 HEAD 版单跑过,
它同样 FAIL** ⇒ 与本次改动无关的既有红,我没动它。
其余 12 个包全 `ok`。新判据 5/5、原有 `TestMarkMailsReadFor*` 4/4。
This commit is contained in:
133
server/internal/repo/markread_authcolumn_test.go
Normal file
133
server/internal/repo/markread_authcolumn_test.go
Normal file
@ -0,0 +1,133 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
/*
|
||||
* ★★ 权威列 `mail_reads` 的回归判据(2026-09-20)。
|
||||
*
|
||||
* 背景:`markReadFor` 原来把 reader **前置**成 `$1`,而调用方 `where` 早已把 `$1`
|
||||
* 用成 recipient ⇒ 整张绑定表右移一格 ⇒ `INSERT ... SELECT` 的 `IN` 子句拿到
|
||||
* `(recipient, id1 … idN-1)`,**最后一封永远插不进**;只传 1 封时一封都不插。
|
||||
*
|
||||
* ★ 为什么长期零痕迹(这条比 bug 本身重要):
|
||||
* 调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的
|
||||
* args ⇒ 计数正确、冗余列 `mails.status` 正确,**只有权威列 `mail_reads` 静默少行**。
|
||||
* 而未读判据是 `mail_reads`(见 `unreadFor`)⇒ 邮件「看起来已读、实际仍算未读」
|
||||
* ⇒ 重启补投(`catchUp`)时被当新信**重投**。
|
||||
*
|
||||
* ★ 为什么原有 4 个测试(`markread_test.go`)全都漏掉它:
|
||||
* 它们断言的是 `statusOf()` —— 读的正是那列**冗余** `mails.status`。
|
||||
* 判据读错了列,于是守着"看起来对"的那个值,而不是**决定行为**的那个值。
|
||||
* 本文件补的就是"断言权威列"。
|
||||
*
|
||||
* ⚠️ 这四条对**顺序**敏感:off-by-one 只丢「最后一个」,所以**必须一次传多个**
|
||||
* 才有区分力 —— 单封用例在旧实现下也插不进去,多封用例才能看出"丢了尾巴"。
|
||||
*/
|
||||
|
||||
// readRows 读**权威列**:这个读者对某封邮件有几行已读记录。
|
||||
func readRows(t *testing.T, id uuid.UUID, reader string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = $2`,
|
||||
id, reader).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 单封:旧实现下 `IN ($2)` 拿到的是 recipient ⇒ 一行都插不进。
|
||||
func TestAuthColumnSingleMailMarked(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := seedMailTo(t, "bot", "")
|
||||
|
||||
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readRows(t, id, "bot"); got != 1 {
|
||||
t.Fatalf("权威列 mail_reads 行数 = %d,期望 1 —— "+
|
||||
"`mails.status` 会说 read,但未读判据读的是 mail_reads ⇒ 这封会被当新信重投", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 核心:一次传多封时,**每一封**都要有行 —— off-by-one 只会丢最后一封。
|
||||
func TestAuthColumnAllMailsMarkedNotJustFirstN(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ids := make([]uuid.UUID, 5)
|
||||
for i := range ids {
|
||||
ids[i] = seedMailTo(t, "bot", "")
|
||||
}
|
||||
n, err := MarkMailsReadFor(ctx, "bot", ids)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != len(ids) {
|
||||
t.Fatalf("冗余列影响行数 = %d,期望 %d", n, len(ids))
|
||||
}
|
||||
|
||||
// 逐封断言,并指名是**哪一封**没标上(尾巴那一封最容易漏)。
|
||||
var missing []int
|
||||
for i, id := range ids {
|
||||
if readRows(t, id, "bot") != 1 {
|
||||
missing = append(missing, i+1)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("★ 权威列漏标第 %v 封(共 %d 封)—— "+
|
||||
"`n=%d` 与 `mails.status` 都会是「对的」,只有 mail_reads 少行;"+
|
||||
"未读判据读 mail_reads ⇒ 这几封会被重投", missing, len(ids), n)
|
||||
}
|
||||
}
|
||||
|
||||
// 抄送路径同样要写权威列(它的 where 也把 $1 用成了 recipient)。
|
||||
func TestAuthColumnCoversCC(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := seedMailTo(t, "other", "bot") // 主收件人 other,bot 被抄送
|
||||
|
||||
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readRows(t, id, "bot"); got != 1 {
|
||||
t.Fatalf("被抄送的邮件在权威列 mail_reads 行数 = %d,期望 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:重复标记不产生第二行(NOT EXISTS 那半也要在**正确的 reader**上生效)。
|
||||
func TestAuthColumnIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := seedMailTo(t, "bot", "")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := readRows(t, id, "bot"); got != 1 {
|
||||
t.Fatalf("重复标记后权威列行数 = %d,期望 1(幂等)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 别人的邮件不该在权威列留下行(鉴权在 WHERE 里,前置错位时这条也可能被绕过)。
|
||||
func TestAuthColumnNotOwnMailUntouched(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
others := seedMailTo(t, "other", "")
|
||||
|
||||
if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{others}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readRows(t, others, "bot"); got != 0 {
|
||||
t.Fatalf("别人的邮件竟在权威列留下了 %d 行 —— 鉴权失效", got)
|
||||
}
|
||||
}
|
||||
@ -503,13 +503,36 @@ func readStateFor(arg string) string {
|
||||
// 调用方各自负责随后刷新 mails.status 那列冗余(那个语句没有 `m` 别名)。
|
||||
// 之前我把同一个 where 复用到 UPDATE 上,直接 SQL 报 "no such column: m.mail_id"
|
||||
// (测试当场抓到)。
|
||||
//
|
||||
// ★★ reader 的占位符**必须排在调用方实参之后**(2026-09-20 修,一个静默少行的真 bug)。
|
||||
//
|
||||
// 原实现把 reader **前置**(`append([]any{reader}, args...)`)当 `$1`,而两个调用方
|
||||
// 的 `where` 早就把 `$1` 用成了 recipient:
|
||||
//
|
||||
// MarkMailsReadFor : $1=recipient,$2..$(N+1)=ids,args=[recipient, ids...]
|
||||
// MarkAllInboxReadForSession : $1=recipient,$2=sessionID,args=[recipient, …]
|
||||
//
|
||||
// 前置之后整张绑定表**右移一格** ⇒ `$2` 拿到的是 recipient 而不是 `id1`:
|
||||
// - `IN ($2 …, $(N+1))` 实际是 `(recipient, id1 … idN-1)` ⇒ **最后一封永远不插**;
|
||||
// 只传 1 封时 `$2=recipient` ⇒ **一封都不插**。
|
||||
// - `MarkAllInboxReadForSession` 带 session 时 `$2=recipient` 被当成 `session_id` ⇒ 同样一行不插。
|
||||
//
|
||||
// **为什么长期零痕迹**:调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,
|
||||
// 它用的是没被前置的 args ⇒ 计数正确、那列冗余值也正确,
|
||||
// 只有权威列 `mail_reads` 静默少行。而未读判据是 `mail_reads`(见 `unreadFor`),
|
||||
// 于是邮件**看起来已读、实际仍是未读** ⇒ 重启补投时被当新信重投。
|
||||
//
|
||||
// 现在把 reader 放在**最后一个**占位符,两个调用方的 `$1` 语义各自保持不变。
|
||||
func markReadFor(ctx context.Context, reader string, where string, args ...any) error {
|
||||
// reader 的编号紧跟在调用方实参之后,避免与它们已占用的 $N 相撞。
|
||||
n := len(args) + 1
|
||||
ph := fmt.Sprintf("$%d", n)
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO mail_reads (mail_id, reader_name)
|
||||
SELECT m.mail_id, $1 FROM mails m
|
||||
SELECT m.mail_id, `+ph+` FROM mails m
|
||||
WHERE `+where+`
|
||||
AND NOT EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = $1)`,
|
||||
append([]any{reader}, args...)...)
|
||||
AND NOT EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = `+ph+`)`,
|
||||
append(append([]any{}, args...), reader)...)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user