用 TRAE SOLO 搭建一套 Serverless 视频转码控制面:从外部 URL 到 CDN MP4

1. 摘要

这次我用 TRAE SOLO 辅助开发了一套轻量级视频转码控制面服务:mps-transcode-serverless

它解决的问题是:业务系统只提供一个外部视频 URL,服务自动完成素材拉取、COS 暂存、腾讯云 MPS 转码、回调处理、状态管理、CDN 地址返回和短周期清理,最终把各种来源的视频统一转换成可投放、可访问的 MP4 CDN 地址。

这个项目不是单纯调用一个云 API,而是围绕真实生产链路补齐了幂等、回调、补偿、Serverless 部署、MySQL 状态管理、COS 生命周期、并发竞争处理等一整套工程化能力。


2. 背景

我的工作场景和广告素材分发、ADX/DSP 投放链路有关。

在广告投放系统里,经常会遇到这样的问题:

业务方给过来的素材可能是:

  • 外部 HTTP/HTTPS 视频 URL;

  • COS 上的原始视频文件;

  • 不同格式、不同编码的视频;

  • 临时有效的素材地址;

  • 不同媒体侧要求不一致的视频格式。

但投放链路最终更希望拿到一个标准化结果:

稳定可访问的 MP4 CDN 地址

如果只是本地用 FFmpeg 转一次,当然很简单。

但一旦进入生产环境,会遇到很多实际问题:

  • 外部 URL 不一定能被腾讯云 MPS 直接拉取;

  • 转码任务是异步的,需要回调处理;

  • 回调可能重复、乱序或丢失;

  • Serverless 实例会扩缩容,内存状态不可靠;

  • 同一个素材不能重复提交、重复扣费;

  • 转码完成后需要把 COS 输出改写成业务 CDN 地址;

  • 原始素材和转码结果都不能永久保存,需要生命周期清理;

  • MySQL 任务状态也不能无限增长,需要定时清理。

所以我希望做一个小而完整的控制面服务,把这些复杂性封装起来。


3. 实践过程

这次开发主要使用 TRAE SOLO 来完成需求拆解、代码实现、测试补齐和部署排查。

整体流程可以概括为:

需求拆解
  -> 设计接口和状态机
  -> 实现 Go 服务
  -> 接入腾讯云 MPS / COS / MySQL
  -> 补齐回调和幂等
  -> Serverless zip 部署
  -> 线上日志排查和修复
  -> 生命周期清理


4. 任务拆解方式

我先让 SOLO 帮我把需求拆成几个模块:

1. HTTP API 层
2. 转码服务业务层
3. 腾讯云 MPS 适配层
4. COS staging 层
5. MySQL job store
6. MPS callback 解析
7. ADX outbound callback
8. 定时补偿 sync
9. Serverless 部署
10. MySQL cleanup 定时函数

其中比较核心的接口包括:

POST /jobs/submit
POST /jobs/batch-submit
GET  /jobs/{id}
POST /jobs/callback
POST /jobs/{id}/sync
POST /admin/sync-stale

单个素材提交:

{

  "source_url": "https://example.com/video.f4v",

  "template_id": "328016"

}


批量提交:

{

  "template_id": "328016",

  "callback_url": "https://adx.example.com/callback",

  "items": [

    {

      "external_id": "creative-001",

      "source_url": "https://example.com/a.f4v"

    },

    {

      "external_id": "creative-002",

      "source_url": "https://example.com/b.f4v"

    }

  ]

}



5. SOLO 在开发中的作用

这次不是让 AI 一次性“写完整项目”,而是把任务拆成小块,逐步推进。

我比较常用的 Prompt 方式是:

请基于当前 Go 项目,实现一个批量提交接口 POST /jobs/batch-submit。
要求:
1. items 最多支持环境变量 BATCH_SUBMIT_MAX_URLS 控制;
2. 每个 item 独立返回结果;
3. 单个 URL 失败不能影响整个 batch;
4. 使用 source_url + template_id 做幂等;
5. 重复 URL 要返回 duplicate_of;
6. 补充单元测试。

另一个例子是排查线上回调 panic:

这是 SCF 日志:
callback: concurrent update detected on callback, other instance won the race
随后 jobToView(nil) panic。
请分析根因,并做最小修复。
要求:
ErrConcurrentUpdate 时重新 Repo.Get(ctx, jobID) 并返回当前 job。
补充回归测试。

SOLO 很适合这种“明确边界 + 明确验收条件”的开发方式。

我的经验是:

不要一次性让 AI 做整个系统。
要把任务拆成接口、状态机、存储、回调、部署、测试几个小步骤。
每一步都要求它自测和解释。


6. 整体架构

