上传接口只存了一个 URL
「回声」最早的上传接口只有三个字段:作者 ID、标题、文件 URL。前端把图片裁好、视频转好之后上传到对象存储,服务端把 URL 写进内容表就算成功。这个接口在纯文字阶段没有问题,因为文字本身就是正文;当产品加上图片和视频,它立刻暴露出一个被忽略的事实:服务端拿到的不是一个“内容”,而是一个指向文件的地址。
地址能打开文件,却回答不了下游最常问的几个问题:这张图多大、什么比例、有没有旋转信息;这段视频多长、有没有音轨、封面取哪一帧;正文有多少字、是什么语言。审核要用这些信息判断风险,检索要用它们建索引,转码要用它们选模板,计费要用它们算存储和带宽。URL 只是入口,元数据才是让这些流水线运转的输入。
这篇文章不讨论对象存储怎么选,而是把“上传时应该收什么、拒绝什么”写成一段可测试的 Go 代码。判断标准很简单:如果一条信息在下游会被反复读取、并且缺失时只能靠猜,它就应该在准入时被校验,而不是等到转码队列排队时才由某个消费者发现。
元数据决定下游哪几条流水线
把一条内容拆开看,元数据不是附属说明,而是四条流水线共同的前置条件。下表列的“缺失后果”都是案例假设,用来展示问题会在哪里暴露,不是某次线上事故的统计。
| 字段 | 典型来源 | 主要消费者 | 缺失后果 |
|---|---|---|---|
| 标题 | 作者填写 | 审核、搜索、列表、分享卡片 | 列表只能显示“无标题”,搜索没有主键 |
| 正文长度 | 服务端按字符统计 | 审核抽样、摘要、分页、计费 | 长文被当成短文,摘要和审核队列失真 |
| 图片宽高 | 上传后解析或客户端上报 | 裁剪、响应式图片、审核 | 前端布局抖动,审核无法判断比例异常 |
| 图片字节数 | 对象存储返回值 | 配额、CDN 成本、限流 | 单用户可无限占用带宽和存储 |
| 视频时长 | 转码前探测 | 转码模板、审核、播放器 | 4K 长视频进入默认模板,队列被堵 |
| 视频字节数 | 对象存储返回值 | 上传限额、成本分摊 | 大文件绕过分片策略,上传超时 |
表格里最容易被漏掉的是“消费者”这一列。团队常常按“前端需要什么”来设计字段,结果审核后台、转码服务和计费任务各自再补一遍解析逻辑。同一份文件被解析三次,口径还可能不一致:客户端报的宽高是裁剪后的,审核解析的是原图,转码拿到的是压缩后的版本。元数据一旦有多个来源,就必须明确哪一份是权威值。
另一个判断是“必填”与“可选”要按类型区分。标题对任何内容都必填;宽高只对图片有意义,视频可以有也可以没有;时长只对视频有意义。把所有字段都设成可选,等于把校验责任推给下游;把所有字段都设成必填,又会让纯文本投稿无法提交。类型决定字段,而不是反过来。
为什么文本也要限制长度
纯文本看起来最安全,实际上它决定了审核队列的成本。假设一条内容平均 2000 字,审核员每小时能看 200 条;如果允许单条 20 万字,同样的人力只能看几条,队列会迅速积压。标题限制在 80 个字符以内,也是同一个道理:标题会进入列表、推送和分享卡片,过长会被截断,过短又无法表达主题。
这里用的是字符数而不是字节数。一个中文字符在 UTF-8 里占 3 个字节,按字节限制 80 会让中文标题只能写 26 个字,英文标题却能写 80 个字母。用户感知的是字符,校验也应该按字符。
一次 4K 视频转码排队事故
案例假设:「回声」上线视频功能的第一周,上传接口仍然只存 URL。审核后台拿不到时长,只能先把视频放进“待人工查看”队列;转码服务则按默认的 720p 模板排队。某天一位作者上传了一段 4K、40 分钟的视频,文件大约 20 GB。转码任务读到文件头才发现分辨率远超模板,临时申请高规格机器,排队时间从几分钟变成几个小时。
更糟的是,这条任务占住了队列里的一个工作槽。后面几百条普通视频跟着等待,用户看到“处理中”一直不结束,反复重新上传,队列进一步变长。团队最初以为是转码机器不够,加了机器之后才发现,真正的问题是上传时没有拒绝或标记这类文件:系统不知道自己要处理什么,只能等到处理时才发现。
事故的后果有三层。第一层是用户体验:视频长时间不可见,作者以为上传失败。第二层是成本:4K 转码的 CPU 和带宽远高于 720p,账单在月底才体现。第三层是数据:审核人员没有时长和封面,无法按优先级抽样,高风险长视频反而被排在后面。三层后果都指向同一个缺失:上传时的元数据校验。
修复并不复杂:上传接口在写入内容表之前调用一次校验,图片必须有宽高和字节数,视频必须有时长和字节数,文本不能携带宽高;不满足条件的请求直接返回 4xx,并告诉客户端缺哪个字段。转码服务仍然要探测真实文件,但探测从“发现意外”变成“确认已知”,队列可以按已知规格分配资源。
用 Validate 把准入条件写成纯函数
校验逻辑如果散落在 handler、审核后台和转码服务里,三处迟早会漂移。把它写成一个纯函数,输入是已经解析好的元数据,输出是第一个错误,至少能保证三件事:同一份规则被所有入口复用、错误信息能定位到具体媒体、测试不需要启动 HTTP 服务。
下面这段代码来自本书配套的 webapp-lab。Validate 先检查标题,再检查正文与媒体是否同时为空,最后逐个检查媒体。标题用 utf8.RuneCountInString 计数,媒体按 Kind 分支,未知类型直接报错。
// file: internal/content/metadata.go
// tier: L1
// verify: go test ./internal/content/
package content
import (
"fmt"
"strings"
"unicode/utf8"
)
// MediaKind 是内容里附件的类型。
type MediaKind string
const (
MediaText MediaKind = "text"
MediaImage MediaKind = "image"
MediaVideo MediaKind = "video"
)
// Media 描述一个附件的客观属性。Bytes 是原始文件大小,
// Width/Height 只对图片有意义,DurationMS 只对视频有意义。
type Media struct {
Kind MediaKind
Bytes int64
Width int
Height int
DurationMS int
}
// Meta 是投稿时提交的元数据。Body 与 Media 至少有一个非空。
type Meta struct {
Title string
Body string
Media []Media
}
// Validate 检查元数据是否足以进入审核、转码与计费流程。
// 标题按字符数而不是字节数限制,避免中文标题被提前截断。
func Validate(m Meta) error {
title := strings.TrimSpace(m.Title)
if title == "" {
return fmt.Errorf("content: title is empty")
}
if n := utf8.RuneCountInString(title); n > 80 {
return fmt.Errorf("content: title has %d runes, limit is 80", n)
}
if strings.TrimSpace(m.Body) == "" && len(m.Media) == 0 {
return fmt.Errorf("content: body and media are both empty")
}
for i, media := range m.Media {
if err := validateMedia(media); err != nil {
return fmt.Errorf("content: media[%d]: %w", i, err)
}
}
return nil
}
// validateMedia 把“类型决定了哪些字段必须存在”写成显式分支。
// 未知类型直接报错,避免新格式被静默当成普通附件放行。
func validateMedia(m Media) error {
if m.Bytes < 0 || m.Width < 0 || m.Height < 0 || m.DurationMS < 0 {
return fmt.Errorf("content: negative media field")
}
switch m.Kind {
case MediaText:
if m.Width != 0 || m.Height != 0 {
return fmt.Errorf("content: text must not carry width/height")
}
if m.DurationMS != 0 {
return fmt.Errorf("content: text must not carry duration")
}
case MediaImage:
if m.Bytes == 0 {
return fmt.Errorf("content: image bytes must be positive")
}
if m.Width == 0 || m.Height == 0 {
return fmt.Errorf("content: image needs width and height")
}
if m.DurationMS != 0 {
return fmt.Errorf("content: image must not carry duration")
}
case MediaVideo:
if m.Bytes == 0 {
return fmt.Errorf("content: video bytes must be positive")
}
if m.DurationMS == 0 {
return fmt.Errorf("content: video needs duration")
}
default:
return fmt.Errorf("content: unknown media kind %q", m.Kind)
}
return nil
}
几个边界值得单独说明。
- 标题先
TrimSpace再判空,避免全空格标题绕过校验。长度也按去掉首尾空格后的字符数计算。 Body与Media至少有一个非空。纯图片内容合法,纯文本内容也合法,两者都空才报错;这比“正文必填”更符合真实产品。- 图片要求字节数、宽、高都大于 0。宽高为 0 通常意味着解析失败或客户端没上报,放行只会把问题推给布局和审核。
- 视频要求字节数和时长。时长是转码模板和审核排期的输入,缺失时无法估计成本。
- 文本不允许携带宽高和时长。字段一旦允许“顺便填”,上游就会开始填错,最后没人知道哪个值可信。
- 未知类型直接报错,而不是当成文本放行。新增音频、直播回放等类型时,必须先改校验,再改下游。
这段代码不负责解析文件。真实系统里,宽高和时长通常由上传完成后的探测任务写入,Validate 在探测之后、内容入库之前执行。探测失败时应该拒绝内容或进入隔离区,而不是写入零值让下游猜。
测试:边界按字符数而不是字节数
校验函数的测试要覆盖两类输入:合法组合证明规则没有误伤,非法组合证明每个必填条件真的生效。下面这组表驱动测试把标题的 80 字符边界、四种媒体组合和七种错误路径固定下来。
// file: internal/content/metadata_test.go
// tier: L1
// verify: go test ./internal/content/
package content
import (
"strings"
"testing"
)
func TestValidateAcceptsWellFormedMeta(t *testing.T) {
cases := []struct {
name string
meta Meta
}{
{"text-only", Meta{Title: "一篇短文", Body: "正文"}},
{"title-80-runes", Meta{Title: strings.Repeat("字", 80), Body: "正文"}},
{"image", Meta{Title: "一张图", Media: []Media{{Kind: MediaImage, Bytes: 2048, Width: 800, Height: 600}}}},
{"video", Meta{Title: "一段视频", Media: []Media{{Kind: MediaVideo, Bytes: 1 << 20, Width: 1920, Height: 1080, DurationMS: 15000}}}},
{"body-with-text-media", Meta{Title: "图文", Body: "说明", Media: []Media{{Kind: MediaText, Bytes: 12}}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := Validate(c.meta); err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
})
}
}
func TestValidateRejectsBadMeta(t *testing.T) {
longTitle := strings.Repeat("字", 81)
cases := []struct {
name string
meta Meta
}{
{"empty-title", Meta{Body: "正文"}},
{"blank-title", Meta{Title: " ", Body: "正文"}},
{"title-too-long", Meta{Title: longTitle, Body: "正文"}},
{"empty-body-and-media", Meta{Title: "只有标题"}},
{"image-without-size", Meta{Title: "图", Media: []Media{{Kind: MediaImage, Bytes: 10}}}},
{"image-without-bytes", Meta{Title: "图", Media: []Media{{Kind: MediaImage, Width: 10, Height: 10}}}},
{"video-without-duration", Meta{Title: "视频", Media: []Media{{Kind: MediaVideo, Bytes: 10}}}},
{"text-with-width", Meta{Title: "文字", Media: []Media{{Kind: MediaText, Width: 10}}}},
{"unknown-kind", Meta{Title: "音频", Media: []Media{{Kind: MediaKind("audio"), Bytes: 10}}}},
{"negative-bytes", Meta{Title: "图", Media: []Media{{Kind: MediaImage, Bytes: -1, Width: 1, Height: 1}}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := Validate(c.meta); err == nil {
t.Fatalf("Validate(%+v) = nil, want error", c.meta)
}
})
}
}
title-80-runes 用 80 个中文字符构造标题,按字节算是 240 字节。如果实现误用 len(title),这个用例会失败,从而在测试阶段暴露“中文标题被提前截断”的问题。title-too-long 只多一个字符,确保边界是闭区间。
错误用例里最容易被忽略的是 image-without-bytes 和 video-without-duration。前者模拟客户端只上报了宽高、没有等对象存储返回大小;后者模拟探测任务只拿到分辨率、没拿到时长。两种输入在真实系统里都很常见,如果校验只看“字段是否存在”而不看“值是否有效”,它们会顺利进入下游。
测试没有覆盖的部分也要说清楚:它不验证 EXIF 是否被正确解析,不验证视频时长是否准确到毫秒,也不验证对象存储返回的字节数是否可信。这些属于探测任务的职责,校验函数只负责守住“入库前必须有什么”。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/content/ -run 'TestValidate' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsWellFormedMeta
=== RUN TestValidateAcceptsWellFormedMeta/text-only
=== RUN TestValidateAcceptsWellFormedMeta/title-80-runes
=== RUN TestValidateAcceptsWellFormedMeta/image
=== RUN TestValidateAcceptsWellFormedMeta/video
=== RUN TestValidateAcceptsWellFormedMeta/body-with-text-media
--- PASS: TestValidateAcceptsWellFormedMeta (0.00s)
--- PASS: TestValidateAcceptsWellFormedMeta/text-only (0.00s)
--- PASS: TestValidateAcceptsWellFormedMeta/title-80-runes (0.00s)
--- PASS: TestValidateAcceptsWellFormedMeta/image (0.00s)
--- PASS: TestValidateAcceptsWellFormedMeta/video (0.00s)
--- PASS: TestValidateAcceptsWellFormedMeta/body-with-text-media (0.00s)
=== RUN TestValidateRejectsBadMeta
=== RUN TestValidateRejectsBadMeta/empty-title
=== RUN TestValidateRejectsBadMeta/blank-title
=== RUN TestValidateRejectsBadMeta/title-too-long
=== RUN TestValidateRejectsBadMeta/empty-body-and-media
=== RUN TestValidateRejectsBadMeta/image-without-size
=== RUN TestValidateRejectsBadMeta/image-without-bytes
=== RUN TestValidateRejectsBadMeta/video-without-duration
=== RUN TestValidateRejectsBadMeta/text-with-width
=== RUN TestValidateRejectsBadMeta/unknown-kind
=== RUN TestValidateRejectsBadMeta/negative-bytes
--- PASS: TestValidateRejectsBadMeta (0.00s)
--- PASS: TestValidateRejectsBadMeta/empty-title (0.00s)
--- PASS: TestValidateRejectsBadMeta/blank-title (0.00s)
--- PASS: TestValidateRejectsBadMeta/title-too-long (0.00s)
--- PASS: TestValidateRejectsBadMeta/empty-body-and-media (0.00s)
--- PASS: TestValidateRejectsBadMeta/image-without-size (0.00s)
--- PASS: TestValidateRejectsBadMeta/image-without-bytes (0.00s)
--- PASS: TestValidateRejectsBadMeta/video-without-duration (0.00s)
--- PASS: TestValidateRejectsBadMeta/text-with-width (0.00s)
--- PASS: TestValidateRejectsBadMeta/unknown-kind (0.00s)
--- PASS: TestValidateRejectsBadMeta/negative-bytes (0.00s)
PASS
ok webapp/internal/content 0.314s
输出里的每个子用例对应一条规则。PASS 表示包内测试通过,ok 后面的时间是本次运行耗时,不是性能承诺。上线前还应该在真实文件样本上跑一遍探测与校验的联调,确认客户端上报、服务端探测和对象存储返回值三者一致。
小结
元数据不是“把文件信息存下来”,而是审核、检索、转码和计费共同的前置输入。上传接口只存 URL,等于把本该在准入时回答的问题推迟到下游;下游没有上下文,只能排队、猜或者失败。
Validate 把准入条件写成纯函数:标题按字符数限制,正文与媒体至少有一个非空,图片必须有宽高和字节数,视频必须有时长,文本不能携带不属于它的字段,未知类型直接报错。它不能替代文件探测,但能保证探测之后的判断一致、可回归。
下一节讨论内容进入系统之后的第二个问题:谁在什么状态下可以修改它,以及审核通过的到底是哪一个版本。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 内容状态机:草稿、审核、发布、删除 。
常见错误与边界
- 只存 URL 不存元数据。下游每次都要重新解析,口径迟早不一致。
- 把宽高当成可选。图片缺尺寸时,前端布局、裁剪和审核都会退化。
- 用字节数限制中文标题。UTF-8 下中文占 3 个字节,按字节限制会误伤。
- 让文本携带宽高和时长。字段越宽松,上游填错的空间越大。
- 未知媒体类型静默放行。新类型会被当成文本或图片处理,问题推迟到渲染阶段。
- 在 handler、审核后台和转码服务里各写一份校验。规则一旦漂移,入口之间就会互相矛盾。
- 探测失败时写入零值。零值看起来合法,实际把不确定性留给了所有下游。
- 认为校验能替代文件安全扫描。元数据校验只回答“字段是否齐备”,不回答“内容是否安全”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。