Files
MailUI4Agents/server/internal/db/migrate.go
JianFeeeee 1619399470 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** 
2026-09-13 14:25:44 +08:00

221 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"strings"
)
//go:embed migrations/init.sql
var initSQLPostgres string
//go:embed migrations/init_sqlite.sql
var initSQLSQLite string
// Migrate 建表建索引。两种方言各有一份 schema语义保持一致。
func Migrate(ctx context.Context) error {
switch D {
case Postgres:
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
return fmt.Errorf("migrate postgres: %w", err)
}
case SQLite:
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
for i, stmt := range splitStatements(initSQLSQLite) {
if _, err := DB.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
}
}
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
if err := addMissingColumns(ctx); err != nil {
return err
}
default:
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 语句,不含字符串字面量里的分号,
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
func splitStatements(script string) []string {
var out []string
for _, raw := range strings.Split(script, ";") {
var lines []string
for _, line := range strings.Split(raw, "\n") {
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
continue
}
lines = append(lines, line)
}
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
out = append(out, stmt)
}
}
return out
}
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
//
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够SQLite 没有这个语法,
// 只能先查 pragma 再决定加不加。
//
// 新增列时同时改两处init_sqlite.sql 的 CREATE TABLE给新库与这张表给老库
var sqliteAddColumns = []struct{ table, column, ddl string }{
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
// 会话级往返预算0 = 不限)。旧库默认 0引入预算不应该把已在进行的会话卡死。
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
// Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
// 造出一批看起来有值实际是错的数据。
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
// 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回
// to_address / agent_name历史事件因此继续工作不需要数据迁移。
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
// 日历事件已触发的 occurrence。旧库为 NULL等价于「从未触发」,
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
// 而反过来(默认成 event_time会让正在等的提醒永远发不出去。
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
// 日历事件的权限档位plan / workspace / full。事件触发时若新建会话
// 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。
//
// 旧库默认 'workspace':历史事件补发提醒不该静默升到 full提权路径
// 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。
// 与 sessions.permission_mode 的默认取向一致。
{"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
// 与新建会话的语义一致,不需要数据迁移。
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
// 派给该 Agent 的新任务默认多少个来回。
// 旧库也给 20之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
// 不能直接搬过来当单任务预算。
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
// 会话级权限档位plan / workspace / full
//
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
// 接收平台实际做到的强制力native / advisory由插件心跳自报后落到会话上。
//
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
// 保守方向是承认做不到,而不是假装做到了。
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
// Agent 自报的档位强制能力native / advisory随心跳更新。
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
// 待决请求类型permission/question与多选语义供 ask_user_question 桥接使用。
// 旧库默认 'permission'/0历史请求是危险工具审批语义不变。
{"permission_requests", "kind", "ALTER TABLE permission_requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'permission'"},
{"permission_requests", "multi_select", "ALTER TABLE permission_requests ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0"},
// 邮件上的请求类型与多选标记(与 permission_requests 表一致)。
// 旧库默认 ''/0历史权限邮件按单选审批渲染。
{"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"},
{"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"},
}
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
var sqliteAddIndexes = []string{
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
// 人类决策后要按 mail_id 反查上游 permission id
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
}
func addMissingColumns(ctx context.Context) error {
for _, c := range sqliteAddColumns {
has, err := columnExists(ctx, c.table, c.column)
if err != nil {
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
}
if has {
continue
}
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
}
fmt.Printf("补列 %s.%s\n", c.table, c.column)
}
for _, ddl := range sqliteAddIndexes {
if _, err := DB.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
}
}
return nil
}
func columnExists(ctx context.Context, table, column string) (bool, error) {
// pragma_table_info 是表函数形式的 PRAGMA可以直接当表查比解析 PRAGMA 输出干净)。
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
var n int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
table, column).Scan(&n)
return n > 0, err
}