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:
2026-09-13 14:25:44 +08:00
parent 49ec22f916
commit 1619399470
10 changed files with 432 additions and 17 deletions

View File

@ -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 ''
);

View File

@ -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 ''
);