本文主要说明自托管媒体库(以 Octans 为例)在媒体图片访问上的架构边界:谁推导 URL、谁做鉴权、HTTP 缓存与权限变更如何共处,以及 Signed URL 与「三层资产模型」相比为何更匹配家用 / 小团队私有化场景。重点是 CDN / 鉴权 / 缓存 的分层口径,而不是接口字段或迁移 runbook。

海报墙、详情页背景、分集剧照、人物头像,表面是「几张图」,实际会同时踩到三件事:

边界要回答的问题
鉴权谁有权看见这张图?权限收回后旧 URL 还能用多久?
缓存浏览器能不能原生缓存?内容变了会不会脏读?换账号会不会串缓存?
CDN / 边缘能不能上共享边缘缓存?Signed URL 会不会堵死这条路?

把这三件事揉进前端路径拼接或长 max-age 的受保护直链,往往会同时坏体验与坏边界。下面按「问题 → 弃用方案 → 采用方案 → 分层结论」收敛一版可复用口径。

重构前常见的架构级问题

以下描述的是方案评估时的历史形态,用来说明选型动机,不代表目标运行时仍如此实现。

1. 前端持有图片 URL 推导权

若客户端根据资源类型、父资源本地 ID 和固定路由模板自行拼出海报 / 背景 / 剧照 / 头像地址,就意味着:

  • 权限边界无法只在后端收口;
  • 路由或 ID 策略一变,所有客户端都要跟着改;
  • 枚举与猜测路径的成本落在「知道拼法」上,而不是「业务 API 是否返回了 URL」。

原则:前端只消费后端返回的完整图片 URL,不持有业务图片路由的推导权。

2. 浏览器 HTTP 缓存与权限变更冲突

受保护图片若返回较长 Cache-Control: private, max-age=…,权限收回后,旧响应仍可能在有效期内被浏览器复用。private 只约束共享代理缓存,阻止同一设备、同一浏览器 profile 下不同账号命中同一 URL。

因此「能缓存」和「权限一变立刻不可见」不能指望单靠 private 同时满足。

3. URL 缺少版本身份

若 URL 仅由「父资源 int ID + 图片类型 + width」构成:

  • 删库重建后本地 ID 复用 → 旧缓存可能错误命中;
  • 刮削更新海报后 URL 不变 → 浏览器继续用旧字节。

缓存正确性需要内容或版本键进入缓存身份,而不是只靠父资源主键。

4. 图片加载依赖 JS Blob 中转

Bearer Token 无法挂在原生 <img src> 上时,前端往往用 axios + responseType: 'blob' 拉图,再转 ObjectURL 渲染。代价包括:

  • ObjectURL 必须手动 revoke,泄漏即内存上涨;
  • 海报墙高并发时 JS 路径成为吞吐与主线程负担;
  • 浏览器原生图片缓存与解码管线用不满。

目标体验应是:业务 DTO 给出可直接喂给 <img> / CSS background-image 的 URL。

方案 A:三层资产模型(已放弃)

曾评估过一套「SaaS 味」更重的模型:

含义
ImageAsset图片字节本身,按内容哈希去重
ImageSlot父资源 × 图片类型的正式绑定
ImageSlotRevision槽位的不可变版本,作为对外缓存身份

对外 URL 形如「父资源 PublicId + 图片类型 + revisionPublicId」。前端消费图片描述对象;HTTP 侧倾向 Cache-Control: private, no-store,另用 IndexedDB / Cache Storage 做应用级缓存,并用全局 authzVersion 在权限变化时整批失效。

看起来很强的点

  • 版本身份精确到 revision,缓存可精确失效;
  • 底层字节可跨父资源去重;
  • authzVersion 与权限变更联动;
  • PublicId 可隐藏内部主键。

为何不适合当前场景

复杂度与收益不匹配。 三张新表 + 实体 / Service / 迁移,换来的跨父资源去重在媒体库里频率极低;版本问题一个内容哈希或版本键往往就够。

应用级缓存等于重建 HTTP 缓存。 no-store 放弃浏览器原生能力后,前端要自己做读写、淘汰与容量管理,且仍可能离不开 Blob 中转,<img src> 依旧不好用。

全局 authzVersion 代价过高。 任一库权限变动导致全站图片缓存失效;管理员频繁调权时命中率断崖。细到 per-library 又显著抬高缓存键复杂度。

强依赖 PublicId 全局迁移。 图片优化被堵在「所有实体 / 路由 / 前端引用换身份」之后,无法独立推进。

