在社交应用中,文本发布通常只传几 KB,视频却可能达到几百 MB。如果所有文件都先进入业务 API,再由应用服务器转发对象存储,带宽、内存、连接数和超时会迅速成为瓶颈。于是很多团队改成浏览器直传对象存储,却又遇到新问题:客户端伪造“上传成功”、回调重复、视频转码长时间无结果、媒体失败后帖子卡在草稿、删除草稿留下大量孤儿对象。
媒体系统的本质不是一个 upload endpoint,而是一个跨浏览器、业务后端、对象存储、处理供应商、CDN 和帖子发布服务的状态机。本文给出一套供应商无关、可验证、可重试、可清理的参考设计;七牛云、Amazon S3、Cloudflare R2 或其他兼容对象存储都可以映射到相同边界。
适用范围与规模假设
本文面向支持多图、视频、异步图片处理或视频转码的内容产品。假设大文件应绕过业务服务器直传对象存储,帖子发布必须等待媒体可用。只有小头像或低频文档上传时,可以先由业务服务接收文件,但仍应保留资产状态、大小限制和可信完成确认。
一、设计目标
- 大文件不穿过业务服务器,降低带宽和连接压力;
- 客户端只能上传到被授权的 bucket、对象 key、类型和大小范围;
- “已上传”必须由可信存储事实确认,不能只信浏览器回报;
- 图片元数据提取、缩略图与视频转码异步执行;
- 帖子只有在全部绑定媒体满足发布条件后才进入 PUBLISHED;
- 回调重复、乱序、丢失和 worker 重启都能收敛;
- 失败可分类、可重试,草稿和孤儿对象可清理;
- 对外只暴露安全播放地址,不泄露内部 bucket 或管理凭证。
二、三个核心模型
1. MediaAsset:媒体业务聚合
type MediaAsset = {
id: string;
ownerUserId: string;
scene: "POST" | "AVATAR" | "COMMUNITY";
assetKind: "IMAGE" | "VIDEO";
status:
| "LOCAL"
| "UPLOADING"
| "UPLOADED"
| "PROCESSING"
| "READY"
| "FAILED"
| "DELETED";
bucket: string | null;
objectKey: string | null;
providerEtag: string | null;
mimeType: string | null;
sizeInBytes: bigint | null;
width: number | null;
height: number | null;
durationMs: number | null;
failureRevision: bigint;
lastErrorCode: string | null;
};
MediaAsset 是 owner truth。前端上传卡片、帖子发布门禁、重试接口和清理任务都围绕它,而不是围绕一个临时 URL。
2. UploadSession:一次有限授权
type UploadSession = {
sessionId: string;
mediaAssetId: string;
revision: bigint;
objectKey: string;
expectedContentType: string;
maxSizeInBytes: bigint;
expiresAt: Date;
status: "ISSUED" | "CONSUMED" | "EXPIRED" | "REVOKED";
};
重新尝试上传时递增 revision,旧 token 与旧回调不能推进新会话。
3. PostMediaBinding:帖子与媒体的所有权边界
type PostMediaBinding = {
postId: string;
mediaAssetId: string;
position: number;
altText: string | null;
};
绑定关系位于内容 owner 中或由明确端口维护,要求 asset 属于发帖人、scene 为 POST、未被其他不可共享场景占用。展示顺序不能依赖对象上传完成顺序。
三、总体架构

