feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -89,6 +89,35 @@ func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachm
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EnsureAttachable 只做**读取校验**:这批附件是否存在、属于该上传者、且尚未挂载。
|
||||
//
|
||||
// # 为什么要有一个「只查不改」的版本
|
||||
//
|
||||
// 原先只有 AttachToMail,而它在 CreateMail **之后**调用。于是附件不合法时
|
||||
// (不属于我 / 已随别的邮件发出)请求返回 403/409,但那封邮件**已经入库、已经
|
||||
// 通知了收件人、已经扣掉了会话预算**。实测两封探针邮件(403 与 409)都躺在库里,
|
||||
// used_rounds 也涨了。发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在产生任何副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它不能取代 AttachToMail 里的原子判断:两次调用之间仍有竞态窗口
|
||||
// (另一个请求把同一个附件挂走了)。那条路径靠调用方回滚,见 handler.attachAll。
|
||||
func EnsureAttachable(ctx context.Context, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return err // ErrAttachmentNotFound 或库错误
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
if a.MailID != nil {
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachToMail 把一批待挂载附件绑到某封邮件上。
|
||||
//
|
||||
// 每条都要求:存在、属于该上传者、且尚未挂载。
|
||||
@ -212,6 +241,87 @@ func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, e
|
||||
return removable, nil
|
||||
}
|
||||
|
||||
// SweepUnreferencedBlobs 删掉磁盘上没有任何库记录指向的内容文件。
|
||||
//
|
||||
// # 为什么 SweepOrphanAttachments 不够
|
||||
//
|
||||
// 那个函数走的是 `SELECT … FROM attachments WHERE mail_id IS NULL` —— 它只能看见
|
||||
// **库里还有记录**的孤儿。一旦记录本身消失(清库、手工 DELETE、迁移),
|
||||
// 对应的文件就永远脱离了 GC 的视野:本机实测磁盘 8 个 blob 里 7 个没有任何库记录,
|
||||
// 全部来自 09-03 那次清库,之后一直躺在那里。
|
||||
//
|
||||
// 这个反向清理从**磁盘**出发:枚举全部内容文件,凡是 attachments 与
|
||||
// calendar_attachments 都不引用的就删。返回删掉的数量。
|
||||
//
|
||||
// # 为什么要 minAge
|
||||
//
|
||||
// 上传是「先落盘、再入库」(顺序不能反,否则会出现「库里有记录、磁盘没文件」的
|
||||
// 下载 500)。那两步之间有一个窗口,此刻文件确实没有任何库记录 —— 不设年龄下限
|
||||
// 会把正在上传的文件删掉。取一个远大于单次上传耗时的值。
|
||||
func SweepUnreferencedBlobs(ctx context.Context, blobs BlobLister, minAge time.Duration) (int, error) {
|
||||
if blobs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
sums, err := blobs.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(sums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 一次查回全部被引用的 sha256。逐个文件查一次库是 N 次往返,
|
||||
// 而这两张表加起来通常只有几百行。
|
||||
referenced := map[string]struct{}{}
|
||||
for _, q := range []string{
|
||||
`SELECT sha256 FROM attachments`,
|
||||
`SELECT sha256 FROM calendar_attachments`,
|
||||
} {
|
||||
rows, qErr := db.DB.QueryContext(ctx, q)
|
||||
if qErr != nil {
|
||||
return 0, qErr
|
||||
}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if sErr := rows.Scan(&s); sErr != nil {
|
||||
rows.Close()
|
||||
return 0, sErr
|
||||
}
|
||||
referenced[s] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
if rErr := rows.Err(); rErr != nil {
|
||||
return 0, rErr
|
||||
}
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
removed := 0
|
||||
for sum, mod := range sums {
|
||||
if _, ok := referenced[sum]; ok {
|
||||
continue
|
||||
}
|
||||
if mod.After(cutoff) {
|
||||
continue // 可能正在上传(落盘与入库之间的窗口)
|
||||
}
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
continue // 删不掉就下一轮再试,不该让整次清理中断
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// BlobLister 是 SweepUnreferencedBlobs 需要的存储能力。
|
||||
//
|
||||
// 用 map[string]time.Time 而不是自定义结构体:那样 blob 包就不必 import repo
|
||||
// (底层存储依赖上层仓储会很怪),而 Go 的接口是结构化匹配的,签名一致即可。
|
||||
type BlobLister interface {
|
||||
// List 返回 sha256 → 该内容文件的修改时间。
|
||||
List() (map[string]time.Time, error)
|
||||
Remove(sum string) error
|
||||
}
|
||||
|
||||
// AttachmentAccessible 判断某人是否有权读取某附件:
|
||||
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
|
||||
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
|
||||
|
||||
181
gateway/internal/repo/defaultsession_test.go
Normal file
181
gateway/internal/repo/defaultsession_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这一组测试钉住「省略 session 位复用默认会话」与「真的新建会话」必须可区分。
|
||||
//
|
||||
// 事故背景:handler 层曾用 `parentMailID == nil` 判断「是不是新建会话」,
|
||||
// 据此决定要不要写往返预算与权限档位。但省略 session 位复用默认会话时
|
||||
// parentMailID 也是 nil —— 于是每一封续谈的信都会把这两个字段重置成默认值。
|
||||
//
|
||||
// 线上实测(修复前):
|
||||
//
|
||||
// 第一封 to=pi@/tmp/budgetprobe max_rounds=7 → budget_max 7
|
||||
// 第二封 to=pi@/tmp/budgetprobe(省略该字段) → budget_max 20 ← 被静默改写
|
||||
//
|
||||
// 而那段代码的注释本身正在论证这不该发生(「续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算」)—— 意图是对的,守卫写错了。
|
||||
//
|
||||
// 修法:FindOrCreateDefaultSessionCreated 额外返回 created,
|
||||
// handler 改用它而不是 parentMailID。
|
||||
|
||||
func TestDefaultSessionFirstCallCreates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("从未通信过的 name@path,第一次必须报告 created=true")
|
||||
}
|
||||
if id.String() == "" {
|
||||
t.Fatal("应返回有效会话 id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSessionReuseReportsNotCreated(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 复用的前提是这条会话里有该收件人参与过的邮件(EXISTS 子查询)
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一条默认会话:first=%s second=%s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用已有默认会话时 created 必须为 false —— 这正是预算被冲掉的根因")
|
||||
}
|
||||
}
|
||||
|
||||
// 这条是上面那个线上事故的最小复现:走 created 判据时预算不被改写。
|
||||
func TestBudgetSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 模拟 handler:只有 created 为真才设预算
|
||||
if _, err := SetSessionBudget(ctx, id, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
// 若这里为真,handler 就会重设预算 —— 事故重现
|
||||
t.Fatal("复用时 created 为真会让 handler 重设预算")
|
||||
}
|
||||
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Max != 7 {
|
||||
t.Fatalf("续谈不得改写预算:want 7, got %d", b.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位与预算同一个判据,一起钉住:plan 档不能因为第二封信而升成 workspace。
|
||||
func TestPermissionModeSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := SetSessionPermissionMode(ctx, id, "plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用时 created 为真会让 handler 把档位重置成默认档")
|
||||
}
|
||||
|
||||
if got := SessionPermissionMode(ctx, id); got != "plan" {
|
||||
t.Fatalf("续谈不得改写档位:want plan, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同工作目录是不同的默认会话,两边各自新建。
|
||||
// 这条防的是「把 created 实现成一个全局标志」之类的偷懒写法。
|
||||
func TestDefaultSessionPerWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
a, createdA, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w1", "jianf", "甲")
|
||||
if err != nil || !createdA {
|
||||
t.Fatalf("/w1 应新建:%v %v", createdA, err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, a, nil, "jianf", "", "pi", "/w1", "甲", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, createdB, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w2", "jianf", "乙")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !createdB {
|
||||
t.Fatal("/w2 是另一个工作目录,应当另建一条默认会话")
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("不同工作目录不该共用同一条默认会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧签名仍在别处被调用,保持行为不变(只是丢掉 created)。
|
||||
func TestLegacyWrapperStillWorks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != id {
|
||||
t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again)
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,9 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human`
|
||||
|
||||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||||
//
|
||||
@ -202,7 +204,8 @@ func scanTreeRows(rows interface {
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil {
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount,
|
||||
&t.FromHuman, &t.ToHuman, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
|
||||
@ -425,22 +425,6 @@ func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UU
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
|
||||
func FirstAdminUsername(ctx context.Context) (string, error) {
|
||||
var name string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT username FROM users
|
||||
WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
Reference in New Issue
Block a user