威胁模型过度设计。 海报与剧照大多来自 TMDB / TVDB 等公开元数据;真正敏感的是「这个用户的库里有哪些片」,而不是海报像素本身。为公开图片建一套 SaaS 级资产体系,安全收益有限。

方案 B:Signed URL(采用并收敛)

核心形态

业务 API 在返回 DTO 时,为每个图片槽位生成带 HMAC 签名 + 过期时间 的 URL,形态上类似:

/api/v1/images/{token}?w=300

token 是短期 bearer:携带图片类型、相对路径、槽位、图片版本键与过期时间等文件数据面字段。前端直接:

<img :src="dto.posterUrl" />

不再由前端拼路径,也不再为鉴权走 Blob 中转。

必须钉死的两条边界

  1. 可解码 payload + HMAC 的目标是防篡改与自然过期,不是对 payload 做保密。需要「完全隐藏内部路径 / ID」时,应升级为加密 token 或服务端 opaque ticket,那是后续增强,不是本方案成立前提。
  2. 图片端点不绑定「浏览器当前登录用户」,也不在取图数据面复查会话、用户 AuthVersion 或父资源实时权限。父资源读权限发生在业务 API 生成 posterUrl / backdropUrl / profileUrl / stillUrl 时;签发之后,token 在有效期内按短期 bearer URL 处理。

读取链路(概念)

前端页面
  → 业务 API(ResourceScope / QueryScope:父资源是否可读)
  → 生成期解析最终图路径 / 类型 / 版本键
  → ImageUrlSigner 签发 token,写入 DTO 图片字段
  → 前端原生 <img src>
  → GET /api/v1/images/{token}
  → 验签 + 过期 + 路径安全边界 + 文件存在性 + 版本键 + 宽度桶
  → 200 图片字节(Cache-Control: private, max-age 与签名有效期对齐)
     或 403 / 404

要点:鉴权在控制面(业务 API),取图在数据面(验签后读文件)。媒体墙高并发加载时,不应再为每张图查询会话表、用户表和父资源权限,避免把数据库锁竞争带进图片热路径。

收敛后的正式口径

条款口径
URL 性质短期 bearer URL,不是「天然绑定当前登录态」的图片认证通道
授权时机仅生成期;GET …/images/{token} 不查库、不复查会话 / AuthVersion / 父资源 ACL
payload只保留文件数据面必要字段(类型、相对路径、槽位、imageVersionKey、exp 等);不携带 uid / sid / 授权版本
宽度按 poster / backdrop / still / profile 等槽位固定尺寸桶归一化与 clamp,禁止任意 width 直接进入缓存键
职责拆分URL 构造与生成期目标解析在业务侧统一;图片 Controller 只消费 token 内数据面字段

缓存响应侧常见基线是 Cache-Control: private, max-age=43200(与签名有效期同量级,具体数值以实现为准)。浏览器在有效期内可复用;过期后需重新拉业务数据拿新 URL,生成期再次做权限判断。

鉴权 / 缓存 / CDN 三层怎么切

这是本文要合成的主结论。

鉴权层:生成期一次,数据面不二次登录

动作发生在哪里不发生在哪里
父资源是否可读业务列表 / 详情 API图片字节端点
会话是否有效、用户是否禁用登录与业务 API图片数据面(当前正式方案)
防篡改、防过期滥用token HMAC + exp依赖「只有登录用户才能拼 URL」

接受的产品语义:权限收回、会话吊销、库 ACL 变更之后,已签发 URL 最长可继续访问到 exp(例如 12 小时量级)。页面刷新或重新拉业务数据会拿到新签发结果(无权限则不再返回可用 URL)。若业务强制「秒级撤权」,Signed URL 单独不够,需要更短 TTL、加密 ticket 吊销表或回到强会话绑定数据面——那会重新引入热路径成本。

缓存层:浏览器私有缓存 + 版本键,而不是全局 authzVersion

手段作用
签名 expmax-age 对齐缓存自然过期,避免「权限早没了、图还能看很久」无限期存在
imageVersionKey / 内容哈希进入 token海报被刮削更新后 URL 变,旧缓存不会永久脏读
Cache-Control: private默认不鼓励共享代理把用户可见图当成公开静态资源
宽度桶同一逻辑图在有限尺寸集合内复用缓存,避免 width 爆炸

刻意做的事:用全局 authzVersion 一刀切失效全站图片缓存;在前端自建 IndexedDB 图片缓存体系来替代 HTTP 缓存。

CDN / 边缘层:路径清晰,共享收益当前有限

