2026-09-23·by Sijie Wang#standmeet#architecture#design

global-asset-pool

全局素材池——一个存储,引用记账

父节点: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-600holder_id 只作为迁移面包屑留着(一张旧图来自哪条笔记);判断"在用"从不看它(schema.sql:582-584)。
  • asset_references(asset_id, referrer_kind, referrer_id)——schema.sql:608-615,三列合起来是主键,ON DELETE CASCADE 只是双保险(有闸在,删除根本到不了被引用的行)。两种引用者:corpusmicrositecorpus/entity/asset.go:37-38)。
  • 保存即重算。 扫描器是 ScanAssetReferencescorpus/usecase/asset_refs.go:36-50):一条正则扫正文里的 standmeet-asset:<uuid> 标记(一个 UUID (universally unique identifier),或 multipart 保存期间的 pending-* 占位)。每一处笔记写入都经过 RebuildNoteRefscorpus/usecase/wiki_crosslink.go:109-121),它在一个钩子里同时重建交叉链接素材引用,新加的写入点不可能漏掉其中一个。重算(note_asset_refs.go:47-67)把笔记的引用设成恰好"正文标记 + 封面"。writing:保存事务提交后跑 RebuildWritingAssetRefswritings_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 的重算只丢掉复用来的、正文已不再引用的图片引用(staleImageRefnote_asset_refs.go:95-101)。一刀切的"全删再插"会在下一次无关的保存里把条目的附件丢掉。microsite 没有附件概念,所以它的重算是整体替换。
  • 删除闸。 DeletePoolAssetcorpus/usecase/asset_pool.go:67-82)数引用,有就返回 ErrAssetReferenced;op 层把它变成 409 Conflict,消息是 still used by N corpus entries and M microsites — remove those firstcorpus/ops/asset_pool.go:93-126)。只有无引用的素材才会被删——先 blob,后行。删笔记或 writing 会释放它的引用(note_assets.go:279writings_delete.go:21);素材留在池子里。
  • 存储。 blob 存在 MinIO(讲 S3 (Amazon 对象存储协议) 的对象存储;infra/deploy/docker-compose.yml 里的 minio 服务),经 infra/storage/storage.go。正文从不存 URL——只存稳定标记——标记在响应时解析成一个 1 小时 TTL (time-to-live) 的预签名 GET URL(storage.go:28:175asset_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-116PoolReuse:打开池子,挑一个在别处上传的素材,它的标记落进正文,下一次保存这条条目也引用它(4f2634619corpus-asset-pool-reuse-ui.spec.ts)。素材在本条目上还是从池子复用,插入路径是同一条。
  • 后台——Resources → Assets。 /admin/assetsAssetsSection.tsx)是池子的查看器 + 带闸的删除;那句 409 消息就是错误 toast 显示的内容。旁边的 /admin/dataDataSection.tsx)管的是每个 microsite 的 NoSQL 存储,不是素材——resources 导航组是 microsites · assets · data(app/src/lib/admin/nav.ts:60-63)。
  • MCP op。 按条目 attach/detach:assets.upload / assets.deletecorpus/ops/assets.go)。池子:assets.list / assets.references / assets.pool_deletecorpus/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。 AssetWidgetsdk/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 后台分区)、836af0feaAssetWidget、microsite 重算、公开素材路由)、4f2634619(编辑器里的引用以复用)。守卫:global-assets-guard.spec.ts(被引用 → 409 且点名 corpus;解除引用 → 204 且消失)、asset-reference-recompute.spec.ts(引用/取消引用、封面设/清、A→B 换图引用跟着挪——不是两个都有也不是两个都没、删笔记释放、共享素材在每个引用者都走完之前一律拒绝)、assets-manager-ui.spec.tscorpus-asset-pool-reuse-ui.spec.tsmicrosite-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)。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →