从浏览器直传到正式发布:社交平台媒体处理系统设计

在社交应用中,文本发布通常只传几 KB,视频却可能达到几百 MB。如果所有文件都先进入业务 API,再由应用服务器转发对象存储,带宽、内存、连接数和超时会迅速成为瓶颈。于是很多团队改成浏览器直传对象存储,却又遇到新问题:客户端伪造“上传成功”、回调重复、视频转码长时间无结果、媒体失败后帖子卡在草稿、删除草稿留下大量孤儿对象。

媒体系统的本质不是一个 upload endpoint,而是一个跨浏览器、业务后端、对象存储、处理供应商、CDN 和帖子发布服务的状态机。本文给出一套供应商无关、可验证、可重试、可清理的参考设计;七牛云、Amazon S3、Cloudflare R2 或其他兼容对象存储都可以映射到相同边界。

适用范围与规模假设

本文面向支持多图、视频、异步图片处理或视频转码的内容产品。假设大文件应绕过业务服务器直传对象存储,帖子发布必须等待媒体可用。只有小头像或低频文档上传时,可以先由业务服务接收文件,但仍应保留资产状态、大小限制和可信完成确认。

一、设计目标

  1. 大文件不穿过业务服务器,降低带宽和连接压力;
  2. 客户端只能上传到被授权的 bucket、对象 key、类型和大小范围;
  3. “已上传”必须由可信存储事实确认,不能只信浏览器回报;
  4. 图片元数据提取、缩略图与视频转码异步执行;
  5. 帖子只有在全部绑定媒体满足发布条件后才进入 PUBLISHED;
  6. 回调重复、乱序、丢失和 worker 重启都能收敛;
  7. 失败可分类、可重试,草稿和孤儿对象可清理;
  8. 对外只暴露安全播放地址,不泄露内部 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 唯一约束提供最终幂等。处理步骤:

  1. 校验签名与时间窗口;
  2. 校验 payload schema,不接受多余或类型错误字段;
  3. 查找 MediaAsset/processing job;
  4. 核对 provider jobId、asset kind、object key 和当前 revision;
  5. 若状态已被同一结果推进,返回幂等成功;
  6. 在事务中更新状态、派生文件信息并写 outbox;
  7. 提交后刷新媒体投影和帖子发布等待状态。

不能因为 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:

  1. Submit Repair:资产已 UPLOADED,但长时间没有 processing job,重新提交或确认提交结果;
  2. 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 是发布门禁;所有异步步骤都要有扫描修复。当这些不变量被落实,图片和视频才不再是“帖子上的附件”,而成为可独立治理、可重试、可审计的业务资产。

发表评论