第21章:直播素材管理服务
本章分析 internal/service/live_material_service.go 与对应 handler:素材 CRUD 的业务规则(URL 类型自动识别、唯一冲突的三态错误)、RetryASR 与字幕导出两个动作型接口,以及「service 层错误即业务文档」的错误变量组织法。
流程
素材服务的输入输出围绕一条状态链展开。Create 的流程:TrimSpace 清洗 → 名称/链接非空校验 → detectLiveURLType 自动识别 URL 类型(含 .m3u8 → m3u8,否则 file,由后台判定而非客户端传入,接口注释明确这一点)→ 扩展名白名单校验(mp3/wav/mp4/mov/ogg/raw,ErrUnsupportedMediaFormat 列出全表)→ 落库(asr_status=pending)→ 触发 ASR Worker Enqueue 唤醒 → 返回完整实体。
Create 的冲突处理是本章最有设计感的部分:素材名称与 live_url 双唯一索引,插入撞唯一约束时 service 捕获并转换为携带已有记录的结构化错误:
LiveMaterialExistsError{ Material: 已存在的那条记录, Cause: 原始错误 }
handler 识别该错误类型后返回 409 + 业务码 CodeLiveMaterialExists = 40901 + data 里带上已有素材——用户录入重复素材时立刻看到「这个链接已录入过,名字叫什么」,而不是一句干瘪的「已存在」。这是 response.Conflict 自定义 code 通道(第 16 章)的标准用例。
Update 仅允许改 name/remark(service 注释「仅允许修改」,仓储层 Select 限定双保险,第 15 章);Delete 物理删除并级联删项目;RetryASR 仅 failed 状态可重试(ErrASRRetryOnlyFailed),重置 pending 后靠 Worker 扫库自动重跑;DownloadASRSubtitle 要求 ASR 已完成且段落非空,返回 TXT 内容与建议文件名。
实现
错误组织法值得单独说:service 文件顶部集中声明哨兵错误(ErrLiveMaterialNotFound 等 8 个)+ 一个结构化错误类型 + 一个业务码常a量。效果是:
- handler 可以
errors.As精确分流(NotFound→404、Exists→409+data、校验错→400、其他→500),错误处理集中在 handler 的一个 switch 里; - 错误文案与错误身份绑定,不会出现「同一个错误两处两种说法」;
- 业务码 40901 紧挨着错误类型定义,前后端契约(前端
businessCodes.ts对应处理)在同一处可查。
其余实现细节:
LiveMaterialListOptions接收字符串日期(2006-01-02布局),service 层解析为时间指针后交仓储——解析失败容错为 nil(不筛选)而非报错,列表接口对脏参数宽容。- 列表查询转仓储的
LiveMaterialListFilter,其中 ASRKeywords 走 jsonb 文本模糊匹配(第 15 章),素材列表因此可以「搜转写内容」。 - Get 返回完整实体含 live_asr 原始 JSON,供调试页展示;List 则瘦身。
- handler 侧
live_material_test.go覆盖创建校验、冲突、retry 状态机等分支,service 用 stub 仓储。
📌 设计决策
- url_type 服务端识别:客户端传类型必然出现「m3u8 标错成 file」的脏数据,识别规则集中在
detectLiveURLType一处,未来可扩展(如识别 flv)。 - 冲突返回已有记录:把「错误」升格为「信息」,用户免于重复录入的来回试错;代价是唯一冲突必须靠 DB 约束兜底(先查后插在并发下有窗口)。
- RetryASR 重置而非直接执行:所有异步动作统一走「改状态 + 唤醒 Worker」模式,service 永不直接执行长任务。
代码示例
冲突错误的定义与判定(完整保留语义):
// internal/service/live_material_service.go(节选)
// CodeLiveMaterialExists 创建素材时记录已存在的业务错误码。
const CodeLiveMaterialExists = 40901
// LiveMaterialExistsError 创建时发现素材已存在;携带已有完整记录供接口返回。
type LiveMaterialExistsError struct {
Material *model.LiveMaterial
Cause error
}
func (e *LiveMaterialExistsError) Error() string {
/* 委托 Cause 或默认文案 */ }
func (e *LiveMaterialExistsError) Unwrap() error {
return e.Cause }
服务接口的注释即业务规则书:
// LiveMaterialService 直播素材业务接口(节选)
type LiveMaterialService interface {
// Create 创建直播素材,createdBy 来自 JWT 当前用户;分辨率由 ASR 预处理 ffprobe 回写。
// url_type 由后台根据 live_url 自动识别(含 .m3u8 → m3u8,否则 file),不由客户端传入。
Create(ctx context.Context, createdBy uint, name, liveURL, remark, ext string) (*model.LiveMaterial, error)
// Update 更新直播素材,仅允许修改 name、remark。
Update(ctx context.Context, id uint, name, remark string) (*model.LiveMaterial, error)
// RetryASR 将失败的 ASR 重置为 pending,由后台 Worker 扫库重试。
RetryASR(ctx context.Context, id uint) (*model.LiveMaterial, error)
// DownloadASRSubtitle 返回可用于直接下载的 ASR 字幕 TXT 与建议文件名。
DownloadASRSubtitle(ctx context.Context, id uint) (content []byte, fileName string, err error)
}
小结
- 素材服务 = CRUD + 三态唯一冲突 + 两个动作接口(retry、字幕导出)。
- 哨兵错误 + 结构化冲突错误 + 业务码常量的三件套,是前后端错误契约的范本。
- 一切异步动作走「改状态 + Enqueue 唤醒」,service 不碰长任务。
思考题
- 冲突检测依赖 DB 唯一约束,若两个请求同时插入同名素材,第二个的错误路径如何保证 Material 字段非空?
- 素材删除级联删项目但任务表存了 live_name 快照——被删素材的历史任务还能正确展示吗?试论证。
项目信息
- GitHub仓库:Chyona/live-mixer