本文主要说明自托管媒体库(以 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 中转。
必须钉死的两条边界
- 可解码 payload + HMAC 的目标是防篡改与自然过期,不是对 payload 做保密。需要「完全隐藏内部路径 / ID」时,应升级为加密 token 或服务端 opaque ticket,那是后续增强,不是本方案成立前提。
- 图片端点不绑定「浏览器当前登录用户」,也不在取图数据面复查会话、用户
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
| 手段 | 作用 |
|---|---|
签名 exp 与 max-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,核心理由:
- 复杂度匹配场景:自部署媒体库,图片多来自公开元数据源,不需要完整 SaaS 图片资产中台。
- 可独立推进:不堵在 PublicId 全局迁移之后。
- 直接消灭 Blob 中转:海报墙体验与内存模型更干净。
- 行业模式成熟:短期签名 URL 是主流对象存储的标准做法。
- 可增量演进:若日后真要去重或精确 revision,可在 Signed URL 之上加层,而不必推倒重来。
关于内部 ID 暴露:本阶段目标是消除前端推导、Blob 中转与缓存失真,不把「隐藏全部内部 ID」设为硬门槛。Base64Url payload + HMAC 默认可解码、不可伪造;若合规或威胁模型升级,再换加密 token / opaque ticket。业务 API 上的 int 主键暴露是独立议题,不应阻塞图片方案。对自托管实例,枚举风险主要仍由 IAM 与业务 API 承担。
运维与产品侧可记住的几条
- 业务 API 无图字段或图字段为空:先查父资源读权限与生成期解析(文件是否存在、槽位是否有最终图),不要先怀疑 CDN。
- 图片 403:优先看 token 是否过期、时钟是否漂移、签名密钥是否轮换不一致。
- 图片 404:路径越界、文件已删、版本键与磁盘不一致,属于数据面校验失败。
- 换海报后仍见旧图:查版本键是否进入 token / URL,以及浏览器是否仍命中旧 URL 缓存。
- 刚收回库权限仍能看海报:在
exp窗口内符合当前正式语义;要更短窗口就调 TTL,而不是在数据面重新引入全量会话查询——除非明确接受其成本。 - 上 CDN 前先分级:公开可共享资源 vs 带签名 private 资源;默认
private时不要预期跨用户边缘命中。
小结
- URL 由后端签发,前端只消费;权限在业务 DTO 生成期收口。
- 图片数据面是验签 + 文件校验,不绑定实时登录态,也不为每张图打数据库。
- 缓存用 private + 与签名对齐的 max-age + 版本键,不用全局 authzVersion 砸全站。
- Signed URL 不堵 CDN 路,但当前共享缓存不是主收益;边缘策略要单独分级。
- 三层资产模型在精确失效与去重上更强,但对自托管媒体库过重,已明确放弃。
把鉴权、缓存、CDN 三条边界钉住之后,海报墙可以继续用浏览器最擅长的方式加载图片,同时把「谁能看见哪些片」留在业务 API 与 IAM 原则里,而不是摊到每一张 <img> 请求上。
相关阅读
同系列还可对照:
- 媒体库用户角色与 IAM 原则:全局角色、库级 R/W 与访问范围分层:库可读范围与「图片跟父资源」的原则层口径,本文的生成期授权与之衔接。
- Octans Docker 私有化部署:单镜像、Compose 叠加与健康检查:多用户实例落地时的运行形态;入口反代与 Web 静态资源同机部署时,图片仍走应用 API 而非裸静态目录。
来源
本文综合改写自 Octans 图片访问架构决策(Signed URL vs 三层资产模型)及后续 Signed URL 数据面去数据库收敛口径中的边界与选型表述。实施步骤、控制器字段名、密钥轮换 runbook 与前端组件细节已省略或抽象;以目标版本架构与 API 文档为准。