修复: 已读**权威列**静默少行 —— 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:
2026-09-21 04:25:17 +08:00
parent 139fa19f90
commit 17908c1623
2 changed files with 159 additions and 3 deletions

View File

@ -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
}