fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。
实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。
三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。
改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。
★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。
判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。
线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** ✅
This commit is contained in:
@ -2,7 +2,9 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@ -37,10 +39,59 @@ func Migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
if err := backfillMailReads(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
backfillMailReads 把已读模型从"邮件级"迁到"读者级"时补一次历史数据。
|
||||
|
||||
背景:`mails.status='read'` 原先表示"有人读过",但**没记是谁读的**。新的
|
||||
mail_reads 表按读者记录,因此老数据只能推断 —— 取主收件人(to_name)作为默认读者:
|
||||
绝大多数已读发生在主收件人身上,而抄送方"被代读"的情况本来就是要修掉的错。
|
||||
|
||||
**只能跑一次**:每次启动都跑的话,它会把"某个抄送方读过"的邮件按主收件人写成已读 ——
|
||||
正是这次要修的语义错误。所以用 app_meta 里的标记守住(见 init_sqlite.sql 的注释)。
|
||||
*/
|
||||
func backfillMailReads(ctx context.Context) error {
|
||||
const marker = "read_model_per_recipient_v1"
|
||||
var v string
|
||||
err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v)
|
||||
if err == nil {
|
||||
return nil // 已迁过
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("backfill mail_reads: 读标记失败: %w", err)
|
||||
}
|
||||
|
||||
// INSERT ... SELECT ... WHERE NOT EXISTS 两种方言都认;WHERE 只是防御
|
||||
// (标记保证只跑一次,但万一上次中断在半路,这条能安全续上)。
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO mail_reads (mail_id, reader_name)
|
||||
SELECT m.mail_id, m.to_name
|
||||
FROM mails m
|
||||
WHERE m.status = 'read'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = m.to_name
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill mail_reads: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
|
||||
if _, err := DB.ExecContext(ctx, `INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker, fmt.Sprintf("done rows=%d", n)); err != nil {
|
||||
return fmt.Errorf("backfill mail_reads: 写标记失败: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
fmt.Printf("已读模型迁移:把 %d 封历史已读邮件记到主收件人名下(一次性)\n", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||||
|
||||
@ -438,3 +438,22 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_platform
|
||||
ON sessions(platform_id) WHERE platform_id <> '';
|
||||
|
||||
-- ─── 按人记录的已读状态(mail_reads)─────────────────────────────────
|
||||
--
|
||||
-- 与 init_sqlite.sql 的同一段语义一致:未读是**每个读者各自的状态**,
|
||||
-- 而不是邮件行上的一个全局列(详见那边的注释:dsh 那句"收件箱列表未展示它")。
|
||||
CREATE TABLE IF NOT EXISTS mail_reads (
|
||||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||||
reader_name TEXT NOT NULL,
|
||||
read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (mail_id, reader_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_reads_reader ON mail_reads(reader_name);
|
||||
|
||||
-- 一次性迁移标记(见 sqlite 侧注释)
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
@ -477,3 +477,35 @@ CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||||
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
|
||||
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
|
||||
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。
|
||||
|
||||
-- ─── 按人记录的已读状态(mail_reads)─────────────────────────────────
|
||||
--
|
||||
-- 为什么需要它(2026-09-13 实测缺陷):已读原先只存在 mails.status 这一列上,
|
||||
-- 而它是**邮件级**的 —— 任何收件人读掉,对所有收件人(含抄送)都变成已读。
|
||||
-- 后果三连:
|
||||
-- ① Agent 的 read_inbox(默认 status=unread)拿不到信,只能靠提示词里的
|
||||
-- mail_id 兜(实测 dsh 明确回报"收件箱列表未展示它,直接按 mail_id 读取成功");
|
||||
-- ② 人类还没读,未读却被抄送的 Agent 读掉了(徽标一起变);
|
||||
-- ③ 桥的补投判据 pending_mails = CountUnread(agent):被别人读掉后计数为 0,
|
||||
-- 于是 SSE 漏过或进程重启时那封信**不会被补投** —— "以为送到了,其实没人看过"。
|
||||
--
|
||||
-- 语义:某封邮件对某个读者(用户名或 Agent 名)是否未读 = 这张表里没有对应行。
|
||||
-- mails.status 保留为"有人读过/已归档"的冗余,不再作为未读判据。
|
||||
CREATE TABLE IF NOT EXISTS mail_reads (
|
||||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||||
reader_name TEXT NOT NULL,
|
||||
read_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (mail_id, reader_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_reads_reader ON mail_reads(reader_name);
|
||||
|
||||
-- 键值元数据表:给**一次性**迁移/回填留标记。
|
||||
--
|
||||
-- 为什么不能只靠 INSERT ... IF NOT EXISTS 这类幂等语句:回填只能做一次。
|
||||
-- 若每次启动都跑,它会把"某个抄送方读过"的邮件按主收件人身份写成已读 —— 正是
|
||||
-- 这次要修的错。所以回填必须由标记守住。
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user