链路角色如下:
- 浏览器:选择文件、做前置校验、申请上传会话、直传、展示进度;
- Media API:创建资产、签发最小权限 token、确认与查询状态、处理重试;
- 对象存储:保存原始对象并产生可信回调或可查询元数据;
- Kafka/Outbox:传播上传完成、处理提交、处理成功/失败事件;
- Image/Video Worker:提交图片处理或视频转码任务;
- Provider Callback API:验签、抗重放、校验 job 与 asset;
- PostgreSQL:媒体状态、会话、处理任务和 outbox 权威事实;
- MongoDB:处理审计或面向卡片的媒体投影;
- Post Publish Gate:等待全部绑定媒体 READY 后继续正式发布;
- CDN:向客户端提供派生图、封面和播放地址。
四、浏览器直传的安全合同
1. 上传初始化
POST /api/media/uploads
Content-Type: application/json
{
"assetKind": "VIDEO",
"scene": "POST",
"contentType": "video/mp4",
"sizeInBytes": 73400320,
"fileName": "demo.mp4"
}
服务端校验当前账号配额、scene 允许的 kind、MIME allowlist 和声明大小,生成不可预测 assetId 与受控 objectKey:
tmp/{ownerUserId}/{assetId}/upload-r{revision}
不要使用原始文件名作为 key,也不要让客户端任意指定 bucket 或前缀。上传 token 应绑定 key、过期时间、大小限制、允许 MIME 和回调信息。
响应:
{
"mediaAssetId": "ma_01J...",
"upload": {
"provider": "QINIU",
"token": "short-lived-token",
"objectKey": "tmp/...",
"expiresAt": "2026-08-07T10:10:00Z"
}
}
2. 客户端进度不是权威完成
浏览器直传拿到 200 只能用于立即更新 UI 为“正在确认”。业务后端必须通过经过验签的存储回调,或主动查询对象元数据,确认:
- bucket/key 与当前 UploadSession 一致;
- provider etag/hash 有效;
- 实际大小不超限;
- MIME 与魔数/探测结果匹配;
- session revision 仍是当前版本。
确认后状态从 UPLOADING → UPLOADED,并在事务内生成处理命令。
五、回调安全:验签、抗重放与幂等
供应商回调是公开 HTTP 入口,必须按原始请求体和供应商规范验签。中间件需要在 JSON 解析前保留 raw body,校验时间戳、签名、请求路径和内容类型。
每次回调提取稳定幂等键:
provider + jobId + callbackType + resultRevision
Redis 可用 nonce 短期抗重放,PostgreSQL 唯一约束提供最终幂等。处理步骤:
- 校验签名与时间窗口;
- 校验 payload schema,不接受多余或类型错误字段;
- 查找 MediaAsset/processing job;
- 核对 provider jobId、asset kind、object key 和当前 revision;
- 若状态已被同一结果推进,返回幂等成功;
- 在事务中更新状态、派生文件信息并写 outbox;
- 提交后刷新媒体投影和帖子发布等待状态。
不能因为 callback URL 难以猜测就省略验签,也不能把供应商原始错误全文写入通知或日志,其中可能包含内部路径和凭证片段。
六、图片与视频是两条不同处理链
图片链
图片通常需要:
- 解码与格式验证;
- 读取宽高、方向和色彩信息;
- 清除危险或不必要的 EXIF;
- 生成缩略图、常规图和高分辨率图;
- 必要时转 WebP/AVIF,同时保留兼容格式;
- 内容安全扫描与失败分类。
视频链
视频通常需要:
- 探测容器、编码、时长、分辨率、帧率;
- 生成封面;
- 转成支持的编码与多档清晰度;
- 生成 HLS/DASH 清单或标准 MP4;
- 校验音视频流、时长与输出对象完整性;
- 内容安全与版权策略(若产品需要)。
两个 kind 的错误码不能混用。IMAGE asset 携带 VIDEO_TRANSCODE_FAILED 表明聚合已损坏,应拒绝自动重试并告警,而不是盲目提交另一条处理链。
七、媒体状态机与允许的转换
LOCAL
└─ issue upload → UPLOADING
├─ trusted object confirmed → UPLOADED
│ └─ submit process → PROCESSING
│ ├─ callback success → READY
│ └─ callback failure → FAILED
├─ session expired → FAILED / LOCAL(new revision)
└─ owner delete → DELETED
FAILED
└─ retry policy allowed → UPLOADED or PROCESSING(new failure revision)
每个转换都要在服务层显式列出,不能让任意 update 把 READY 改回 UPLOADING。状态推进最好带 compare-and-set:
UPDATE media_assets
SET status = 'PROCESSING', process_revision = process_revision + 1
WHERE id = :id AND status = 'UPLOADED';
受影响行数为 0 时,再读取当前状态判断是幂等成功、竞态还是非法转换。

