第31章:任务接口与任务服务
本章覆盖异步任务的"入口层"——handler/v1/task.go 与 service/task_service.go。它们是三组任务流水线(ai_slice / draft / ai_slice_draft)的编排起点:把 HTTP 请求翻译为带快照字段的 Task 落库,并即时唤醒对应 Worker。
流程
任务创建遵循"双路径"模型。推荐路径:前端只传 live_id + clips0(时间轴选区),由后端完成排序合并、按有效 ASR 拆分为多个 video_project、逐个发布任务;兼容路径:只传 video_project_id,复用已有项目。两条路径最终都汇聚到同一段"快照组装"代码。
flowchart TD
A[POST /v1/tasks/ai-slice] --> B{live_id 还是 video_project_id?}
B -- live_id + clips0 --> C[校验 clips0 与 ASR 完成态]
C --> D[sortAndMerge 合并选区]
D --> E[splitClips0IntoProjects<br/>按有效 ASR 约每 30 分钟拆分]
E --> F[逐组创建 VideoProject]
B -- video_project_id --> G[读取既有项目校验 clips0]
F --> H[组装 Task 快照字段<br/>live_url/live_name/width/height]
G --> H
H --> I[task_repo.Create 落库]
I --> J[Worker.Enqueue 唤醒]
J --> K[返回 list/total 立即响应]
CreateDraft 是最简的特例:只接受 video_project_id,直播 URL 与切片由 Worker 从关联表读取——创建时即校验 ResolveClipRanges + ValidateClipRanges,把无效任务挡在队列之外。
实现
服务层最值得品味的是三层防御的创建期校验。第一层是素材状态:requireASRCompletedMaterial 检查 ASRStatus == completed,未完成直接返回 ErrTaskASRNotReady,避免 LLM 拿不到分句空跑。第二层是提示词:resolveSysPrompt 允许 prompt_id == 0 回落到 DefaultVideoProjectPromptID,但内容为空则拒绝建任务。第三层是选区与 ASR 的重叠校验:validateAISliceGroups 确保每段 clips0 与 live_asr 有重叠分句,否则抛 ErrTaskClips0NoASR。
快照策略贯穿始终:CreateDraft 中画布尺寸由 draft.ResolveCanvasSize(请求值, 项目值) 解析后写入 task.width/height;live_url/live_name 从素材实体复制。列表页因此永远不需要 JOIN——这是第 12 章 Task 模型设计的兑现处。
TaskExt 的纪律同样严格:clips 不写入 ext(由 Worker 从 video_project 读取),marshalTaskExt 超过 1024 字节即报错。ext 只承载 ID 引用与少量元数据,大对象一律走关联表。
错误到 HTTP 状态码的映射集中在 handler 的 writeTaskCreateError:NotFound 类(素材/项目/提示词不存在)→ 404;ASR 未就绪、重名 → 400。GetTask 轮询接口则直接读库返回全字段,created_by 通过 createdByResolver 批量解析为展示名(nickname 优先)。
📌 设计决策
- 双路径创建:新路径(live_id + clips0)让前端无需先建项目,把"拆分粒度"决策权收回后端;兼容路径保留 API 早期形态,避免破坏既有调用方。
- 批量任务原子性妥协:
createAISliceJobs在循环中逐个建 project + task,中途失败会留下部分任务但返回错误。选择"不回滚"而非事务包裹,因为已成功的任务本身有效,且回滚需要跨 project/task 两表清理。 - Worker 可为 nil:
NewTaskService注入三个 Worker 均可空,单测场景只验证落库逻辑不真正调度——依赖倒置让调度成为可插拔能力。 - 列表返回 TaskListItem 而非 Task:列表投影复用快照字段 + ext 原文,避免为分页列表加载完整实体,也与仓储层 SELECT 列裁剪对齐。
代码示例
片段 1 展示推荐路径的核心编排(摘自 task_service.go,节选):
func (s *taskService) createAISliceJobs(ctx context.Context, createdBy uint, in createAISliceJobsInput) ([]*model.Task, error) {
if len(in.Clips0) == 0 {
return nil, errors.New("clips0 不能为空,请先设置待分析时间段")
}
if err := prepare.ValidateClipRanges(in.Clips0); err != nil {
return nil, err
}
// 防御一:素材必须 ASR 完成,否则 LLM 没有分句可分析。
material, err := s.requireASRCompletedMaterial(ctx, in.LiveID)
if err != nil {
return nil, err
}
// 防御二:系统提示词必须非空,ID 为 0 时回落默认。
sysPrompt, err := s.resolveSysPrompt(ctx, promptID)
if err != nil {
return nil, err
}
// 排序合并重叠选区,再按有效 ASR 拆成多个项目(约每 30 分钟一个)。
merged := sortAndMergeOverlappingClipRanges(in.Clips0)
utterances := asr.FormatUtterancesForAPI(material.LiveASR)
groups := splitClips0IntoProjects(merged, utterances, material.ASRParagraphs)
groups = finalizeAISliceGroups(groups, utterances)
if err := validateAISliceGroups(groups, utterances); err != nil {
return nil, err
}
tasks := make([]*model.Task, 0, len(groups))
for i, clips := range groups {
project := &model.VideoProject{
Name: names[i], LiveID: in.LiveID, PromptID: promptID,
Clips0: clips, ProjectSource: in.ProjectSource,
EnableCaptions: model.EnableCaptionsOn, CreatedBy: createdBy,
}
if err := s.videoProjectRepo.Create(ctx, project); err != nil {
return nil, mapVideoProjectUniqueError(err)
}
task := &model.Task{
Type: in.TaskType, Status: model.TaskStatusPending,
VideoProjectID: model.NewUintPtr(project.ID),
VideoProjectName: project.Name,
LiveURL: material.LiveURL, LiveName: material.Name, // 快照字段
SysPrompt: sysPrompt, CreatedBy: createdBy, Ext: ext,
}
if err := s.taskRepo.Create(ctx, task); err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, nil
}
片段 2 展示 ext 容量护栏与 Worker 唤醒(摘自 task_service.go):
// TaskExt 写入 task.ext 的结构化元数据。
// 画布尺寸与源视频链接/名称已冗余落在 task.width/height/live_url/live_name,不再写入 ext。
type TaskExt struct {
LiveID uint `json:"live_id,omitempty"`
VideoProjectID uint `json:"video_project_id,omitempty"`
SysPromptID uint `json:"sys_prompt_id,omitempty"`
TargetDurationMs int64 `json:"target_duration_ms,omitempty"`
Clips0 []model.ClipRange `json:"clips0,omitempty"`
}
func marshalTaskExt(ext TaskExt) (string, error) {
raw, err := json.Marshal(ext)
if err != nil {
return "", err
}
if len(raw) > 1024 {
return "", errors.New("任务扩展参数过长")
}
return string(raw), nil
}
// CreateDraft 尾部:落库后立即唤醒草稿 Worker,轮询兜底。
if s.draftWorker != nil {
s.draftWorker.Enqueue()
}
return task, nil
小结
任务服务是"薄入口、厚校验"的典型:入口逻辑只做参数翻译与快照组装,真正的业务展开在 Worker 侧。三层创建期校验把失败前移到请求时点,队列里只剩有希望成功的任务。
思考题
createAISliceJobs循环建 project/task 无事务回滚,若第 3 组失败,前 2 组任务已入队执行——这种"部分成功"语义对前端轮询展示有什么影响?如何向用户呈现?- 若把
SysPrompt全文写入 task(当前做法)改为只存SysPromptID由 Worker 再查库,会引入哪些一致性问题?当前的快照取舍解决了什么?
项目信息
- GitHub仓库:Chyona/live-mixer