最终服务链路大致是:

ADX / 投放系统
  -> 提交外部视频 URL
  -> mps-transcode-serverless
  -> 下载源视频
  -> 上传到 COS staging
  -> 调用腾讯云 MPS ProcessMedia
  -> MPS 异步转码
  -> MPS 回调 /jobs/callback
  -> 更新 MySQL job 状态
  -> 生成 CDN MP4 地址
  -> 回调 ADX / 返回查询结果

核心组件:

Go HTTP Server
  提供 submit、batch-submit、callback、query、sync 等接口。

Tencent MPS Adapter
  封装 ProcessMedia 和 DescribeTaskDetail。

COS Stager
  把外部 HTTP/HTTPS 视频下载后上传到 COS,再交给 MPS。

MySQL Store
  保存 job 状态、幂等 key、MPS task id、回调事件、CDN URL。

Serverless SCF
  主 Web 函数处理 API 请求和 MPS 回调。

Cleanup SCF
  定时清理 3 天前的 MySQL job 状态。


7. 关键实现一:外部 URL 先 staging 到 COS

实际接入时发现,不能假设腾讯云 MPS 一定能直接处理任意公网 URL。

所以服务做了一层 staging:

外部 URL
  -> 服务下载
  -> 上传 COS staging
  -> 得到 cos://bucket/region/object
  -> 传给 MPS

staging 文件路径类似:

staging/{jobID}/source.f4v

这样 MPS 输入更加稳定,也方便做生命周期管理。


8. 关键实现二:幂等和去重

同一个素材不能反复提交,否则会造成重复转码、重复计费。

所以项目使用:

NormalizeSourceURL(source_url) + effective_template_id

作为幂等 key。

如果同一个素材之前已经提交过:

  • 已成功:直接返回 ready + cdn_url

  • 处理中:返回 processing + job_id

  • 已失败:允许重新提交

批量接口里还做了同批次去重:

{

  "index": 3,

  "status": "ready",

  "duplicate_of": 0

}


这样一个 batch 里重复出现的 URL 不会重复提交 MPS。


9. 关键实现三:MPS 回调解析和 CDN URL 改写

MPS 回调 payload 有一定复杂性,不同任务类型外层结构可能不同。

项目里做了兼容解析:

ProcedureStateChangeEvent
ProcedureStateChangedEvent
WorkflowTaskEvent
WorkflowTask
TaskEvent
Data
EventContent

另一个坑是 MPS 输出里真正可用的路径在:

TranscodeTask.Output.Path

而不是 Output.Object

最终服务会把 MPS 输出路径改写成业务 CDN 地址:

/transcode/{jobID}/xxx_transcode_328016.mp4

改写为:

https://cdn-mps.360os.com/transcode/{jobID}/xxx_transcode_328016.mp4

也就是说业务方拿到的是稳定的 CDN 地址,而不是原始 COS 地址。


10. 关键实现四:并发回调和乐观锁

Serverless 场景下,同一个 MPS 回调可能被多个实例并发处理。

项目里 MySQL job 使用 version 字段做乐观锁:

UPDATE transcode_jobs

SET ...

WHERE id = ? AND version = ?


如果更新行数为 0,说明另一个实例已经抢先更新成功。

线上真实遇到过一次问题:

callback: concurrent update detected on callback, other instance won the race
http: panic serving ...
jobToView(0x0)

根因是并发更新被识别后,服务返回了:

return nil, nil


HTTP 层以为成功了,于是执行:

jobToView(job)


最终 job == nil 导致 panic。

修复方式是:

if errors.Is(err, ErrConcurrentUpdate) {

    log.Printf("callback: concurrent update detected ...")

    return s.Repo.Get(ctx, req.JobID)

}


也就是并发竞争失败的一方重新读取当前 job 返回。

这个问题也补了回归测试,覆盖第一次 Save 和 ADX callback 二次 Save 两个场景。


11. 关键实现五:回调丢失后的补偿同步

MPS 回调不是百分百可靠,网络、配置、签名、实例冷启动都可能导致回调失败。

所以项目提供了同步接口:

POST /jobs/{id}/sync

它会主动调用腾讯云:

DescribeTaskDetail

查询 MPS 任务状态,并更新本地 MySQL job。

还提供了定时扫描卡住任务的能力:

POST /admin/sync-stale

用于补偿长时间停留在:

pending
submitted
running

的任务。


12. 关键实现六:Serverless 部署

项目使用 Go 编译成腾讯云 SCF Web 函数需要的 zip 包。

打包结果必须包含:

scf_bootstrap

不是 bootstrap

Makefile 里现在区分了 Web 函数和 cleanup 定时函数:

make package-web

make package-cleanup