八、帖子发布门禁:等待,而不是制造半成品
用户可以在媒体处理期间继续编辑草稿,但点击发布时必须得到明确语义:
- 全部绑定资产
READY:继续正式发布; - 任一资产
FAILED:拒绝发布并指出可重试项; - 资产仍在
UPLOADING/UPLOADED/PROCESSING:进入WAITING_MEDIA; - 资产已删除、所有者不一致或 kind 不合法:拒绝并要求移除。
Waiting Publish 记录发布意图、草稿版本和媒体集合。媒体状态事件到达时,PostMediaStatusWorker 检查所有绑定资产;全部 READY 后重新执行权限和关系最终门禁,再自动继续发布。
为什么要重新门禁?等待期间帖子引用来源可能删除,社区权限可能变化,草稿也可能被用户修改。不能把几分钟前的预检当作当前事实。
防止重复自动发布
发布继续动作使用 lifecycle lock 和幂等键,条件更新草稿状态。多个媒体几乎同时完成会产生多个触发事件,但只有一个事务能从 WAITING 进入 PUBLISHED,其余返回幂等结果。
九、部分失败与重试策略
一个帖子绑定 4 张图,其中 3 张 READY、1 张 FAILED。系统不应自动发布前三张,除非产品明确允许“失败媒体自动剔除”。更安全的默认是保持草稿,允许用户:
- 重试失败资产;
- 删除失败资产后再次发布;
- 替换文件,生成新 UploadSession revision;
- 取消整篇草稿。
失败码分为:
| 类别 | 示例 | 是否可重试 |
|---|---|---|
| 临时基础设施 | provider timeout、rate limit | 自动退避重试 |
| 源对象暂不可见 | eventual consistency、head timeout | 短期轮询/重试 |
| 文件不合法 | MIME 欺骗、解码失败、超时长 | 不自动重试,要求换文件 |
| 策略拒绝 | 大小超限、内容安全拒绝 | 不重试 |
| 合同损坏 | jobId 不匹配、跨 kind 错误码 | 隔离并告警 |
Retry API 读取当前 asset、failureRevision、kind 和错误分类,在事务中创建新处理 revision。重复点击返回同一正在进行的 revision,而不是提交多个转码任务。
十、回调丢失与修复扫描
不能假设供应商回调永不丢失。需要两类 repair worker:
- Submit Repair:资产已 UPLOADED,但长时间没有 processing job,重新提交或确认提交结果;
- Result Scan:资产 PROCESSING 超过阈值,向供应商查询 job 状态,补写成功/失败结果。
扫描采用租约、小批次 keyset 分页和限流。它们必须调用与实时回调相同的状态推进函数,避免两套逻辑产生不同派生字段。
处理超时阈值要按图片、短视频、长视频区分,并观察供应商实际 P99。过早判失败会与晚到成功回调竞争,过晚则用户长时间卡住。
十一、对象生命周期与孤儿清理
对象存储通常包含:原始临时对象、处理输出和最终发布对象。推荐分前缀并维护引用:
tmp/{owner}/{asset}/... 上传中/未发布
processed/{asset}/{revision}/... 处理输出
public/{asset}/... 可公开交付(可选复制策略)
清理条件:
- UploadSession 过期且对象未被可信确认;
- 草稿删除后宽限期已过,asset 没有其他有效绑定;
- 新 revision 成功后,旧失败输出超出保留期;
- Post 删除后依据产品和合规策略撤销公开引用;
- 数据库无记录但 bucket 有对象的孤儿扫描。
删除采用 mark-and-sweep:先在 owner 标记待清理和时间,异步删除对象,成功后完成状态。对象存储删除失败可重试,不应阻塞用户删除草稿。清理工具必须验证 key 前缀与 asset 所有权,不能对计算出的宽泛路径执行递归删除。
十二、CDN、播放地址与权限
公开帖子可使用 CDN 公共地址,但仍要支持源内容删除后的缓存失效。私密内容使用短期签名 URL,签发前执行当前 viewer 权限。
卡片投影不应长期固化即将过期的签名 URL。它保存 assetId 和公开派生 key,Media Gateway 在读取时生成适当 URL,或返回较短 TTL 并允许刷新。
视频播放资格还应检查 asset READY、输出存在、内容未被封禁和当前帖子可见。前端不要直接使用用户上传的临时 objectKey。
十三、前端上传体验
每个媒体卡片要呈现明确状态:
本地预览 → 上传中 42% → 服务端确认中 → 处理中 → 可发布
└→ 失败(原因 + 重试/移除)
“部分媒体上传失败”提示应指出哪几个资产失败,草稿仍引用哪些 READY 项。刷新页面后,前端通过 MediaAsset 状态恢复,而不是依赖内存中的上传进度。
取消上传要区分:仅停止浏览器请求、撤销当前 session、还是删除资产。断点续传需要 provider 支持和 session 级 uploadId,不应与业务 assetId 混为一谈。
十四、接口建议
POST /api/media/uploads 创建资产与上传会话
GET /api/media/:mediaAssetId 查询权威状态
POST /api/media/:mediaAssetId/retry 重试允许的处理失败
DELETE /api/media/:mediaAssetId 标记删除
POST /internal/media/qiniu/image-callback
POST /internal/media/qiniu/video-callback
POST /api/posts/:postId/publish 进入发布门禁
内部 callback 路由和公开 API 分开配置限流、鉴权与日志脱敏。客户端不能调用内部处理成功接口。
十五、可观测性
媒体链路需要按 assetId 和 revision 贯穿 trace。关键指标:
- 上传会话签发数、过期率、实际上传成功率和声明/实际大小不符;
- 从 session issued 到 object confirmed 的 P95/P99;
- 处理提交延迟、provider job 接受率;
- 图片和各档视频处理耗时分布;
- callback 验签失败、重放、job mismatch 和重复幂等;
- 各失败码、自动重试成功率、死信;
- WAITING_MEDIA 帖子数量和最老等待时间;
- repair scan 发现并修复数量;
- 孤儿对象数、清理延迟、对象存储费用趋势;
- CDN 4xx/5xx、签名 URL 刷新和播放启动时间。
一次“上传失败”应能回答:浏览器是否传完?对象是否存在?回调是否到达并验签?处理 job 是否提交?供应商状态是什么?回调是否推进 owner?帖子等待记录是否被唤醒?如果只能看到前端 toast,就无法运营媒体系统。
十六、测试矩阵
- token 仅允许指定 key、大小、MIME 和过期时间;
- 浏览器声称成功但对象不存在时不能进入 UPLOADED;
- 回调签名错误、超时、重复、乱序、错误 jobId、跨 kind payload;
- IMAGE/VIDEO 各状态合法与非法转换;
- 多媒体全部 READY 自动发布,任一 FAILED 保持草稿;
- 多个 READY 事件并发只发布一次;
- 等待期间权限变化触发最终门禁失败;
- callback 丢失由 result scan 修复;
- retry 重复点击幂等,永久失败不可重试;
- 草稿删除后的宽限、引用检查和孤儿清理;
- 私密媒体签名 URL 过期、刷新与越权拒绝。
十七、方案权衡
浏览器直传显著降低应用服务器压力,却把安全边界转移到 token、回调和对象确认;必须投入更严格的合同。同步处理实现简单,但视频会让请求长时间占用且不可恢复;异步状态机增加 worker 与投影,却提供可重试和可观测性。
“媒体就绪后自动继续发布”体验顺畅,但必须冻结或版本化发布意图,并重新执行最终门禁。若团队暂时无法维护等待发布状态,可以让用户手动再次点击发布,但不能把尚未 READY 的媒体静默丢掉。
结语
一套成熟媒体系统应允许任何单个环节失败:浏览器断网、回调重复、转码超时、worker 重启、Redis 丢失、CDN 延迟——最终仍能从 MediaAsset、UploadSession、Processing Job 和 Post Waiting 状态恢复。
核心原则可以浓缩为四句:客户端进度不是事实;回调必须验签且幂等;媒体 READY 是发布门禁;所有异步步骤都要有扫描修复。当这些不变量被落实,图片和视频才不再是“帖子上的附件”,而成为可独立治理、可重试、可审计的业务资产。