server/cmd/server/attachment_sweeper.go,上游 Multica 完全没有这个东西,是我们自己加的(0432ba618,2026-07-14 11:37)。
它是一个后台垃圾回收进程:
expires_at <= now() 且 issue_id、comment_id、chat_message_id 三个外键全为 NULL 的附件行。store.DeleteKeys 删掉 S3 对象 —— 不可恢复。它要解决的问题很小:用户在编辑器里贴了图然后没保存就走了,这个 blob 永远没人引用,占存储。所以给「还没绑定到任何东西」的上传加了 7 天 TTL。
agent.avatar_url 这个字符串字段,不是外键。
于是 sweeper 把正在被 Web/Desktop/Mobile 渲染的头像判成垃圾删掉了。
这不是推测,是我们自己的修复迁移写在注释里的原话:
245_preserve_avatar_attachments.up.sql:
"Migration 176 classified those rows as staged and could therefore expire an image that was still actively rendered by Web, Desktop, and Mobile."
而且迁移 176 还把所有已存在的未绑定附件回填成 created_at + 7 天 —— 上线那一刻,所有超过 7 天的老头像立即到期。迁移 248 结尾那句 RAISE NOTICE '... left % records unchanged because matching attachment rows were missing' 就是在说:有一批 S3 对象当时已经被删掉、找不回来了。
之后为了救它,又加了:sweeper 里 4 条 NOT EXISTS (... avatar_url = attachment.url) 排除、迁移 245 的 1 个 PL/pgSQL 函数 + 4 个数据库触发器、迁移 248 的正则回写。而这一切的起点,只是想清理「用户贴了图没保存」的临时文件。
2878abf43、fccd4bda6、62dae5966、e1549f5e8、迁移 245、迁移 248,加上本次 d9a9f6835。每处只打掉一个症状。multica agent avatar 仍走 /api/upload-file,把不可渲染的代理 URL 写进 avatar_url;又因该 URL 与 attachment.url 不相等,迁移 245 的触发器不生效 → 7 天后仍被连 S3 一起删。attachment.sql、file.go、service/issue.go 这些上游热文件里堆了 9 条 fork 专有查询和大量条件分支。一句话:附件不再拥有自己的权限模型和 GC 模型;它继承所属容器的权限,由容器的生命周期决定存亡。
| 删除项 | 来源 | 为什么删 |
|---|---|---|
Sweeper + expires_at | 0432ba618 | 上游没有。删过正在使用的头像;判据(三 FK 全 NULL)与「是否被引用」不等价。孤儿 blob 改用离线报表 + 受控清理,不在线自动删 S3 |
| 迁移 245 的函数 + 4 触发器 | 迁移 245 | 只为给 sweeper 打补丁而存在。数据库触发器隐式改数据,是「莫名崩溃」的典型来源 |
| 附件级读授权 | a21ae23a3 | 权限应由容器判定,附件行不该自己再判一次。两套模型不一致已直接造成 BUG-3 / MIA-284 |
| staged 附件 URL 改写 | a21ae23a3 | 头像渲染不出来(迁移 248)和很可能 MIA-279 下载失效的直接原因。回归上游:可渲染资源就给可渲染 URL |
| 绑定的 uploader 等值判断 | 0432ba618 | 上游没有此条件。它让 agent 永远无法代人类挂图(BUG-3),且它防的「成员猜 UUID」路径实际不可达 |
| 保留项 | 为什么保留 |
|---|---|
Work 会话云盘ListWorkSessionAttachments、attachment_latest_head 及其 4 条查询、GetAttachmentForUpdate | Chat→Work 带来的真实产品能力:集中列出/展示本会话传过的附件,并支持同名文件的最新版本。上游无 Work 概念,这部分必须是 fork 的 |
Work 回复上传的任务门禁GetRunningNativeWorkTaskForAttachmentUpload | 这不是「给附件加权限」,而是「限制谁能往正在运行的任务回复里塞文件」,属于 Work 执行契约 |
| Work 会话本身的私密性 | Work 私密是产品定义,不删。但实现位置要换 —— 见下一节 |
Work 会话是私密的。但上游在附件读取层只校验 workspace 成员身份,没有会话级校验。所以「完全按上游」和「Work 保持私密」在这一点上有张力:
| 方案 | 行为 | 代价 | 风险 |
|---|---|---|---|
| A · 纯上游 | 附件读取只校验 workspace 成员 | fork 分歧最小,代码最少 | 别人若拿到你 Work 附件的 UUID(截图 / 日志 / 转发链接),能读到。UUID 不可枚举,但一旦泄漏就无边界 |
| B · 容器继承 (推荐) | 附件若属于某 Work/chat 会话,读取时要求会话可见性(会话创建者,或该会话的运行中任务) | 比 A 多一处判断、一条查询 | 无。TTL / GC / URL 改写 / uploader 等值仍全部删掉 |
work-session-attachment-drive:定义 Work 会话云盘 contract —— 列出本会话附件、同名文件最新版本(head)、展示与下载语义。Chat→Work 唯一保留的附件相关 fork 能力。attachment-lifecycle:存亡由所属容器决定,不再有 expires_at 与在线自动 GC。孤儿 blob 通过离线报表识别,清理需受控执行。attachment-authorization:不再有独立授权模型,权限继承容器。读与写(绑定)使用同一判定,杜绝「能读却不能绑」。attachment-reference:可渲染资源(头像等)的持久化引用不得是需鉴权的代理端点。staged-attachment-expiry(含 sweeper 与 4 个数据库触发器):整体移除。attachment_sweeper.go(删)、attachment.sql、file.go、service/issue.go、cmd_agent.go(头像改走 /api/avatar-upload)。attachment.expires_at、删函数 promote_avatar_attachment 与 4 个触发器。main.go 不再启动它。attachment.sql 从 28 条查询回到接近上游 19 条 + 会话云盘所需少数几条;file.go、service/issue.go 的 fork 条件分支大幅减少;attachment_sweeper.go 整体消失。attachment_latest_head(附加表)与 Work 会话查询,属于 AGENTS.md 鼓励的「narrow module / additive schema field」形态,未来上游导入冲突面显著下降。hasSendableWorkTurn 允许纯附件消息,判断为前端校验问题,与存储模型无关,建议从 epic 摘出。attachment_latest_head(会话云盘的「最新版本」)是否是你要的产品行为,还是也想简化?我按「保留」写的。d9a9f6835(授权统一),还是让它独立先合?合并更完整,但会让那个已验证的修复等这个大方案。