make deploy-web

make deploy-cleanup


Web 主函数负责:

/jobs/submit
/jobs/batch-submit
/jobs/callback
/jobs/{id}

cleanup 定时函数负责:

每小时清理 MySQL 中超过 3 天的 job 记录

cleanup 函数使用同一个 function.zip,只是环境变量不同:

APP_MODE=cleanup-jobs
JOB_RETENTION_DAYS=3
JOB_CLEANUP_BATCH_SIZE=1000


13. 生命周期设计

为了避免数据无限增长,最终生命周期设计为:

COS staging 原始/临时文件:1 天
COS transcode 转码输出文件:3 天
MySQL job 状态记录:3 天

COS 文件过期靠 COS 生命周期规则:

staging/    1 天删除
transcode/ 3 天删除

MySQL 清理靠定时 SCF:

DELETE FROM transcode_jobs
WHERE updated_at < 当前时间 - 3 天
LIMIT 1000

这样 MySQL 不作为长期素材库,只承担短期状态、幂等、回调去重和查询功能。


14. 踩坑记录

这次最有价值的地方其实不是写 API,而是把生产坑一个个补上。

坑一:SCF 函数超时默认只有 3 秒

线上出现过:

batch-submit: internal error: context canceled
Invoking task timed out after 3 seconds

根因是 SCF 超时还是默认 3 秒。

batch-submit 会同步做下载、上传 COS、调用 MPS、写 DB,3 秒完全不够。

最终 Web 函数超时设置为:

180 秒


坑二:Web 函数可执行文件必须叫 scf_bootstrap

腾讯云 SCF Web 函数 Go 运行时要求 zip 根目录里的文件名是:

scf_bootstrap

不是:

bootstrap

否则会出现启动失败或 exec format 类问题。


坑三:MPS 回调 SessionId 在外层

真实 MPS 回调里:

{
  "EventType": "WorkflowTask",
  "WorkflowTaskEvent": {...},
  "SessionId": "job_id",
  "SessionContext": "job_id"
}

SessionIdSessionContext 不在 WorkflowTaskEvent 里面,而在外层。

解析时需要把外层字段传播进内层,否则会出现:

job_id is required


坑四:TaskId 不能单独作为回调去重 key

MPS 会对同一个任务发多次状态回调,例如:

PROCESSING
FINISH

如果只用 TaskId 做事件去重,FINISH 可能被当成重复事件跳过。

最终事件 ID 使用:

TaskId + "/" + Status


坑五:输出路径在 Output.Path

MPS 回调里转码文件路径在:

Output.Path

不是:

Output.Object

CDN URL 需要基于 Output.Path 拼出来。


15. 成果展示

目前项目已经实现:

:white_check_mark: 单 URL 转码提交
:white_check_mark: 批量 URL 转码提交
:white_check_mark: 同批次 URL 去重
:white_check_mark: source_url + template_id 幂等
:white_check_mark: 外部 URL staging 到 COS

:white_check_mark: 腾讯云 MPS ProcessMedia 接入

:white_check_mark: MPS 回调解析
:white_check_mark: CDN URL 改写

:white_check_mark: ADX outbound callback
:white_check_mark: MySQL 状态管理
:white_check_mark: 乐观锁防并发覆盖
:white_check_mark: 回调丢失后的 sync 补偿
:white_check_mark: stale job 定时扫描
:white_check_mark: Serverless zip 部署
:white_check_mark: cleanup 定时函数
:white_check_mark: COS / MySQL 短周期清理策略


16. 效果与总结

这个项目的价值不在于“调用了一次腾讯云 MPS”,而在于把一个真实业务链路里容易出问题的部分工程化封装了起来。

对业务侧来说:

输入:一个视频 URL
输出:一个标准 MP4 CDN URL

中间的复杂性都被服务吸收:

  • 源文件暂存;

  • 云转码;

  • 异步回调;

  • 状态查询;

  • 失败补偿;

  • 幂等去重;

  • 生命周期清理;

  • Serverless 部署。

对我个人来说,TRAE SOLO 在这个项目里的价值主要体现在三点:

第一,帮助我把一个复杂需求拆成多个小任务,而不是一开始就陷入细节。

第二,在 Go 代码、测试、部署模板之间来回切换时,SOLO 能持续保持上下文,适合做这种多模块工程项目。

第三,线上问题排查时,我可以直接把 SCF 日志贴给 SOLO,让它定位到具体代码路径,再做最小修复和回归测试。

最后总结一句:

AI 编程最适合的不是“替我写完一切”,而是把真实工程里的每一个小闭环快速完成:设计、实现、验证、排查、修复、沉淀。

这个项目就是一次比较完整的实践。