《互联网产品的服务端架构·内容、社交与实时互动》1.1 文本、图片与视频的元数据

从一次只存 URL 的上传接口出发,说明文本、图片与视频的元数据为什么是审核、检索、转码和计费的共同输入,拆解缺尺寸、缺时长如何把问题推迟到下游,并用可测试的 Go 校验函数固定标题长度、媒体类型与必填字段的边界,让上传接口在下游排队之前就拒绝不合格内容。

上传接口只存了一个 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、审核后台和转码服务里各写一份校验。规则一旦漂移,入口之间就会互相矛盾。
  • 探测失败时写入零值。零值看起来合法,实际把不确定性留给了所有下游。
  • 认为校验能替代文件安全扫描。元数据校验只回答“字段是否齐备”,不回答“内容是否安全”。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查