跳转至

场次录像 API(User Record API)

客户业务后端 ──appId/appSecret(appSecret 是签名密钥)──> mmx-recorder 直连(切片 + 上传)
客户业务后端 ──appId/appSecret──────────────────────────> ppcenter(round-events,纯审计,可选)

这里的“场次”是一次有明确开始和结束的业务活动:一局游戏、一场竞拍、一堂课都可以。

1. 服务地址

POST https://record.pp-cdn.org/api/record/split
Content-Type: application/json

2. 两阶段协议

一场次对应"开始"和"结束"两次调用,靠 gameRound 字段是否为空区分:

阶段 gameRound 效果
场次开始(round-start) 留空/不传 把 (appId, tableId) 锁给这次调用的 owner(appEnv+gameId,或没传 appEnv 时就是 gameId),对 tableId 解析出的每一个已配置 view 路径立刻切一刀,开始累积新分段
场次结束(round-end) 传具体值 校验调用方就是当初开始这场次的 owner,把每个 view 路径当前分段再切一刀并重命名为最终文件名,触发异步上传,解锁 (appId, tableId)

同一个 (appId, tableId) 组合同一时间只能被一个 owner 持有;别的 owner 在此期间调用 round-start 会被拒绝(见 §5 状态码表)。不同 appId 下相同的 tableId 字符串互不影响——两者解析到的是完全不同的物理流路径(见 §4 的路径拼接规则),不会互相锁住。用同一个 owner(相同 appEnv+gameId)重复调用是被允许的。

对一个从未开始过的场次调用 round-end 是静默 no-op 成功,不是错误——因为 round-end 信号本身是无条件发出的,不该因为没有匹配的 round-start 就报错/告警。

一个 tableId 如果有多个 view 同时在线(同一桌台的多个机位/镜头,流名形如 {tableId}-{view}),一次 round-start/round-end 会同时对每个在线 view 各自的流路径都切一刀,调用方不需要(也没办法)针对单个 view 分别调用。view 由录像节点从当前已知的流路径自动识别,无需任何事先配置(不再依赖后台的 site_stream_configs)。

3. 请求格式

{
  "time": "1757280000",
  "appId": "app_xxx",
  "tableId": "table1",
  "gameId": "p2w001",
  "gameRound": "",
  "appEnv": ""
}
字段 必填 说明
time 是 unix 秒时间戳字符串,是这次请求的签名时刻(不是过期时间),鉴权和防重放都靠它
appId 是 你在控制台创建的 app 的 appId。用于拼装实际流路径,也是签名所用 appSecret 的归属方——请求用哪个 appId,签名就必须用该 appId 自己的 appSecret 计算。appId 本身也参与该签名,防止被篡改成别的 app
tableId 是 桌台/机位标识,^[A-Za-z0-9_-]{1,128}$。与 appId 一起按 {appId}/{tableId}-{view} 前缀匹配节点上当前已知的流路径;view 从实际流名({appId}/{tableId}-{view},可带 /h264、/hevc 编码后缀)自动识别(见多机位说明)。若该 tableId 下没有任何匹配路径,回退为默认 view fwh
gameId 是 本次场次/业务标识,同上字符集限制。与 appEnv 一起决定这场次的 owner 身份
gameRound 场次结束时必填,场次开始留空 场次内的"阶段/轮次"标识,决定是开始还是结束(见 §2),同样的字符集限制
appEnv 否 覆盖上传落地环境(见 §6);同时也参与 owner 身份计算,不同 appEnv 下相同 gameId 视为不同 owner

字段名区分大小写,多余的未知字段会被忽略

4. 鉴权

请求头带 Authorization,签名用你这个 appId 自己的 appSecret(控制台创建 app 时生成的同一个凭证;不是部署级共享密钥)。具体格式取决于部署选的模式(默认 simple)。

4.1 simple 模式(默认)

Authorization: <32位十六进制 md5 值>

计算方式:md5(appSecret + time + appId + tableId + gameRound + gameId)——gameRound 为空字符串时(场次开始)不参与拼接,gameId 必填所以总是参与:

base = appSecret + time + appId + tableId
if gameRound:  base += gameRound
base += gameId
token = md5(base).hexdigest()

4.2 advance 模式(需我方显式开启)

防重放更严格,需要在部署侧为你的接入开启:

Authorization: HMAC-SHA256 <hex(hmac_sha256(appSecret, canonical_json))>
X-Split-Rec-Nonce: <16~128 字符,不含 \r\n 的随机串>

其中 canonical_json 是按固定字段顺序序列化的 JSON(顺序为 time,appId,tableId,gameRound,gameId,nonce):

{"time":"1757280000","appId":"app_xxx","tableId":"table1","gameRound":"","gameId":"p2w001","nonce":"<同一个nonce>"}

两种模式的时间容忍窗口不同:simple 模式 time 须在服务器时钟 ±30 秒内(因为没有 nonce,容忍窗口越宽,被截获重放的可利用时间越长);advance 模式 time 须在 ±5 分钟内,真正防重放靠的是 nonce——同一个 nonce 不能重复使用。

5. 响应格式与状态码

统一信封:

{"code": 200, "msg": "OK", "data": null}
HTTP 状态码 code 触发场景
200 200 成功,包括"结束一个从未开始过的场次"这种静默 no-op
400 400 请求体不是合法 JSON;缺 time/appId/tableId/gameId/Authorization 任一项;appId/tableId/gameId/gameRound/appEnv 含非法字符
401 401 签名校验失败(含 appId 被篡改);time 超出容忍窗口(simple 模式 ±30s / advance 模式 ±5min);advance 模式下 nonce 重复使用
429 429 限流,单 IP 每秒最多 500 次请求
500 500 业务失败——包括"这个 (appId, tableId) 已经被另一个 gameId 占用"这种冲突场景,不是 409;以及"要结束的分段自上次切割以来没有任何新数据,无法完成"等
500(code=50001) 50001 该 app 名下这个 tableId 解析出的每一个 view 路径都不在线(app 本身合法,只是当前没有推流)——与其他 500 场景的区别在于 code 字段,不需要按 msg 文本判断

500 类错误(不含 code=50001)的 msg 是具体错误文本(如 game "p2w002" already has an active recording on table "table1"),接入时应该按文本判断而不是只看状态码,因为这类"业务性拒绝"和真正的服务端异常共用同一个 HTTP 状态码。

6. 录像上传

场次结束(round-end)成功切完最终文件后,会异步、尽力而为地上传到对象存储——上传失败不影响本次 /api/record/split 已经返回的 200,拿到成功响应不代表上传已经完成或一定会成功。

  • 不传 appEnv(留空)时,对象名不带任何前缀,就是 <tableId>-<viewName>-<roundId>.mp4(viewName 是该 tableId 解析出的机位名,roundId 即请求里的 gameRound;gameId 只用于上报/审计,不再进入文件名)——不传不会报错,也不会拿部署环境的默认值来顶替。
  • 只有请求体里显式传了非空 appEnv,才会在对象名前面加一层 <小写 appEnv>/ 前缀,例如传 appEnv=test 得到 test/table1-...mp4;prod 不是特例,显式传 appEnv=prod 同样会带上 prod/ 前缀。
  • 不管传不传、传的是什么 appEnv,最终都落在 OVH net-storage 里同一个桶——这是使用 OVH net-storage 的约束(没有"每个环境一个桶"这种配置),不同环境只能靠对象名前缀区分,不能靠换桶。
  • 这个机制的实际意义:接入验收/联调阶段可以显式传一个 appEnv(如 test),让测试产生的文件落在同一个桶下的独立前缀里,命名上不会跟不传 appEnv 的录像混在一起;但这只是同一个桶内的路径隔离,不是存储桶级别的访问权限隔离。
  • 上传前会先把录像文件做格式优化(不重新编码),方便直接在浏览器里播放不用等下载完;优化失败会退化成直接上传原文件,不会因此丢失这段录像。

7. 完整示例(simple 模式)

APP_SECRET="你这个 appId 自己的 appSecret(控制台创建 app 时生成)"
APP_ID="app_xxx"; TABLE_ID="table1"; GAME_ID="p2w001"

# 场次开始
T=$(date +%s)
AUTH=$(printf '%s%s%s%s%s' "$APP_SECRET" "$T" "$APP_ID" "$TABLE_ID" "$GAME_ID" | md5sum | cut -d' ' -f1)
curl -s -X POST "https://record.pp-cdn.org/api/record/split" \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d "{\"time\":\"$T\",\"appId\":\"$APP_ID\",\"tableId\":\"$TABLE_ID\",\"gameId\":\"$GAME_ID\"}"

# ...场次进行中(直播 / 课堂等)...

# 场次结束
T=$(date +%s); GAME_ROUND="gc001"
AUTH=$(printf '%s%s%s%s%s%s' "$APP_SECRET" "$T" "$APP_ID" "$TABLE_ID" "$GAME_ROUND" "$GAME_ID" | md5sum | cut -d' ' -f1)
curl -s -X POST "https://record.pp-cdn.org/api/record/split" \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d "{\"time\":\"$T\",\"appId\":\"$APP_ID\",\"tableId\":\"$TABLE_ID\",\"gameRound\":\"$GAME_ROUND\",\"gameId\":\"$GAME_ID\"}"

8. 接入前需要确认的部署细节

  • 目标流必须走 appId/streamName 推流体系(在控制台创建 App 并按 appId/streamName 推流),streamName 必须是 {tableId}-{view} 格式——这是 mmx 推流白名单的硬性要求,不符合格式的推流会被拒绝,round-start 也无法解析到任何路径。view 任意;录像按 {appId}/{tableId}- 前缀从当前已知流路径自动识别(不需要事先配置 view)。
  • appId 必须是当前有效(未欠费、未删除)的 app:mmx 节点每 30 秒从 ppcenter 同步一次有效 app 列表(含 appId+appSecret),新建 app、欠费后恢复、或修改了 appSecret,最多有 30 秒的生效延迟。
  • 若该 app 名下这个 tableId 当前没有任何 view 在推流,round-start 会以 code=50001 失败(见 §5),不是流路径配置问题,等实际推流开始后重试即可。
  • 多机位(同一 tableId 下多个 view)不需要事先配置:只要各 view 的流({appId}/{tableId}-{view})当前在线,round-start 会自动识别并对每个 view 同时录制;只有 fwh 这个默认回退视图是唯一保留的隐式约定(用于无任何匹配路径时的 ingest 兜底)。
  • 场次开始后不要立刻调用结束:如果两次调用间隔太短(短于一个分段时长,通常是 1 秒量级),还没有新数据写入当前分段,结束会以"nothing to finalize"失败。正常场次的开始到结束通常远长于这个量级,只有联调/压测脚本容易踩到。