技术方案 · DESIGN.MD 摘要 · 基线 ORIGIN/DEVELOP · 产品方案 · 技术方案 · Review 回应

回归上游的两阶段拆法

TL;DR
技术上拆两阶段:阶段一只改代码不改 schema(第一优先级是停掉 sweeper —— 它正在持续删数据), 阶段二才删列 / 索引 / 函数 / 4 个触发器,且必须先产出四项生产数据盘点并人工核对。这样回滚 = 回滚代码,不碰数据库。
Code review 的 6 条 findings 已全部修订:基线更正为 origin/develop、失败告警改为新增、 latest 恢复已发布语义、生命周期矛盾拆开、暴露面按部署形态分档、URL 轴移交 MIA-279。
01

基线更正(review P1-1)

d9a9f6835 不在基线内
它只存在于已废弃的 fix/attachment-bind-authz-parity 分支。已在 origin/develop 基线核实:ActorTaskID / resolveActorTaskID / actor_task_id / warnUnboundAttachmentIDs 全部不存在;LinkAttachmentsToIssue 仍带 uploader_type / uploader_id 两行条件。

因此:失败可观测性是新增项而非「保留」;绑定的修改就是删掉那两行,不涉及 actor_task_id。

另外原分支基于 origin/xisheng,还带了 4 个与附件模型无关的 v0.0.2 release-note commit。已用 git rebase --onto origin/develop origin/xisheng <branch> 只重放 spec commit,甩掉那 4 个,并把 tracking 指向 origin/develop。分支与 worktree 原地保留,工作没丢。

02

授权契约

操作判定与上游
读取 / 下载 / 预览附件请求者是该附件 workspace_id 的成员一致
绑定附件到 issue同 workspace + 目标 issue_id IS NULL一致
绑定附件到 comment附件已属该 issue + comment_id IS NULL一致
往运行中任务的回复写文件GetRunningNativeWorkTaskForAttachmentUploadRollica 专有(保留)
读取 Work / chat 会话与消息会话创建者(GetWorkSessionForCreator 404 / ListTaskMessagesByUser 403)一致,不改

LinkAttachmentsToIssue 回归上游三条件形态 —— 在当前基线上就是删掉两行:

UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
  AND issue_id IS NULL
-  AND uploader_type = sqlc.arg(uploader_type)   ← 删
-  AND uploader_id   = sqlc.arg(uploader_id)     ← 删
  AND id = ANY($3::uuid[]);
副产品:MIA-284 自动修好
上游这条 SQL 本来就没有 uploader 条件,所以 agent 代人类挂图天然可行。已在 BUG-2 建单会话逐字证实退化路径(带了 --attachment-id、无报错、随后下载并改用 comment add --attachment)。
已发布契约的显式收窄(review P1-2)
rollica-work-surface 发布过 Work attachments remain creator-private across every access path。本 change 以 MODIFIED Requirement 显式收窄它:私密性改由会话与消息读取层统一保证,附件层不再重复判定。产品已在知情前提下确认,残余风险与部署形态限定一并写入 spec。
03

生命周期:禁止的与保留的(review P1-5)

原草稿同一条 requirement 里既说「不得在线删除对象」又说「容器删除连带删对象」,自相矛盾。已拆开:

04

两阶段

阶段一 · 只改代码

  1. 先停 sweeper(最高优先级,可单独先发):main.go 移除启动,删 attachment_sweeper.go 与其测试,删查询 DeleteExpiredStagedAttachments。
  2. 停止读写 expires_at:UploadFile 不再写入;各 Link / Replace 查询不再清空;移除 StagedAttachmentTTL 与 staged_ttl_secs。
  3. 删除附件级读授权,读取调用点改为 workspace 成员判定;保留 attachmentRequiresCreatorScope 供 URL 策略使用。
  4. 绑定回归上游(删两行)。
  5. 新增失败可观测性:CLI warnUnboundAttachmentIDs + 服务端 dropped-id slog.Warn + 单测。
阶段一之后
expires_at 列仍存在但无人读写;迁移 245 的触发器仍存在但无害(只把该列置 NULL,已无消费者)。回滚 = 回滚代码。

阶段二 · 迁移

DROP TRIGGER IF EXISTS trg_agent_promote_avatar_attachment ON agent;
DROP TRIGGER IF EXISTS trg_user_promote_avatar_attachment ON "user";
DROP TRIGGER IF EXISTS trg_workspace_promote_avatar_attachment ON workspace;
DROP TRIGGER IF EXISTS trg_squad_promote_avatar_attachment ON squad;
DROP FUNCTION IF EXISTS promote_avatar_attachment();
DROP INDEX IF EXISTS idx_attachment_staged_expiry;
ALTER TABLE attachment DROP COLUMN IF EXISTS expires_at;

down.sql 重建列(全 NULL)、索引、函数与 4 触发器。列内容不可逆恢复 —— 但阶段一后已无语义消费者。迁移号按 develop 当前最大值 +1 确定,不预先写死。

05

阶段二之前必须产出的四项盘点

  1. expires_at IS NOT NULL 的行数,及其中三外键全 NULL 的行数 —— 现行判据下「随时可能被删」的集合。
  2. 上述集合中 url 被任一 avatar_url 引用的行数 —— 曾经/仍处于误删风险的头像。
  3. avatar_url 形如 /api/attachments/{uuid}/download 的 agent/user/workspace/squad 行数 —— 迁移 248 之后仍在产生的坏数据存量。
  4. 上述 3 中对应 attachment 行已缺失的条数 —— 已发生且不可恢复的损失规模。
06

测试

必须删除的既有测试
TestLinkAttachmentsToIssueRequiresOriginalUploader(0432ba618 引入)断言非上传者不能绑定 —— 这正是本 change 移除的行为。删除,并在 spec 的 REMOVED Requirements 中记录该契约。

新增覆盖:

Work 会话云盘既有测试须全部保持通过。

07

风险与缓解

风险缓解
id 泄漏后 Work 附件可被同 workspace 成员读取产品已知情接受并写入 spec;会话与消息层门禁不变且新增测试锁住
公开 CDN 无签名部署上暴露面扩大到匿名结论按部署形态分档写入 spec;该形态的 URL 策略归 MIA-279
阶段二删列不可逆阶段一先移除所有消费者使该列无语义;down.sql 可重建结构;发布说明声明列内容不恢复
去授权时漏改某个调用点读取调用点逐一对照上游同名函数改写,每处配测试
孤儿 blob 永久累积产品已接受。后续如需可观测性可加只读报表,不得引入删除路径
08

交给 MIA-279 的轴(不在本 change)

下列都属于「持久引用与可渲染性」这一条轴,产品决策为与 MIA-279 合并处理,MIA-277 / MIA-278 已在 issue 树上挂到 MIA-279 之下:

两轴为何能并行
本 change 第 2 步只摘掉 attachmentRequiresCreatorScope 在授权上的用途,不动它在 URL 策略上的用途。因此两条轴互不阻塞,MIA-279 的独立快修也不受影响。