Signed URL 是对象存储与边缘常见的受保护资源模式(S3 / GCS / Azure Blob / R2 等同类思路),不阻塞将来把验签与源站回源放到 CDN 或边缘节点。但在当前默认假设下:

  • 若签名载荷含用户或会话差异,或响应仍是 private共享 CDN 缓存命中率有限
  • 海报本身多来自公开元数据,边缘共享的安全模型与「库可见性」并不一致——真正要保护的是「谁有哪些片」的元数据面,而不是把公开海报当机密;
  • 家用单实例 / 小团队部署里,瓶颈更常在业务 API 与源站 I/O,而不是缺一层全球边缘。

因此正式表述是:接入路径清晰,共享缓存不是本阶段主收益。若未来要做跨用户可共享的公开海报 CDN,需要单独设计「哪些图可匿名共享、哪些必须带签名且 private」的分级策略,而不是默认全站上共享缓存。

能力对比(选型摘要)

维度三层资产模型Signed URL(收敛版)
前端 URL 推导权消除消除
缓存与权限一致性authzVersion 精确失效签名过期 + 图片版本键
浏览器原生加载弱(易仍需 Blob / 应用缓存)强(<img src> 直接可用)
实现复杂度高(多表 + 前端缓存层 + 全局版本)低(签名服务 + 验签端点)
数据库迁移多张新表无新表(可选旁路 contentHash)
对 PublicId 的依赖
缓存效率低(no-store + JS 管理)高(原生 HTTP 缓存,有效期内复用)
权限收回响应可即时最长到签名过期
图片去重支持不支持(当前不需要)
CDN需额外设计可演进;当前共享收益有限
图片热路径打库视实现正式要求数据面不打库

决策与演进空间

选择收敛后的 Signed URL,核心理由:

  1. 复杂度匹配场景:自部署媒体库,图片多来自公开元数据源,不需要完整 SaaS 图片资产中台。
  2. 可独立推进:不堵在 PublicId 全局迁移之后。
  3. 直接消灭 Blob 中转:海报墙体验与内存模型更干净。
  4. 行业模式成熟:短期签名 URL 是主流对象存储的标准做法。
  5. 可增量演进:若日后真要去重或精确 revision,可在 Signed URL 之上加层,而不必推倒重来。

关于内部 ID 暴露:本阶段目标是消除前端推导、Blob 中转与缓存失真,把「隐藏全部内部 ID」设为硬门槛。Base64Url payload + HMAC 默认可解码、不可伪造;若合规或威胁模型升级,再换加密 token / opaque ticket。业务 API 上的 int 主键暴露是独立议题,不应阻塞图片方案。对自托管实例,枚举风险主要仍由 IAM 与业务 API 承担。

运维与产品侧可记住的几条

  1. 业务 API 无图字段或图字段为空:先查父资源读权限与生成期解析(文件是否存在、槽位是否有最终图),不要先怀疑 CDN。
  2. 图片 403:优先看 token 是否过期、时钟是否漂移、签名密钥是否轮换不一致。
  3. 图片 404:路径越界、文件已删、版本键与磁盘不一致,属于数据面校验失败。
  4. 换海报后仍见旧图:查版本键是否进入 token / URL,以及浏览器是否仍命中旧 URL 缓存。
  5. 刚收回库权限仍能看海报:在 exp 窗口内符合当前正式语义;要更短窗口就调 TTL,而不是在数据面重新引入全量会话查询——除非明确接受其成本。
  6. 上 CDN 前先分级:公开可共享资源 vs 带签名 private 资源;默认 private 时不要预期跨用户边缘命中。

小结

  1. URL 由后端签发,前端只消费;权限在业务 DTO 生成期收口。
  2. 图片数据面是验签 + 文件校验,不绑定实时登录态,也不为每张图打数据库。
  3. 缓存用 private + 与签名对齐的 max-age + 版本键,不用全局 authzVersion 砸全站。
  4. Signed URL 不堵 CDN 路,但当前共享缓存不是主收益;边缘策略要单独分级。
  5. 三层资产模型在精确失效与去重上更强,但对自托管媒体库过重,已明确放弃。

把鉴权、缓存、CDN 三条边界钉住之后,海报墙可以继续用浏览器最擅长的方式加载图片,同时把「谁能看见哪些片」留在业务 API 与 IAM 原则里,而不是摊到每一张 <img> 请求上。

相关阅读

同系列还可对照:

来源

本文综合改写自 Octans 图片访问架构决策(Signed URL vs 三层资产模型)及后续 Signed URL 数据面去数据库收敛口径中的边界与选型表述。实施步骤、控制器字段名、密钥轮换 runbook 与前端组件细节已省略或抽象;以目标版本架构与 API 文档为准。