全局素材池——一个存储,引用记账
父节点:corpus
结论(2026-09-05): 媒体素材是一个 owner 范围的池子(
assets)加一张边表(asset_references),边表说的是谁在用什么。引用每次保存时从内容重算,从不按动作手工维护。删除有闸:被引用的素材拒绝删除,拒绝语里点名引用者。死表media_assets(三个 genre 外键、零个写者)已经没了。
触发问题。 2026-09-05 之前,素材属于它的 holder——storage_key = <holder_id>/<asset_id>,"删 holder → 同事务删它的素材"(那段旧注释还留在表上方,backend/db/schema.sql:560-578)。这个形状让复用不可能(第二条想用同一张图的条目只能再传一份),也让共享一旦存在删除就成了破坏性操作:删一篇 writing 直接删掉它的 blob(corpus/usecase/writings_delete.go:1-8 记着旧行为)。owner 点名引用记账是最容易错的部分。死表 media_assets——三个 genre 外键、没有写者——在 schema.sql:304 记为已删;代码、migration、测试里都不再有它的名字。
设计
- 一个池子,按 owner 划分。
assets(owner_id NOT NULL, holder_id nullable, kind image|attachment, storage_key, content_type, sha256, …)——schema.sql:585-600。holder_id只作为迁移面包屑留着(一张旧图来自哪条笔记);判断"在用"从不看它(schema.sql:582-584)。 asset_references(asset_id, referrer_kind, referrer_id)——schema.sql:608-615,三列合起来是主键,ON DELETE CASCADE只是双保险(有闸在,删除根本到不了被引用的行)。两种引用者:corpus和microsite(corpus/entity/asset.go:37-38)。- 保存即重算。 扫描器是
ScanAssetReferences(corpus/usecase/asset_refs.go:36-50):一条正则扫正文里的standmeet-asset:<uuid>标记(一个 UUID (universally unique identifier),或 multipart 保存期间的pending-*占位)。每一处笔记写入都经过RebuildNoteRefs(corpus/usecase/wiki_crosslink.go:109-121),它在一个钩子里同时重建交叉链接和素材引用,新加的写入点不可能漏掉其中一个。重算(note_asset_refs.go:47-67)把笔记的引用设成恰好"正文标记 + 封面"。writing:保存事务提交后跑RebuildWritingAssetRefs(writings_save.go:142)。microsite:每次构建后跑RebuildMicrositeAssetRefs,扫全部源文件找同一个标记(note_asset_refs.go:138-151,接线在cmd/server/boot_wireup_microsites.go:76)。 - corpus 是 diff,不是清空重来。 附件(
kind=attachment)靠显式的 attach/detach,从不在正文里被引用;holder 是这条笔记的素材,不管正文引不引都保持挂载。所以 corpus 的重算只丢掉复用来的、正文已不再引用的图片引用(staleImageRef,note_asset_refs.go:95-101)。一刀切的"全删再插"会在下一次无关的保存里把条目的附件丢掉。microsite 没有附件概念,所以它的重算是整体替换。 - 删除闸。
DeletePoolAsset(corpus/usecase/asset_pool.go:67-82)数引用,有就返回ErrAssetReferenced;op 层把它变成 409 Conflict,消息是still used by N corpus entries and M microsites — remove those first(corpus/ops/asset_pool.go:93-126)。只有无引用的素材才会被删——先 blob,后行。删笔记或 writing 会释放它的引用(note_assets.go:279、writings_delete.go:21);素材留在池子里。 - 存储。 blob 存在 MinIO(讲 S3 (Amazon 对象存储协议) 的对象存储;
infra/deploy/docker-compose.yml里的minio服务),经infra/storage/storage.go。正文从不存 URL——只存稳定标记——标记在响应时解析成一个 1 小时 TTL (time-to-live) 的预签名GETURL(storage.go:28、:175;asset_refs.go:65-95)。microsite 里的嵌入加载GET /api/v1/assets/{id},它只在有 microsite 引用该素材时才 302 跳到预签名 blob(routes/public/microsite_assets.go);只被私有 corpus 条目用的素材返回 404,所以公开端点枚举不了池子。
几个面
- 编辑器——引用以复用。
app/src/components/admin/sections/corpus/CorpusAssetsPanel.tsx:88-116的PoolReuse:打开池子,挑一个在别处上传的素材,它的标记落进正文,下一次保存这条条目也引用它(4f2634619;corpus-asset-pool-reuse-ui.spec.ts)。素材在本条目上还是从池子复用,插入路径是同一条。 - 后台——Resources → Assets。
/admin/assets(AssetsSection.tsx)是池子的查看器 + 带闸的删除;那句 409 消息就是错误 toast 显示的内容。旁边的/admin/data(DataSection.tsx)管的是每个 microsite 的 NoSQL 存储,不是素材——resources导航组是 microsites · assets · data(app/src/lib/admin/nav.ts:60-63)。 - MCP op。 按条目 attach/detach:
assets.upload/assets.delete(corpus/ops/assets.go)。池子:assets.list/assets.references/assets.pool_delete(corpus/ops/asset_pool.go)。admin 路由分发的是同一批 op(routes/admin/corpus_crud.go:39-46),owner 工具集规范把五个都列了(e2e/test/norm-outward-toolset.spec.ts:107、:151)——service-handle 对等。 - SDK。
AssetWidget(sdk/packages/react/src/widgets/AssetWidget.tsx)刻意接收完整的standmeet-asset:<uuid>标记:同一个扫描器从构建后的源码里读出它,于是活页面用的素材和笔记用的一样受闸保护(microsites)。
诚实的天花板
- 闸是先读后删,不是一条原子语句(
asset_pool.go:64-66自己写明了)——对单 owner 实例够用,引用的唯一写者就是 owner 本人;真有并发时把删除改成"不存在引用才删"的条件语句。 asset_references是写事务之外的派生索引(和note_refs一样):行写入和重算之间崩溃,引用会落后一次保存,下一次保存修正——是陈旧,不是损坏。- migration 对 holder 已经解析不到的素材留下孤儿 blob 在 MinIO 里(
2026-09-05-global-assets.sql:17-19)——不可见、永不回收;没有清扫器。 - 附件对内容扫描器刻意不可见;它的引用只靠显式 attach/detach 生灭。
长期成立的点
asset_references是"在用"的唯一权威;holder_id永远不是。- 先删引用者,再删素材——删除顺序是产品规则,不是级联。
- 所有引用者共用一套标记语法(笔记正文、writing 正文、microsite 源码):
standmeet-asset:<uuid>。和 backlinks-as-rebuilt-edge-tables 同一个形状——边表从正文重建,从不手工维护——也是 ephemeral-over-stateful 的同一种口味:派生,别维护。
已实现 2026-09-05。 d81d2782a(池子 + asset_references + 闸;migration 2026-09-05-global-assets.sql 从 holder 笔记回填 owner_id,并给每张已有图片种一条 corpus 引用)、79136cac5(笔记与 writing 的保存即重算;Assets + Data 后台分区)、836af0fea(AssetWidget、microsite 重算、公开素材路由)、4f2634619(编辑器里的引用以复用)。守卫:global-assets-guard.spec.ts(被引用 → 409 且点名 corpus;解除引用 → 204 且消失)、asset-reference-recompute.spec.ts(引用/取消引用、封面设/清、A→B 换图引用跟着挪——不是两个都有也不是两个都没、删笔记释放、共享素材在每个引用者都走完之前一律拒绝)、assets-manager-ui.spec.ts、corpus-asset-pool-reuse-ui.spec.ts、microsite-asset-widget.spec.ts,加上更早的 genre-assets*.spec.ts 族(按 genre 挂载、继承、阅读器、后台)。writing 的 zip 导入/导出仍按自己的方式带素材——writings-import-export。设计种子:docs/design/global-assets.md。
写于 2026-09-07,对照 standmeet-new main 36789537d(v0.1.31)。