huz1xuan

高光时刻接口V2

node调用录制文件
2025-0306 新的dev分支
node 调用录制文件。
项目文档统一放在 [docs](./docs/README.md) 目录,高光录制接口见 [docs/HIGHLIGHT_API.md](./docs/HIGHLIGHT_API.md)。
项目文档统一放在 [docs](./docs/README.md) 目录。V2 录制任务与文件查询接口见 [docs/RECORDING_API.md](./docs/RECORDING_API.md)。
... ...
... ... @@ -28,12 +28,10 @@
"h":720
},
"HIGHLIGHTCONFIG": {
"enabled": false,
"sourceMode": "site",
"enabled": true,
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
... ...
# 获取录制文件(V2)
## 1. 接口说明
查询指定课堂的录制文件是否已经生成,并在文件全部可用时返回访问地址。
接口自动识别整课录制和仅高光录制,调用方不需要指定录制类型。查询操作不会创建、重新执行或修改录制任务。
| 项目 | 内容 |
|---|---|
| 接口版本 | V2 |
| 请求方式 | `POST` |
| 请求路径 | `/fileExistsV2` |
| Content-Type | `application/json; charset=utf-8` |
| 字符编码 | UTF-8 |
实际请求域名及网关鉴权方式以部署环境提供的信息为准。本接口请求体不额外接收签名字段。
## 2. 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|:---:|---|
| `siteId` | String | 是 | 站点 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
| `classId` | String | 是 | 课堂 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
| `classStartTime` | String | 否 | 兼容历史整课文件查询时使用,格式为 `yyyyMMdd`;V2 录制任务通常不需要传入 |
### 请求示例
```http
POST /fileExistsV2 HTTP/1.1
Host: {API 服务域名}
Content-Type: application/json; charset=utf-8
{
"siteId": "doctest",
"classId": "487012832"
}
```
历史整课文件查询示例:
```json
{
"siteId": "doctest",
"classId": "487012832",
"classStartTime": "20260805"
}
```
## 3. 响应参数
| 参数 | 类型 | 必定返回 | 说明 |
|---|---|:---:|---|
| `code` | Integer | 是 | 业务状态码,详见“状态码” |
| `message` | String | 是 | 状态说明 |
| `fileExists` | Boolean | 是 | `true` 表示本次录制对应的全部文件均已生成 |
| `onlyHighlight` | Integer | 否 | `0` 表示整课录制,`1` 表示仅高光录制 |
| `classUrl` | String | 否 | 整课录制文件地址;仅整课文件生成成功时返回 |
| `files` | Array | 是 | 录制文件列表;文件未全部生成时返回空数组 |
### `files` 元素
| 参数 | 类型 | 必定返回 | 说明 |
|---|---|:---:|---|
| `type` | String | 是 | 文件类型:`full` 为整课,`highlight` 为高光片段 |
| `url` | String | 是 | 文件访问地址 |
| `highlightId` | Integer | 否 | 高光记录 ID;仅 `type=highlight` 时返回 |
## 4. 响应示例
### 4.1 整课录制文件已生成
HTTP 状态码:`200`
```json
{
"code": 0,
"message": "文件已生成",
"fileExists": true,
"onlyHighlight": 0,
"classUrl": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4",
"files": [
{
"type": "full",
"url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4"
}
]
}
```
### 4.2 高光录制文件已生成
高光任务可能返回多个文件。只有全部高光文件都可用时,`fileExists` 才会返回 `true`。
HTTP 状态码:`200`
```json
{
"code": 0,
"message": "文件已生成",
"fileExists": true,
"onlyHighlight": 1,
"files": [
{
"type": "highlight",
"highlightId": 5,
"url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_5.mp4"
},
{
"type": "highlight",
"highlightId": 6,
"url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_6.mp4"
}
]
}
```
### 4.3 文件尚未生成
HTTP 状态码:`200`
```json
{
"code": 1,
"message": "文件未生成",
"fileExists": false,
"files": []
}
```
`fileExists=false` 表示当前没有可交付的完整录制结果,可能处于录制中、文件同步中或没有生成录制文件。调用方可在业务允许的时间范围内继续查询。
### 4.4 请求参数错误
HTTP 状态码:`400`
```json
{
"code": 3,
"message": "classId 无效",
"fileExists": false,
"files": []
}
```
### 4.5 服务异常
HTTP 状态码:`500`
```json
{
"code": -1,
"message": "服务器内部错误",
"fileExists": false,
"files": []
}
```
## 5. 状态码
| HTTP 状态码 | `code` | 说明 |
|---:|---:|---|
| `200` | `0` | 录制文件已全部生成,可以使用 `files` 中的地址 |
| `200` | `1` | 当前没有可交付的完整录制结果 |
| `400` | `2` | `siteId` 或兼容日期参数无效 |
| `400` | `3` | `classId` 无效 |
| `500` | `-1` | 服务内部异常 |
业务处理应同时判断 HTTP 状态码、`code` 和 `fileExists`。文件可交付的唯一判定条件为:
```text
HTTP 200 && code == 0 && fileExists == true
```
## 6. 调用建议
1. 本接口为幂等查询接口,可以重复调用。
2. 建议轮询间隔不低于 10 秒,避免高频查询。
3. `fileExists=false` 时不会返回部分文件地址。
4. 收到 HTTP `500` 时,可采用逐步延长间隔的方式重试。
5. 调用方应设置业务侧最长等待时间,避免无限轮询。
## 7. 版本记录
| 版本 | 日期 | 说明 |
|---|---|---|
| V2 | 2026-08-10 | 支持统一查询整课录制和仅高光录制结果 |
... ...
# WebScreen 高光录制接口文档
# 高光录制内部说明
## 1. 文档范围
本文汇总 WebScreen 对外提供的全部高光接口,以及 WebScreen 依赖的 SaaS 内部接口。
高光录制与整堂录制是两套独立业务:
- 原整堂录制接口保持不变。
- 所有高光接口统一使用 `/highlight` 前缀。
- `HIGHLIGHTCONFIG.enabled` 只控制高光查询、录制和文件检查,不影响原整堂录制;`GET /highlight/status` 始终可用于健康检查。
- 高光文件使用 `{classId}_highlight_{highlightId}.mp4`,不会覆盖整堂录像 `{classId}.mp4`。
示例服务地址:
正式入口是 `/recordingTaskV2`,正式查询是 `/fileExistsV2`。原录制接口保持不变。
```text
http://127.0.0.1:3001
```
除 `GET /highlight/status` 外,请求均使用:
```http
Content-Type: application/json
```
当前 WebScreen 接口没有应用层鉴权,只应开放给可信内网或经过访问控制的调用方。
## 2. 接口总览
| 方法 | 路径 | 用途 | 是否启动录制 |
|---|---|---|---|
| POST | `/highlight/preview/by-class` | 预览指定课堂的全部高光及目标文件信息 | 否 |
| POST | `/highlight/preview/by-site` | 预览指定站点、时间范围内的全部高光 | 否 |
| POST | `/highlight/recording/by-class` | 提交指定课堂的全部高光录制 | 是 |
| POST | `/highlight/recording/by-site` | 提交指定站点、时间范围内的全部高光录制 | 是 |
| POST | `/highlight/recording/scheduled` | 运行一次独立的高光定时任务 | 是 |
| POST | `/highlight/fileExists` | 查询指定课堂的全部高光文件状态 | 否 |
| POST | `/highlight/files` | 根据高光明细批量查询文件状态 | 否 |
| GET | `/highlight/status` | 查询当前进程内的高光队列状态 | 否 |
## 3. 公共约定
### 3.1 高光字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `highlightId` | Integer | 高光唯一 ID,对应 SaaS 高光记录的 `id` |
| `classId` | String | 课堂号,对应 SaaS 高光记录的 `meetingNumber` |
| `siteId` | String | 机构编码,必须位于 `HIGHLIGHTCONFIG.siteIds` |
| `beginTime` | Integer | 高光开始时间,13 位毫秒时间戳 |
| `endTime` | Integer | 高光结束时间,13 位毫秒时间戳 |
`classId`、`siteId` 只允许字母、数字、下划线和短横线,最长 128 个字符。
### 3.2 任务状态
| 状态 | 说明 |
|---|---|
| `not_generated` | 本地、OSS 和当前任务队列均未发现文件 |
| `queued` | 已进入等待队列 |
| `recording` | `web_capture_c` 正在录制 |
| `uploading` | 本地文件已生成,等待服务器搬运到 OSS |
| `generated` | OSS 文件已经存在,可以返回播放地址 |
| `invalid` | 高光字段、站点或时间段校验失败 |
| `failed` | 录制任务执行失败 |
### 3.3 HTTP 状态和错误响应
| HTTP 状态 | `code` | 说明 |
|---|---:|---|
| 200 | 0 或 1 | 请求正常完成;文件查询接口使用 1 表示尚未全部生成 |
| 400 | 10 | 请求参数错误、站点未启用或高光功能未启用 |
| 502 | SaaS 返回码或 -1 | SaaS 高光接口、录制任务接口或 OSS 查询失败 |
| 500 | -1 | WebScreen 内部异常 |
错误示例:
```json
{
"code": 10,
"message": "高光录制功能未启用"
}
```
## 4. 预览课堂高光
```http
POST /highlight/preview/by-class
```
该接口查询 SaaS 高光数据并计算录制地址、文件名和当前文件状态,但不会加入录制队列。
请求:
```json
{
"classId": "2008975651"
}
```
成功响应:
```json
{
"code": 0,
"message": "success",
"data": [
{
"highlightId": 5,
"classId": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000,
"endTime": 1785895343000,
"duration": 45000,
"status": "not_generated",
"fileName": "2008975651_highlight_5.mp4",
"localPath": "/root/web_capture_release/media/xdyui2/20260805/2008975651_highlight_5.mp4",
"ossKey": "oss/xdyui2/20260805/2008975651_highlight_5.mp4",
"url": null,
"playbackUrl": "https://pclive.xuedianyun.com/...&recBeginTime=1785895298000&recEndTime=1785895343000"
}
]
}
```
没有高光时返回 `code=0`、`message=无高光数据`、`data=[]`。
## 5. 预览站点高光
```http
POST /highlight/preview/by-site
```
请求字段:
onlyHighlight 不传或为 0
→ 原整课录制
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `siteId` | String | 是 | 机构编码 |
| `beginTime` | Integer | 否 | 查询开始时间,13 位毫秒时间戳 |
| `endTime` | Integer | 否 | 查询结束时间,13 位毫秒时间戳 |
| `pageSize` | Integer | 否 | 调用 SaaS 时的分页大小,范围 1~1000 |
请求:
```json
{
"siteId": "xdyui2",
"beginTime": 1785859200000,
"endTime": 1785945599999,
"pageSize": 100
}
```
WebScreen 会自动读取全部分页。响应项与 `/highlight/preview/by-class` 相同。
## 6. 按课堂提交高光录制
```http
POST /highlight/recording/by-class
onlyHighlight=1
→ getByClassPrivate.do
→ 每条高光录制一个 MP4
```
请求:
```json
{
"classId": "2008975651"
}
```
成功响应:
```json
{
"code": 0,
"message": "success",
"data": [
{
"highlightId": 5,
"classId": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000,
"endTime": 1785895343000,
"status": "queued"
}
]
}
```
接口返回表示任务已经接收,不表示 MP4 已经生成。调用方应继续查询 `/highlight/fileExists` 或 `/highlight/status`。
重复提交同一 `siteId + highlightId` 时,WebScreen 会检查内存任务、本地文件和 OSS,不会重复录制已经存在的高光。
## 7. 按站点提交高光录制
```http
POST /highlight/recording/by-site
```
请求字段与 `/highlight/preview/by-site` 相同。
请求:
```json
{
"siteId": "xdyui2",
"beginTime": 1785859200000,
"endTime": 1785945599999
}
```
成功响应:
```json
{
"code": 0,
"message": "success",
"data": {
"received": 3,
"queued": 2,
"recording": 1,
"uploading": 0,
"generated": 0,
"invalid": 0,
"failed": 0
}
}
```
这里的状态数量是本次提交结果的快照,不是全局队列统计。
## 8. 运行高光定时任务
```http
POST /highlight/recording/scheduled
```
无请求体。该接口与原整堂录制 `GET /recording` 完全独立。
当 `sourceMode=site` 时:
1. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。
2. 遍历 `HIGHLIGHTCONFIG.siteIds`。
3. 查询并提交所有有效高光。
响应:
```json
{
"code": 0,
"message": "success",
"data": {
"mode": "site",
"beginTime": 1785859200000,
"endTime": 1785945599999,
"received": 3,
"queued": 3,
"recording": 0,
"uploading": 0,
"generated": 0,
"invalid": 0,
"failed": 0
}
}
```
当 `sourceMode=task` 时,只处理同时满足以下条件的 SaaS 录制任务:
```text
status = 0
onlyHighlight = 1
siteId 位于 HIGHLIGHTCONFIG.siteIds
```
高光任务只按课堂调用 `/3m/api/highlight/getByClassPrivate.do`。`getBySitePrivate.do` 仅保留给内部批量排查和补录,不应对每个课堂重复调用两个查询接口。
响应额外包含:
```json
{
"mode": "task",
"sourceTasks": 10,
"pendingTasks": 2
}
```
任务模式目前尚未实现向 SaaS 回写完成状态,不建议在正式环境启用。
建议的独立 cron:
```cron
57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1
```
原整堂录制 cron 保持原样,两者不要使用同一个接口。
## 9. 查询课堂全部高光文件
```http
POST /highlight/fileExists
```
请求:
```json
{
"siteId": "xdyui2",
"classId": "2008975651"
}
```
全部生成时:
```json
{
"code": 0,
"message": "文件已生成",
"onlyHighlight": 1,
"classUrlList": [
{
"highlightId": 5,
"classId": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000,
"generated": true,
"status": "generated",
"url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260805/2008975651_highlight_5.mp4"
}
]
}
```
只要存在未生成项,就返回:
```json
{
"code": 1,
"message": "部分文件未生成",
"onlyHighlight": 1,
"classUrlList": []
}
```
实际 `classUrlList` 仍包含所有已生成和未生成项目;上例省略了项目明细。课堂没有高光时返回 `code=1`、`message=文件未生成`。
## 10. 按明细批量查询高光文件
```http
POST /highlight/files
```
适合调用方已经持有高光 ID 和开始时间、不希望 WebScreen 再按课堂查询 SaaS 的场景。一次最多 1000 条。
请求:
```json
{
"items": [
{
"highlightId": 5,
"classId": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000
}
]
}
```
响应:
```json
{
"code": 0,
"message": "success",
"data": [
{
"highlightId": 5,
"classId": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000,
"generated": false,
"status": "recording",
"url": null
}
]
}
```
## 11. 查询高光队列状态
```http
GET /highlight/status
```
响应:
```json
{
"code": 0,
"message": "success",
"data": {
"queued": 2,
"recording": 1,
"knownTasks": 5
}
}
```
| 字段 | 说明 |
|---|---|
| `queued` | 当前等待录制的任务数 |
| `recording` | 当前正在录制的任务数 |
| `knownTasks` | 当前进程内保留的全部已知任务数 |
队列状态只保存在当前 Node.js 进程内,PM2 重启后会清空;本地文件和 OSS 文件不会丢失。
## 12. 配置
配置文件:`config/config.json`。
```json
"HIGHLIGHTCONFIG": {
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
```
| 字段 | 说明 |
|---|---|
| `enabled` | 高光业务开关;不影响整堂录制,关闭后状态接口仍可访问 |
| `sourceMode` | 定时入口的数据源:`site` 或 `task` |
| `siteIds` | 允许处理的机构编码列表 |
| `pageSize` | SaaS 高光站点查询分页大小 |
| `taskPageSize` | SaaS 录制任务查询分页大小 |
| `maxPages` | 自动分页安全上限 |
| `maxConcurrent` | 高光录制最大并发数 |
| `maxDurationMs` | 单条高光允许的最大时长 |
| `apiTimeoutMs` | SaaS 接口超时时间 |
| `apiRetryCount` | SaaS 接口失败重试次数 |
| `loadGraceMs` | 回放页面加载预留时间 |
| `endGraceMs` | 录制结束预留时间 |
| `taskRetentionMs` | 完成任务在内存中的保留时间 |
| `outputNamespace` | 可选输出隔离目录;正式环境通常留空 |
| `outputBaseUrl` | OSS 对外访问地址 |
OSS 文件查询还需要环境变量:
```text
ALIBABA_CLOUD_ACCESS_KEY_ID
ALIBABA_CLOUD_ACCESS_KEY_SECRET
```
## 13. 文件规则
```text
文件名:{classId}_highlight_{highlightId}.mp4
本地: {PROJECTCATALOG}/media/{siteId}/{yyyyMMdd}/{fileName}
OSS: oss/{siteId}/{yyyyMMdd}/{fileName}
URL: {outputBaseUrl}/oss/{siteId}/{yyyyMMdd}/{fileName}
```
`yyyyMMdd` 按 `beginTime` 对应的 Asia/Shanghai 日期计算。
录制先写入 `{PROJECTCATALOG}/.highlight_tmp`,完成后再移动到正式 `media` 目录。WebScreen 不主动上传 OSS,由服务器已有搬运程序处理,并使用 `download.json` 作为目录完成标记。
## 14. WebScreen 依赖的 SaaS 内部接口
以下接口由 WebScreen 内部调用,不应由普通前端直接调用。
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/3m/api/highlight/getByClassPrivate.do` | 根据课堂号获取全部高光 |
| POST | `/3m/api/highlight/getBySitePrivate.do` | 根据站点和时间范围分页获取高光 |
| POST | `/3m/api/recording/getRecordingTasksPrivate.do` | `sourceMode=task` 时获取录制任务 |
SaaS 高光记录示例:
```json
{
"id": 5,
"meetingNumber": "2008975651",
"siteId": "xdyui2",
"beginTime": 1785895298000,
"endTime": 1785895343000,
"type": 0,
"more": ""
}
```
字段映射:
```text
id → highlightId
meetingNumber → classId
```
更详细的 SaaS 请求签名和响应说明见 [getByClassPrivate.md](./getByClassPrivate.md) 与 [getBySitePrivate.md](./getBySitePrivate.md)。
## 15. 调用顺序建议
单课堂人工验证:
```text
POST /highlight/preview/by-class
→ POST /highlight/recording/by-class
→ GET /highlight/status
→ POST /highlight/fileExists
```
每日自动录制:
```text
cron
→ POST /highlight/recording/scheduled
→ 查询前一天高光
→ 加入高光队列
→ 生成本地 MP4 和 download.json
→ 服务器搬运到 OSS
→ POST /highlight/fileExists 查询结果
```
已有 `/highlight/*` 路由属于内部验证工具,不是正式客户调用链路。
... ...
# WebScreen 高光录制部署文档
# V2 录制部署说明
## 1. 部署范围
## 接口边界
当前仅部署到新验证服务器,只启用 `xdyui2`。
- 原 `/recordingTask`、`/fileExists` 保持不变。
- `/recordingTaskV2` 兼容原整课请求并支持 `onlyHighlight=1`。
- `/fileExistsV2` 是新功能的客户查询接口。
- `/highlight/*` 仅允许运维内网访问。
不要直接替换现有正式录制服务器。CrazyTalk 等 xdyui2 验证通过后再添加。
## 配置
## 2. 环境要求
- Node.js 与现有 WebScreen 生产版本一致。
- `web_capture_c` 可执行文件及依赖完整。
- PCLive 回放地址能从服务器访问。
- 服务器时钟已通过 NTP 同步。
- OSS AccessKey 环境变量已配置,供文件状态查询使用。
- 原本地文件搬运至 OSS 的定时任务已部署。
WebScreen 只监听端口 `3001`。
## 3. 安装
```bash
cd /root/webScreen
npm install
npm test
```
确认录制程序可执行:
```bash
test -x /root/web_capture_release/linux-x64/web_capture_c
```
## 4. 配置
保留服务器原 `GETCLASSURLPARAMETER`、`PROJECTWINCATALOG`、`PROJECTCATALOG` 和 `BACKMEDIACONFIG`,增加:
启用高光前设置:
```json
"HIGHLIGHTCONFIG": {
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
```
验证 JSON:
```bash
node -e "JSON.parse(require('fs').readFileSync('config/config.json')); console.log('config ok')"
```
## 5. 启动
```bash
npm run pm2
pm2 show webScreen
curl http://127.0.0.1:3001/highlight/status
```
还需配置 OSS AccessKey、`web_capture_c`、显示服务及现有 OSS 搬运任务。
预期:
不录制站点已由 xdySDK 上游流程筛除,WebScreen V2 不再维护第二份站点策略。
```json
{"code":0,"message":"success","data":{"queued":0,"recording":0,"knownTasks":0}}
```
## 6. 首次手工验证
先查询某节已知课堂,不启动录制:
```bash
curl -X POST http://127.0.0.1:3001/highlight/preview/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"课堂号"}'
```
检查响应中的:
- `siteId` 必须是 `xdyui2`。
- `playbackUrl` 包含 `recBeginTime` 和 `recEndTime`。
- 文件名包含 `_highlight_高光ID.mp4`。
- `duration` 等于 `endTime-beginTime`。
再手工触发单课堂:
```bash
curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"课堂号"}'
```
观察:
```bash
tail -f log/$(date +%Y%m%d).txt
find /root/web_capture_release/media/xdyui2 -type f
```
## 外部 cron
一个课堂有 N 条高光时,应出现 N 个不同文件。
正式任务链路由现有外部 cron 分发,不使用 WebScreen 本机的
`/highlight/recording/scheduled` 作为正式入口。
## 7. 验证多文件查询
```bash
curl -X POST http://127.0.0.1:3001/highlight/fileExists \
-H 'Content-Type: application/json' \
-d '{"siteId":"xdyui2","classId":"课堂号"}'
```
本地文件等待搬运时,状态应为 `uploading`;OSS 可见后应为 `generated` 并返回 URL。
## 8. 验证 cron 全量模式
先手工执行独立的高光 cron 入口:
```bash
curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled
```
响应 `data.mode` 应为 `site`,时间窗应为 Asia/Shanghai 前一天。
确认无误后配置:
```cron
57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1
```
## 9. 切换任务模式
xdyui2 全量验证完成后,可改为:
```json
"sourceMode": "task"
```
任务模式只处理:
```text
status = 0
onlyHighlight = 1
siteId = xdyui2
```
当前没有任务状态回写接口。重复 cron 依靠队列、本地文件和 OSS 文件跳过,代码中保留 TODO。后端接口确定后再补领取和结果回写。
## 10. OSS 搬运验证
高光录制完成后,本地目录应包含:
cron 继续调用 SaaS 的 `getRecordingTasksPrivate.do`,但需要将目标地址从:
```text
{classId}_highlight_{highlightId}.mp4
download.json
POST /recordingTask
```
等待现有搬运任务执行,再检查:
改为:
```text
https://xdymp4.xuedianyun.com/oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
POST /recordingTaskV2
```
确认搬运程序不会只匹配旧 `{classId}.mp4` 文件名。
## 11. 回滚
最快业务回滚:
原来的字段映射可以继续使用,只需增加 `onlyHighlight`:
```json
"HIGHLIGHTCONFIG": {
"enabled": false
{
"classId": "task.meetingNumber",
"siteId": "task.siteId",
"yymmdd": "原有课堂日期",
"onlyHighlight": "task.onlyHighlight"
}
```
然后:
```bash
pm2 restart webScreen
```
也可以不做映射,直接转发 SaaS 的完整 `taskList`;V2 同时兼容
`classId/meetingNumber`、`taskId/id` 两种字段名。`id` 和
`beginTime/endTime` 不是状态回写的必填字段。
关闭后,高光查询、录制和文件检查接口停止处理,状态接口仍可访问;`GET /recording` 始终运行原整堂录制逻辑。高光使用独立文件名,不覆盖旧整堂 MP4。
## 灰度与回滚
## 12. 上线 CrazyTalk 前检查
1. 用旧格式调用 `/recordingTaskV2`,确认只生成整课文件。
2. 用 `onlyHighlight=1` 调用,确认只生成高光文件。
3. 验证 `/fileExistsV2` 可分别查询两种任务。
4. 确认两个老接口的请求和响应没有变化。
1. 获取 CrazyTalk 准确、区分大小写的 `siteId`。
2. xdyui2 连续验证多天,无重复、缺段和错误路径。
3. 确认 OSS 搬运支持高光文件名。
4. 确认任务状态回写方案。
5. 将 CrazyTalk 加入 `HIGHLIGHTCONFIG.siteIds`,不要改旧 `GETCLASSURLPARAMETER.siteId`。
停用新功能时停止调用两个 V2 接口即可,原接口继续运行。
... ...
# WebScreen 高光录制部署、使用与测试手册
# V2 录制验收测试
## 1. 范围
## 1. 整课兼容
本文用于同事在新 Linux 服务器部署 WebScreen,并只对 `xdyui2` 验证高光 MP4 录制。
向 `/recordingTaskV2` 提交不含 `onlyHighlight` 的原请求,预期只调用原整课录制代码,不调用 SaaS 高光接口。
当前规则:
## 2. 仅高光
- 一个高光时间段生成一个 MP4。
- 一个课堂有 N 条高光,生成 N 个 MP4。
- 全量模式录制 xdyui2 前一天全部高光。
- 任务模式只处理 `status=0 && onlyHighlight=1`。
- WebScreen 只生成本地文件,服务器原任务负责搬运到 OSS。
- 暂不启用 CrazyTalk。
不要直接覆盖现有正式录制服务器。
## 2. 交付物和外部依赖
项目包:
```text
webScreen-full-latest.zip
```
包内含源码、Git 历史、`.env`、`node_modules`、文档和测试。`node_modules` 来自 macOS,Linux 必须重新安装。
项目不包含 `web_capture_c`。需从现有录制服务器复制完整运行目录:
```text
/root/web_capture_release
/root/web_capture_release/linux-x64/web_capture_c
```
同时复制其动态库、字体、浏览器运行环境和显示服务配置。
## 3. 部署前检查
```bash
date
timedatectl
node -v
npm -v
ss -lntp | grep ':3001'
df -h /root
test -x /root/web_capture_release/linux-x64/web_capture_c && echo CAPTURE_OK
```
要求:
- 时区为 `Asia/Shanghai`,NTP 已同步。
- Node.js 与现有正式服务器一致。
- 端口 `3001` 未被占用。
- 录制程序可执行,磁盘空间充足。
## 4. 解压和安装
```bash
cd /root
unzip webScreen-full-latest.zip
cd /root/webScreen
mv node_modules node_modules.macos.bak
npm install
npm test
```
预期测试输出:
```text
highlightRecordingService tests passed
highlight routes tests passed
```
## 5. 环境变量
`.env` 需要包含:
```text
ALIBABA_CLOUD_ACCESS_KEY_ID=...
ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
```
只检查是否存在:
```bash
grep -q '^ALIBABA_CLOUD_ACCESS_KEY_ID=' .env && echo ACCESS_KEY_ID_OK
grep -q '^ALIBABA_CLOUD_ACCESS_KEY_SECRET=' .env && echo ACCESS_KEY_SECRET_OK
```
这两个变量只用于查询 OSS 状态;WebScreen 不主动上传文件。
## 6. 配置
编辑 `/root/webScreen/config/config.json`。保留服务器原有:
- `GETCLASSURL`
- `GETCLASSURLPARAMETER`
- `PROJECTWINCATALOG`
- `PROJECTCATALOG`
- `BACKMEDIACONFIG`
- `classLastNumber`
目录应与服务器一致:
```json
"PROJECTWINCATALOG": "/root/web_capture_release/linux-x64",
"PROJECTCATALOG": "/root/web_capture_release"
```
`BACKMEDIACONFIG.url` 必须指向已支持 `recBeginTime/recEndTime` 的 PCLive 测试版本。保留验证服务器已确认可用的 `devback` 或测试路径,不在代码中硬编码。
高光配置:
```json
"HIGHLIGHTCONFIG": {
"enabled": false,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
```
首次启动保持 `enabled=false`。验证 JSON:
```bash
node -e "JSON.parse(require('fs').readFileSync('config/config.json')); console.log('config ok')"
```
## 7. 启动服务
测试期间先不要配置 cron。
```bash
npm run pm2
pm2 show webScreen
curl http://127.0.0.1:3001/highlight/status
```
若 PM2 已有同名进程:
```bash
pm2 restart webScreen
```
状态接口预期:
提交:
```json
{
"code": 0,
"message": "success",
"data": {
"queued": 0,
"recording": 0,
"knownTasks": 0
}
"list": [{
"id": "task-high-1",
"siteId": "doctest",
"meetingNumber": "487012832",
"beginTime": "2026-08-05 10:00:00",
"endTime": "2026-08-05 11:00:00",
"onlyHighlight": 1
}]
}
```
查看日志:
```bash
pm2 logs webScreen --lines 100
```
## 8. 开启 xdyui2
基础服务正常后修改:
```json
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"]
```
```bash
pm2 restart webScreen
```
## 9. 准备测试课堂
选择一个 xdyui2 已结束课堂:
- 至少两条高光。
- 回放正常。
- 最好含教师、学生音视频、屏幕共享和声音。
记录:
```text
siteId:xdyui2
classId:__________
高光数量:__________
课堂日期:__________
```
## 10. 只读预览
预览不会启动录制程序:
```bash
curl -X POST http://127.0.0.1:3001/highlight/preview/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"替换为课堂号"}'
```
逐条检查:
- `siteId` 是 `xdyui2`。
- `highlightId` 不重复。
- `classId` 正确。
- `beginTime/endTime` 是 13 位毫秒时间戳。
- `duration = endTime - beginTime`。
- `playbackUrl` 含正确的 `recBeginTime/recEndTime`。
- 文件名为 `{classId}_highlight_{highlightId}.mp4`。
- 路径位于 `/root/web_capture_release/media/xdyui2/{yyyyMMdd}/`。
返回“无高光数据”时,先让后端确认高光表确实存在记录。
## 11. 单课堂录制
```bash
curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"替换为课堂号"}'
```
新任务初始状态应为 `queued`;已有文件可能返回 `uploading` 或 `generated`。
观察队列和进程:
验证:
```bash
watch -n 2 'curl -s http://127.0.0.1:3001/highlight/status'
tail -f /root/webScreen/log/$(date +%Y%m%d).txt
ps -ef | grep '[w]eb_capture_c'
```
响应返回后不能立即停止 PM2;录制在后台队列继续执行。
- 只调用一次 `getByClassPrivate.do`。
- N 条有效高光生成 N 个文件,不生成整课文件。
- 时间范围外或站点、课堂不匹配的数据被忽略。
- 正常空数组计入 `noMedia` 并回写 `status=3`。
- 有高光时在全部本地录制完成后回写 `status=2`,返回 `code=0` 即成功。
- 接口超时或错误不能回写 `status=3`。
## 12. 本地文件验证
## 3. 查询
```bash
find /root/web_capture_release/media/xdyui2 -type f -name '课堂号_highlight_*.mp4' -ls
```
要求:
- N 条高光生成 N 个文件。
- 文件名中的 `highlightId` 不同。
- 文件大小大于 0。
- 不覆盖旧 `{classId}.mp4`。
- 日期目录包含 `download.json`。
- `.highlight_tmp` 无本次任务残留。
如有 ffprobe:
```bash
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 /完整/文件路径.mp4
```
- `/fileExistsV2` 不触发录制或 SaaS 查询。
- 整课任务兼容返回 `classUrl`。
- 高光任务返回全部高光 URL。
- 任一目标文件缺失时 `fileExists=false`。
人工播放检查:
## 4. 隔离
- 开始位置接近 `beginTime`。
- 结束位置接近 `endTime`。
- 教师、学生、屏幕共享画面正常。
- 声音正常。
- 文件不是整堂课堂。
## 13. 重复录制验证
再次调用同一课堂的 `/highlight/recording/by-class`。
要求:
- 不再启动新的 `web_capture_c`。
- 文件数量不增加。
- 原文件不被覆盖。
- OSS 已有文件时也不重复录制。
## 14. OSS 搬运和多地址查询
等待服务器原搬运任务执行。预期 OSS Key:
```text
oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
```
查询:
```bash
curl -X POST http://127.0.0.1:3001/highlight/fileExists \
-H 'Content-Type: application/json' \
-d '{"siteId":"xdyui2","classId":"替换为课堂号"}'
```
OSS 尚不可见时:
```text
status = uploading
generated = false
```
OSS 可见后:
```text
status = generated
generated = true
url = https://xdymp4.xuedianyun.com/oss/...
```
N 条高光时,`classUrlList` 必须有 N 条记录。
## 15. 前一天全量验证
保持:
```json
"sourceMode": "site",
"siteIds": ["xdyui2"]
```
手工调用 cron 入口:
```bash
curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled
```
检查:
- `code` 为 `"0"`。
- `data.mode` 为 `site`。
- `beginTime/endTime` 是 Asia/Shanghai 前一天。
- 只查询 xdyui2。
- 前一天每条有效高光都进入队列。
## 16. 任务模式验证
全量模式通过后改为:
```json
"sourceMode": "task"
```
```bash
pm2 restart webScreen
```
请后端创建四组任务:
1. `status=0, onlyHighlight=1, siteId=xdyui2`:应录制。
2. `status!=0, onlyHighlight=1`:不录制。
3. `status=0, onlyHighlight=0`:不进入高光录制。
4. 其他站点 `status=0, onlyHighlight=1`:不录制。
```bash
curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled
```
检查:
- `data.mode` 为 `task`。
- `pendingTasks` 只统计第一类任务。
- 使用 `taskList.meetingNumber` 查询课堂高光。
后端尚未确认任务状态回写接口。当前依靠内存队列、本地文件和 OSS 文件避免重复录制。
## 17. 启用每日 cron
只有单课堂、重复录制、OSS 搬运和全量模式全部通过后才启用:
```cron
57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1
```
```bash
crontab -l
```
任务每天 `07:57` 执行。任务模式中新任务最多等待约 24 小时,业务已确认可接受。
## 18. 日常使用
```bash
pm2 show webScreen
curl http://127.0.0.1:3001/highlight/status
pm2 logs webScreen --lines 200
tail -n 200 /root/webScreen/log/$(date +%Y%m%d).txt
```
手工补录:
```bash
curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"课堂号"}'
```
查询地址:
```bash
curl -X POST http://127.0.0.1:3001/highlight/fileExists \
-H 'Content-Type: application/json' \
-d '{"siteId":"xdyui2","classId":"课堂号"}'
```
## 19. 故障排查
### 高光录制功能未启用
检查 `HIGHLIGHTCONFIG.enabled=true`,修改后执行 `pm2 restart webScreen`。
### SaaS 接口 code=4
检查服务器时间、NTP、`apiBaseUrl` 和最新代码。签名时间戳必须是 13 位毫秒。
### 无高光数据
检查高光表记录、`meetingNumber`、`siteId=xdyui2`,以及全量模式时间窗是否为前一天。
### web_capture_c 启动失败
```bash
ls -l /root/web_capture_release/linux-x64/web_capture_c
ldd /root/web_capture_release/linux-x64/web_capture_c
echo "$DISPLAY"
```
比较正式服务器的显示服务、字体、浏览器依赖和 PM2 环境变量。
### 一直是 uploading
检查搬运任务是否运行,是否支持 `{classId}_highlight_{highlightId}.mp4`、`download.json` 和 `oss/xdyui2/{yyyyMMdd}/`。
### OSS文件状态查询失败
检查 `.env`、AccessKey 权限、OSS 网络和 bucket `xdymp4`。OSS 查询异常不会被当作“文件不存在”。
### 视频时间错误
确认 `playbackUrl` 的 `recBeginTime/recEndTime` 与接口原值完全一致。不能传 duration,不能换算相对秒数。
## 20. 回滚
优先使用配置回滚:
```json
"HIGHLIGHTCONFIG": {
"enabled": false
}
```
```bash
pm2 restart webScreen
```
关闭后,高光查询、录制和文件检查接口停止处理,状态接口仍可访问;`GET /recording` 始终执行原整堂录制逻辑。`POST /recording`、`POST /recordingTask`、`POST /fileExists` 和实时录制接口不变。
## 21. 测试记录模板
```text
测试服务器:
测试日期:
测试人员:
Git 提交(执行 git rev-parse --short HEAD):
站点:xdyui2
课堂号:
高光记录数:
本地 MP4 数量:
OSS MP4 数量:
预览接口:通过 / 失败
单课堂录制:通过 / 失败
时间范围:通过 / 失败
音频:通过 / 失败
教师视频:通过 / 失败
学生视频:通过 / 失败
屏幕共享:通过 / 失败
重复录制:通过 / 失败
OSS 搬运:通过 / 失败
多地址查询:通过 / 失败
前一天全量:通过 / 失败
任务模式:通过 / 失败 / 未测试
问题记录:
结论:可继续验证 / 需要修复
```
- `/recordingTask` 不进入 V2 服务。
- `/fileExists` 不进入 V2 服务。
- V2 停用后原整课录制与查询继续工作。
... ...
# WebScreen 高光时刻 MP4 录制 Spec
# 高光 MP4 内部约定
## 1. 目标
- 一个 SaaS 高光记录生成一个独立 MP4。
- 文件名为 `{classId}_highlight_{highlightId}.mp4`。
- 回放地址携带绝对毫秒时间戳 `recBeginTime/recEndTime`。
- 录制进程使用参数数组和 `spawn(..., {shell:false})`,不拼接未校验的 shell 输入。
- 临时文件完成并校验非空后,原子移动到正式 `media` 目录。
- 相同 `siteId + highlightId` 不重复进入队列。
- 本地文件或 OSS 对象已存在时不重复录制。
- SaaS 高光接口失败不能当作“没有高光”。
- V2 客户查询只读取任务创建时保存的高光清单。
WebScreen 根据 SaaS 高光记录,把每个 `beginTime/endTime` 时间段录制成独立 MP4。
已确认:
- 一个课堂可以有多条高光。
- 一个高光时间段生成一个 MP4;N 条高光生成 N 个文件。
- 当前只在 `xdyui2` 测试,验证后再增加 CrazyTalk。
- WebScreen 只生成本地文件;服务器已有任务定时移动到 OSS。
- 高光定时任务使用独立的 `POST /highlight/recording/scheduled`,不占用整堂录制入口。
- 任务模式暂时只处理 `status=0`;后端状态回写规则待确认。
## 2. 兼容原则
原 `GET /recording` 始终执行整堂录制逻辑,不读取高光开关。
```json
{
"HIGHLIGHTCONFIG": {
"enabled": false
}
}
```
只有显式配置 `enabled: true` 才能调用 `/highlight/*` 高光业务接口。关闭高光不会关闭或改变整堂录制。
以下接口保持原行为:
- `GET /recording`
- `POST /recording`
- `POST /recordingTask`
- `POST /fileExists`
- `POST /mp4record/recording/:id`
高光文件查询使用独立接口 `POST /highlight/fileExists`,不改变原 `/fileExists` 单文件响应。
## 3. 定时触发
WebScreen 本身不创建 cron。高光需要独立 cron:
```cron
57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1
```
`POST /highlight/recording/scheduled` 读取 `HIGHLIGHTCONFIG.sourceMode`:
- `site`:全量高光模式。
- `task`:指定课堂任务模式。
这里“全量”表示录制指定站点前一天的全部高光,不表示录制整堂课堂。
## 4. 配置
当前 xdyui2 测试配置:
```json
{
"HIGHLIGHTCONFIG": {
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
}
```
`BACKMEDIACONFIG.url` 继续由服务器配置决定,代码不硬编码 `dev`、`devback` 或 `release`。
## 5. 全量高光模式
配置:
```json
"sourceMode": "site"
```
流程:
1. cron 调用 `POST /highlight/recording/scheduled`。
2. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。
3. 遍历 `HIGHLIGHTCONFIG.siteIds`。
4. 分页调用 `getBySitePrivate.do`。
5. 每条有效高光加入录制队列。
6. 一个时间段生成一个本地 MP4。
接口:
```http
POST /3m/api/highlight/getBySitePrivate.do
Content-Type: application/x-www-form-urlencoded
```
签名:
```text
authId = MD5(siteId + timestamp)
```
## 6. 指定课堂任务模式
配置:
```json
"sourceMode": "task"
```
流程:
1. 分页调用 `getRecordingTasksPrivate.do`。
2. 只保留 `status=0 && onlyHighlight=1`。
3. 只保留 `siteIds` 白名单内的任务。
4. `taskList.meetingNumber` 作为 `classId`。
5. 调用 `getByClassPrivate.do` 获取该课堂全部高光。
6. 每条高光加入录制队列。
任务接口签名:
```text
authId = MD5(pageNo + pageSize + timestamp)
```
课堂高光接口签名:
```text
authId = MD5(classId + timestamp)
```
代码必须保留:
```js
// TODO: 等后端明确录制任务状态流转及完成回写接口。
```
任务状态回写接口确定前,使用内存队列、本地文件和 OSS 文件共同避免重复录制。
## 7. 字段映射
SaaS 高光记录:
```json
{
"id": 4,
"meetingNumber": "1486758620",
"siteId": "xdyui2",
"beginTime": 1779159793000,
"endTime": 1779159893000
}
```
WebScreen 内部统一为:
```text
highlightId = id
classId = meetingNumber
```
校验:
- `id` 为正整数。
- `meetingNumber`、`siteId` 只包含安全字符。
- 时间为 13 位毫秒时间戳。
- `endTime > beginTime`。
- 时长不超过 `maxDurationMs`。
- 站点必须属于 `HIGHLIGHTCONFIG.siteIds`。
唯一键:
```text
siteId:highlightId
```
## 8. 回放录制地址
使用 `URLSearchParams` 在现有 `BACKMEDIACONFIG.url` 上增加:
```text
classId={meetingNumber}
recordMp4=true
playRecord=1
recBeginTime={beginTime}
recEndTime={endTime}
```
`recBeginTime` 和 `recEndTime` 都是绝对毫秒时间戳,不换算为相对秒数。
调用 `web_capture_c` 时使用参数数组和 `spawn(..., { shell: false })`,不把接口字段拼接进 shell 命令。
## 9. 文件与上传
文件名:
```text
{classId}_highlight_{highlightId}.mp4
```
路径:
文件位置:
```text
本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
OSS: oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
URL: https://xdymp4.xuedianyun.com/oss/{siteId}/{yyyyMMdd}/{fileName}
```
日期按高光 `beginTime` 的 Asia/Shanghai 日期计算。
录制过程先写入 `PROJECTCATALOG/.highlight_tmp`,完成后原子移动到 `media`,避免 OSS 搬运程序读取半成品。队列完成后在涉及的日期目录写入 `download.json`,兼容原搬运机制。
WebScreen 不主动上传 OSS。
## 10. 队列和去重
- 全局并发由 `maxConcurrent` 控制。
- 同一课堂高光串行,避免同时加载同一课堂回放。
- 不同课堂可以并行。
- 队列中已有相同 `siteId:highlightId` 时不重复加入。
- 本地最终文件存在时不重复录制。
- OSS 文件存在时不重复录制。
- OSS 查询异常时停止本轮处理,不能把查询失败当作文件不存在。
## 11. 高光多文件查询
```http
POST /highlight/fileExists
Content-Type: application/json
OSS:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
```
请求:
```json
{
"siteId": "xdyui2",
"classId": "1486758620"
}
```
WebScreen 调用 `getByClassPrivate.do` 获取该课堂全部高光,再逐条检查本地任务状态和 OSS。
响应:
```json
{
"code": 0,
"message": "文件已生成",
"onlyHighlight": 1,
"classUrlList": [
{
"highlightId": 4,
"classId": "1486758620",
"siteId": "xdyui2",
"beginTime": 1779159793000,
"generated": true,
"status": "generated",
"url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260519/1486758620_highlight_4.mp4"
}
]
}
```
全部生成时 `code=0`;无文件或存在未生成文件时 `code=1`,但仍返回每条记录状态。
## 12. 验收标准
1. `xdyui2` 一个课堂有 N 条高光时生成 N 个不同 MP4。
2. 每个录制 URL 包含正确的 `recBeginTime/recEndTime`。
3. 重复执行 cron 不重复录制本地或 OSS 已存在文件。
4. `sourceMode=site` 只查询前一天 xdyui2 高光。
5. `sourceMode=task` 只处理 `status=0 && onlyHighlight=1`。
6. `/highlight/fileExists` 返回课堂全部高光文件状态和地址。
7. 原 `/fileExists` 响应不变。
8. 删除或关闭 `HIGHLIGHTCONFIG` 后,高光接口拒绝处理,原 `GET /recording` 仍执行整堂录制。
## 13. 已知待办
- 后端确认任务领取、成功、失败状态及回写接口。
- xdyui2 验证通过后再增加 CrazyTalk 的准确 `siteId`。
- 正式部署前确认服务器 OSS 搬运任务会处理新文件名和 `download.json`。
... ...
# WebScreen 文档目录
## 高光录制
- [V2 录制任务与文件查询接口](./RECORDING_API.md)
- [获取录制文件 V2(标准对外接口文档)](./FILE_EXISTS_V2.md)
- [高光录制内部说明](./HIGHLIGHT_API.md)
- [高光 MP4 约定](./HIGHLIGHT_MP4_SPEC.md)
- [部署说明](./HIGHLIGHT_DEPLOYMENT.md)
- [部署与验收测试](./HIGHLIGHT_DEPLOYMENT_USAGE_TEST.md)
- [高光录制接口文档](./HIGHLIGHT_API.md)
- [高光 MP4 录制 Spec](./HIGHLIGHT_MP4_SPEC.md)
- [高光录制部署文档](./HIGHLIGHT_DEPLOYMENT.md)
- [高光录制部署、使用与测试手册](./HIGHLIGHT_DEPLOYMENT_USAGE_TEST.md)
SaaS 已有接口:
## SaaS 内部接口
- `/3m/api/recording/getRecordingTasksPrivate.do`
- `/3m/api/recording/updateRecordingTask.do`
- `/3m/api/highlight/getByClassPrivate.do`
- [根据课堂获取高光](./getByClassPrivate.md)
- [根据站点获取高光](./getBySitePrivate.md)
接口契约以 3m 仓库及 SaaS 提供的对接文档为准。
... ...
# WebScreen V2 录制接口
## 1. 范围
原接口保持原实现不变:
```http
POST /recordingTask
POST /fileExists
```
新增:
```http
POST /recordingTaskV2
POST /fileExistsV2
```
V2 兼容原整课录制格式,并通过 SaaS 字段 `onlyHighlight=1` 支持仅录高光。不支持也不需要“整课和高光同时录制”。不录制站点已经由 xdySDK 上游流程筛除。
| `onlyHighlight` | 行为 |
|---:|---|
| 不传或 `0` | 复用原整课录制实现 |
| `1` | 仅录制该课堂的高光时刻 |
## 2. 创建 V2 录制任务
```http
POST /recordingTaskV2
Content-Type: application/json
```
### 2.1 兼容原整课请求
```json
{
"list": [
{
"siteId": "doctest",
"classId": "487012832",
"yymmdd": "20260805"
}
],
"maxMedia": 1
}
```
未传 `onlyHighlight` 时,V2 调用现有整课录制代码,文件仍为 `{classId}.mp4`。
### 2.2 仅高光请求
V2 可直接接受 SaaS `getRecordingTasksPrivate.do` 返回项的字段形式:
```json
{
"list": [
{
"id": "ff808081956a495901956a498d0f0001",
"siteId": "doctest",
"meetingNumber": "487012832",
"beginTime": "2026-08-05 10:00:00",
"endTime": "2026-08-05 11:00:00",
"status": 0,
"onlyHighlight": 1
}
]
}
```
字段兼容关系:
| V2 字段 | 兼容字段 | 说明 |
|---|---|---|
| `classId` | `meetingNumber` | 课堂号,二者任选其一 |
| `taskId` | `id` | 可选;SaaS 任务 ID,用于辅助追踪和幂等 |
| `yymmdd` | `classStartTime` | 原整课目录日期,格式 `yyyyMMdd` |
| `beginTime/endTime` | — | 可选;用于精确限定课堂高光时间范围,接受 13 位毫秒时间戳或 SaaS 时间格式 |
| `onlyHighlight` | — | 只有值 `1` 表示仅高光,其他值按整课兼容 |
如果现有 cron 已经把 SaaS 任务映射为 `classId/siteId/yymmdd`,只需在原映射中
增加 `onlyHighlight`。高光状态回写使用 `siteId + classId`,不依赖 SaaS 任务 `id`。
仅高光流程:
1. 调用 `/3m/api/highlight/getByClassPrivate.do` 获取课堂高光。
2. 只保留站点、课堂一致且位于课堂时间范围内的数据。
3. 每条高光生成一个 MP4。
4. 全部高光本地录制完成后向 SaaS 回写 `status=2`;返回 `code=0` 即表示更新成功。
5. SaaS 正常返回空高光数组时回写 `status=3`;接口失败不能当作无高光。
`getBySitePrivate.do` 不属于单课堂任务主流程,只用于批量排查或补录。
### 2.3 响应
```json
{
"code": "0",
"message": "success",
"accepted": 1,
"duplicates": 0,
"noMedia": 0,
"v": "v1.2.0.20251208"
}
```
| 字段 | 说明 |
|---|---|
| `accepted` | 本次接收的整课或高光任务数 |
| `duplicates` | 已处理或正在执行的重复任务数 |
| `noMedia` | `onlyHighlight=1` 但 SaaS 正常返回零条高光的任务数 |
## 3. 查询 V2 录制文件
```http
POST /fileExistsV2
Content-Type: application/json
```
请求:
```json
{
"siteId": "doctest",
"classId": "487012832"
}
```
也兼容原整课查询日期:
```json
{
"siteId": "doctest",
"classId": "487012832",
"classStartTime": "20260805"
}
```
V2 根据 `/recordingTaskV2` 保存的任务快照判断文件类型,客户不需要再次传 `onlyHighlight`。
整课成功响应:
```json
{
"code": 0,
"message": "文件已生成",
"fileExists": true,
"onlyHighlight": 0,
"classUrl": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4",
"files": [
{
"type": "full",
"url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4"
}
]
}
```
高光成功响应:
```json
{
"code": 0,
"message": "文件已生成",
"fileExists": true,
"onlyHighlight": 1,
"files": [
{
"type": "highlight",
"highlightId": 5,
"url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_5.mp4"
}
]
}
```
任一目标文件尚未生成时:
```json
{
"code": 1,
"message": "文件未生成",
"fileExists": false,
"files": []
}
```
查询接口只检查明确的 OSS Key,不触发录制,也不重新调用 SaaS 高光接口。
## 4. 文件规则
```text
整课:oss/{siteId}/{yyyyMMdd}/{classId}.mp4
高光:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
```
高光日期按每条高光 `beginTime` 的 Asia/Shanghai 日期计算。
... ...
# 获取课堂内精彩时刻
内部接口,获取课堂内精彩时刻。
**`POST`**
```/3m/api/highlight/getByClassPrivate.do```
## 接口参数
|字段|类型|必选|描述|
|-------|-------|--------|--------|
|classId|String|是|课堂ID|
|timestamp|String|是|时间戳|
|authId|String|是|MD5(classId+timestamp)|
## 请求示例
```http
POST /3m/api/highlight/getByClassPrivate.do HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB
Content-Length: 82
classId=1486758620&authId=a66749831542e5e090e41d72f455918d&timestamp=1784628401782
```
## 返回结果
Success 200
|字段|类型|描述|
|----|----|----|
|code|int|0.正常 <br>1.classId不存在 <br>4.authId错误 <br>10.报文格式错误|
## 返回示例
```json
{
"code": 0,
"data": [
{
"meetingNumber": "1486758620",
"more": "ext",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 4,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "xuid"
},
{
"meetingNumber": "1486758620",
"more": "test",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 5,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "sss"
}
]
}
```
# 获取站点内精彩时刻
内部接口,获取站点内精彩时刻。
**`POST`**
```/3m/api/highlight/getBySitePrivate.do```
## 接口参数
|字段|类型|必选|描述|
|-------|-------|--------|--------|
|siteId|String|是|站点ID|
|pageNo|Integer|是|页码|
|pageSize|Integer|是|每页记录数|
|beginTime|Timestamp|否|开始时间:13为时间戳|
|beginTime|Timestamp|否|结束时间:13为时间戳|
|pageSize|Integer|是|每页记录数|
|timestamp|String|是|时间戳|
|authId|String|是|MD5(siteId+timestamp)|
## 请求示例
```http
POST /3m/api/highlight/getBySitePrivate.do HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB
Content-Length: 145
siteId=doctest&pageNo=1&pageSize=10&beginTime=1779159593000&endTime=1779159993000&authId=9ead85fffcaab547ba68edf0ea2b0170&timestamp=1784628642310
```
## 返回结果
Success 200
|字段|类型|描述|
|----|----|----|
|code|int|0.正常 <br>1.站点错误或已过期 <br>2.分页参数错误 <br>4.authId错误 <br>10.报文格式错误|
## 返回示例
```json
{
"code": 0,
"data": [
{
"meetingNumber": "1486758620",
"more": "test",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 5,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "sss"
},
{
"meetingNumber": "1486758620",
"more": "ext",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 4,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "xuid"
}
],
"pageNo": 1,
"count": 2,
"pageSize": 10
}
```
... ... @@ -5,7 +5,7 @@
"scripts": {
"start": "node ./bin/www",
"pm2": "pm2 start ./bin/www --name webScreen",
"test": "node ./test/highlightRecordingService.test.js && node ./test/highlightRoutes.test.js"
"test": "node ./test/highlightRecordingService.test.js && node ./test/highlightRoutes.test.js && node ./test/recordingTaskService.test.js && node ./test/recordingRoutes.test.js"
},
"dependencies": {
"ali-oss": "^6.22.0",
... ...
... ... @@ -8,6 +8,12 @@ require('dotenv').config(); // 加载环境变量
const method = require("../config/method")
const config = require("../config/config")
const {
HighlightUpstreamError,
HighlightValidationError,
RecordingTaskValidationError,
recordingTaskService
} = require('../services/recordingTaskService');
const version ='v1.2.0.20251208';
// const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config
const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method
... ... @@ -354,6 +360,29 @@ router.post('/fileExists', async (req, res) => {
res.status(500).send({ code: -1, message: "服务器内部错误", error: err.message });
}
});
router.post('/fileExistsV2', async (req, res) => {
try {
const result = await recordingTaskService.getFileResult(req.body || {});
return res.send(result);
} catch (err) {
if (err instanceof RecordingTaskValidationError) {
return res.status(400).send({
code: err.apiCode,
message: err.message,
fileExists: false,
files: []
});
}
console.error('Error checking file existence:', err);
return res.status(500).send({
code: -1,
message: "服务器内部错误",
fileExists: false,
files: []
});
}
});
router.post('/recordingTask', async function (req, res, next) {
new MediaCreat().wrieLog("录制启动:------>")
let fileConfig = new MediaCreat().getConfigFileJson()
... ... @@ -381,4 +410,35 @@ router.post('/recordingTask', async function (req, res, next) {
res.send({ code: "0",message:"success",v:version });
})
router.post('/recordingTaskV2', async function (req, res, next) {
try {
const result = await recordingTaskService.acceptTasks(req.body || {}, {
recordFullClass: task => {
new MediaCreat().recordingCreat(
task.classId,
task.siteId,
'post',
task.classStartTime || task.classDate
);
}
});
return res.send({
code: "0",
message: "success",
accepted: result.accepted,
duplicates: result.duplicates,
noMedia: result.noMedia,
v: version
});
} catch (error) {
if (error instanceof RecordingTaskValidationError || error instanceof HighlightValidationError) {
return res.status(400).send({ code: "1", message: error.message, data: [], v: version });
}
if (error instanceof HighlightUpstreamError) {
return res.status(502).send({ code: String(error.upstreamCode), message: error.message, data: [], v: version });
}
return next(error);
}
})
module.exports = router
... ...
... ... @@ -9,11 +9,9 @@ require('dotenv').config();
const DEFAULT_CONFIG = {
enabled: false,
sourceMode: 'site',
siteIds: [],
apiBaseUrl: 'https://saas.xuedianyun.com',
pageSize: 100,
taskPageSize: 100,
maxPages: 1000,
maxConcurrent: 2,
maxDurationMs: 6 * 60 * 60 * 1000,
... ... @@ -187,8 +185,10 @@ class HighlightRecordingService {
constructor(options) {
const opts = options || {};
this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json');
this.completionMarkersEnabled = opts.completionMarkersEnabled !== false;
this.queue = [];
this.tasks = new Map();
this.taskWaiters = new Map();
this.activeClassIds = new Set();
this.activeCount = 0;
this.scheduledRunActive = false;
... ... @@ -310,67 +310,6 @@ class HighlightRecordingService {
return result.data;
}
async fetchRecordingTasks() {
this.ensureFeatureEnabled();
const config = this.readConfig().highlight;
const pageSize = Number(config.taskPageSize || config.pageSize);
if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 1000) {
throw new HighlightValidationError('taskPageSize 无效');
}
const records = [];
let pageNo = 1;
while (pageNo <= config.maxPages) {
const url = new URL(
'/3m/api/recording/getRecordingTasksPrivate.do',
`${String(config.apiBaseUrl).replace(/\/$/, '')}/`
).toString();
let response;
for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) {
const timestamp = String(Date.now());
const body = {
pageNo,
pageSize,
timestamp,
authId: md5(`${pageNo}${pageSize}${timestamp}`)
};
try {
response = await axios.post(url, querystring.stringify(body), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: config.apiTimeoutMs
});
break;
} catch (error) {
const status = error && error.response && error.response.status;
const retryable = !status || status >= 500;
if (!retryable || attempt >= config.apiRetryCount) {
throw new HighlightUpstreamError(-1, `录制任务接口请求失败${status ? ` HTTP ${status}` : ''}`);
}
await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt));
}
}
const result = response.data || {};
if (Number(result.code) !== 0) {
throw new HighlightUpstreamError(Number(result.code), `录制任务接口返回错误 code=${result.code}`);
}
if (!Array.isArray(result.taskList)) {
throw new HighlightUpstreamError(10, '录制任务接口 taskList 不是数组');
}
records.push(...result.taskList);
const taskCount = Number(result.taskCount);
if (result.taskList.length < pageSize || (Number.isFinite(taskCount) && pageNo * pageSize >= taskCount)) {
break;
}
pageNo += 1;
}
if (pageNo > config.maxPages) {
throw new HighlightUpstreamError(2, '录制任务接口分页超过安全上限');
}
return records;
}
async fetchBySite(params) {
this.ensureFeatureEnabled();
const input = params || {};
... ... @@ -533,16 +472,17 @@ class HighlightRecordingService {
return output;
}
async enqueue(records) {
async enqueue(records, options) {
this.cleanupTasks();
const config = this.readConfig();
const allowedSiteIds = new Set(this.getAllowedSiteIds());
const skipSiteCheck = Boolean(options && options.skipSiteCheck);
const output = [];
for (const raw of records || []) {
let item;
try {
item = normalizeHighlight(raw, config.highlight.maxDurationMs);
if (!allowedSiteIds.has(item.siteId)) {
if (!skipSiteCheck && !allowedSiteIds.has(item.siteId)) {
throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
}
} catch (error) {
... ... @@ -612,6 +552,7 @@ class HighlightRecordingService {
task.updatedAt = Date.now();
this.writeLog(`失败 key=${task.key} error=${task.error}`);
}).finally(() => {
this.resolveTaskWaiters(task);
this.activeCount -= 1;
this.activeClassIds.delete(activeClassKey);
this.writeCompletionMarkersIfIdle();
... ... @@ -719,6 +660,7 @@ class HighlightRecordingService {
}
writeCompletionMarkersIfIdle() {
if (!this.completionMarkersEnabled) return;
if (this.activeCount !== 0 || this.queue.length !== 0 || this.completedDirs.size === 0) return;
for (const localDir of this.completedDirs) {
try {
... ... @@ -730,6 +672,27 @@ class HighlightRecordingService {
this.completedDirs.clear();
}
resolveTaskWaiters(task) {
const waiters = this.taskWaiters.get(task.key) || [];
this.taskWaiters.delete(task.key);
for (const resolve of waiters) resolve(this.snapshot(task));
}
waitForTaskKeys(keys) {
const terminal = new Set(['uploading', 'generated', 'failed']);
return Promise.all((keys || []).map(key => {
const task = this.tasks.get(key);
if (!task || terminal.has(task.status)) {
return Promise.resolve(task ? this.snapshot(task) : { key, status: 'not_found' });
}
return new Promise(resolve => {
const waiters = this.taskWaiters.get(key) || [];
waiters.push(resolve);
this.taskWaiters.set(key, waiters);
});
}));
}
summarizeTasks(tasks) {
const summary = {
received: tasks.length,
... ... @@ -755,51 +718,26 @@ class HighlightRecordingService {
}
this.scheduledRunActive = true;
try {
const config = this.readConfig().highlight;
const mode = String(config.sourceMode || 'site');
const siteIds = this.getAllowedSiteIds();
if (siteIds.length === 0) {
throw new HighlightValidationError('未配置高光站点');
}
if (mode === 'site') {
const range = getPreviousShanghaiDayRange(now);
const records = [];
for (const siteId of siteIds) {
const siteRecords = await this.fetchBySite({
siteId,
beginTime: range.beginTime,
endTime: range.endTime
});
records.push(...siteRecords);
}
const tasks = await this.enqueue(records);
return Object.assign({ mode, beginTime: range.beginTime, endTime: range.endTime }, this.summarizeTasks(tasks));
}
if (mode === 'task') {
const rawTasks = await this.fetchRecordingTasks();
// TODO: 等后端明确录制任务状态流转及完成回写接口。
const pendingTasks = rawTasks.filter(task =>
Number(task && task.status) === 0 &&
Number(task && task.onlyHighlight) === 1 &&
siteIds.includes(String(task && task.siteId || ''))
);
const classKeys = new Set();
const records = [];
for (const task of pendingTasks) {
const classId = String(task.meetingNumber || task.classId || '');
const classKey = `${task.siteId}:${classId}`;
if (!isSafeIdentifier(classId) || classKeys.has(classKey)) continue;
classKeys.add(classKey);
const classRecords = await this.fetchByClass(classId);
records.push(...classRecords.filter(record => String(record.siteId || '') === String(task.siteId)));
}
const tasks = await this.enqueue(records);
return Object.assign({ mode, sourceTasks: rawTasks.length, pendingTasks: pendingTasks.length }, this.summarizeTasks(tasks));
const range = getPreviousShanghaiDayRange(now);
const records = [];
for (const siteId of siteIds) {
const siteRecords = await this.fetchBySite({
siteId,
beginTime: range.beginTime,
endTime: range.endTime
});
records.push(...siteRecords);
}
throw new HighlightValidationError('HIGHLIGHTCONFIG.sourceMode 只能是 site 或 task');
const tasks = await this.enqueue(records);
return Object.assign({
mode: 'site',
beginTime: range.beginTime,
endTime: range.endTime
}, this.summarizeTasks(tasks));
} finally {
this.scheduledRunActive = false;
}
... ...
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const querystring = require('querystring');
const axios = require('axios');
const OSS = require('ali-oss');
const {
HighlightRecordingService,
HighlightUpstreamError,
HighlightValidationError,
formatShanghaiDate,
normalizeHighlight
} = require('./highlightRecordingService');
class RecordingTaskValidationError extends Error {
constructor(message, apiCode) {
super(message);
this.name = 'RecordingTaskValidationError';
this.apiCode = apiCode == null ? 2 : apiCode;
}
}
function isSafeIdentifier(value) {
return /^[A-Za-z0-9_-]{1,128}$/.test(String(value || ''));
}
function normalizeOnlyHighlight(value) {
return Number(value) === 1 ? 1 : 0;
}
function normalizeClassDate(raw) {
const value = raw == null ? '' : String(raw).trim();
if (/^\d{8}$/.test(value)) return value;
throw new RecordingTaskValidationError('classStartTime/yymmdd 必须是 yyyyMMdd 日期');
}
function parseTaskTime(raw, fieldName) {
if (raw == null || raw === '') return null;
const value = String(raw).trim();
if (/^\d{13}$/.test(value)) {
const timestamp = Number(value);
if (Number.isSafeInteger(timestamp)) return timestamp;
}
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);
if (matched) {
const parts = matched.slice(1).map(Number);
const timestamp = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3] - 8, parts[4], parts[5]);
if (Number.isSafeInteger(timestamp)) return timestamp;
}
throw new RecordingTaskValidationError(`${fieldName} 必须是13位毫秒时间戳或 yyyy-MM-dd HH:mm:ss`);
}
function normalizeTaskPeriod(input, now, allowDefaultDate) {
const hasBeginTime = input.beginTime != null && input.beginTime !== '';
const hasEndTime = input.endTime != null && input.endTime !== '';
if (hasBeginTime || hasEndTime) {
if (!hasBeginTime || !hasEndTime) {
throw new RecordingTaskValidationError('beginTime 和 endTime 必须同时传入');
}
const beginTime = parseTaskTime(input.beginTime, 'beginTime');
const endTime = parseTaskTime(input.endTime, 'endTime');
if (endTime <= beginTime) {
throw new RecordingTaskValidationError('endTime 必须大于 beginTime');
}
return {
beginTime,
endTime,
classDate: formatShanghaiDate(beginTime),
classStartTime: ''
};
}
const legacyValue = input.classStartTime != null ? input.classStartTime : input.yymmdd;
if (legacyValue != null && legacyValue !== '') {
const classDate = normalizeClassDate(legacyValue);
return { beginTime: null, endTime: null, classDate, classStartTime: classDate };
}
if (!allowDefaultDate) return null;
const classDate = formatShanghaiDate(Number(now == null ? Date.now() : now));
return { beginTime: null, endTime: null, classDate, classStartTime: '' };
}
function normalizeRecordingTask(raw, now) {
const input = raw || {};
const siteId = String(input.siteId || '');
const classId = String(input.classId || input.meetingNumber || '');
if (!isSafeIdentifier(siteId)) throw new RecordingTaskValidationError('siteId 无效', 2);
if (!isSafeIdentifier(classId)) throw new RecordingTaskValidationError('classId 无效', 3);
const period = normalizeTaskPeriod(input, now, true);
return {
taskId: input.taskId == null ? (input.id == null ? '' : String(input.id)) : String(input.taskId),
siteId,
classId,
onlyHighlight: normalizeOnlyHighlight(input.onlyHighlight),
beginTime: period.beginTime,
endTime: period.endTime,
classDate: period.classDate,
classStartTime: period.classStartTime
};
}
function buildManifestKey(task) {
return `${task.siteId}:${task.classId}`;
}
class RecordingTaskService {
constructor(options) {
const opts = options || {};
this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json');
this.highlightService = opts.highlightService || new HighlightRecordingService({ configPath: this.configPath });
this.inspectObjectOverride = opts.inspectObject;
this.updateTaskStatusOverride = opts.updateTaskStatus;
this.ossClient = opts.ossClient || null;
this.inFlight = new Set();
this.objectStatusCache = new Map();
}
readConfig() {
return JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
}
getStateDir() {
const config = this.readConfig();
const configured = config.RECORDINGV2CONFIG && config.RECORDINGV2CONFIG.stateDir;
return configured || path.join(config.PROJECTCATALOG, '.recording_tasks_v2');
}
getManifestPath(key) {
const digest = crypto.createHash('sha256').update(key, 'utf8').digest('hex');
return path.join(this.getStateDir(), `${digest}.json`);
}
loadManifest(key) {
try {
return JSON.parse(fs.readFileSync(this.getManifestPath(key), 'utf8'));
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
}
saveManifest(manifest) {
const stateDir = this.getStateDir();
fs.mkdirSync(stateDir, { recursive: true });
const target = this.getManifestPath(manifest.key);
const temp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(temp, JSON.stringify(manifest, null, 2));
fs.renameSync(temp, target);
}
getOutputBaseUrl() {
const config = this.readConfig();
return String((config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.outputBaseUrl) ||
'https://xdymp4.xuedianyun.com').replace(/\/$/, '');
}
buildFullFile(task) {
const ossKey = `oss/${task.siteId}/${task.classDate}/${task.classId}.mp4`;
return { type: 'full', ossKey, url: `${this.getOutputBaseUrl()}/${ossKey}` };
}
buildHighlightFile(item) {
const config = this.readConfig();
const namespace = String((config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.outputNamespace) || '').trim();
const prefix = namespace ? `oss/${namespace}` : 'oss';
const date = formatShanghaiDate(item.beginTime);
const ossKey = `${prefix}/${item.siteId}/${date}/${item.classId}_highlight_${item.highlightId}.mp4`;
return {
type: 'highlight',
highlightId: item.highlightId,
beginTime: item.beginTime,
endTime: item.endTime,
ossKey,
url: `${this.getOutputBaseUrl()}/${ossKey}`
};
}
getOssClient() {
if (this.ossClient) return this.ossClient;
if (!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID || !process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET) {
throw new Error('OSS环境变量未配置');
}
this.ossClient = new OSS({
region: 'oss-cn-beijing',
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
authorizationV4: true,
bucket: 'xdymp4'
});
return this.ossClient;
}
async inspectObject(ossKey) {
if (this.inspectObjectOverride) return Boolean(await this.inspectObjectOverride(ossKey));
const cached = this.objectStatusCache.get(ossKey);
if (cached && cached.expiresAt > Date.now()) return cached.exists;
let exists;
try {
await this.getOssClient().head(ossKey);
exists = true;
} catch (error) {
const status = error && (error.status || (error.res && error.res.status));
if (status === 404 || (error && error.code === 'NoSuchKey')) exists = false;
else throw error;
}
this.objectStatusCache.set(ossKey, { exists, expiresAt: Date.now() + 5000 });
return exists;
}
async updateTaskStatus(task, status) {
if (this.updateTaskStatusOverride) {
await this.updateTaskStatusOverride(task, status);
return;
}
const config = this.readConfig().HIGHLIGHTCONFIG || {};
const timestamp = String(Date.now());
const body = {
siteId: task.siteId,
classId: task.classId,
status,
timestamp,
authId: crypto.createHash('md5')
.update(`${task.siteId}${task.classId}${timestamp}`, 'utf8')
.digest('hex')
};
const url = new URL(
'/3m/api/recording/updateRecordingTask.do',
`${String(config.apiBaseUrl || 'https://saas.xuedianyun.com').replace(/\/$/, '')}/`
).toString();
let response;
const retryCount = Number(config.apiRetryCount) || 0;
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
try {
response = await axios.post(url, querystring.stringify(body), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: Number(config.apiTimeoutMs) || 10000
});
break;
} catch (error) {
if (attempt >= retryCount) {
throw new HighlightUpstreamError(-1, '录制任务状态回写失败');
}
}
}
const result = response && response.data || {};
if (Number(result.code) !== 0) {
throw new HighlightUpstreamError(Number(result.code), `录制任务状态回写错误 code=${result.code}`);
}
}
buildManifest(task, highlights, status) {
const key = buildManifestKey(task);
const existing = this.loadManifest(key);
const taskIds = existing && Array.isArray(existing.taskIds) ? existing.taskIds.slice() : [];
if (task.taskId && !taskIds.includes(task.taskId)) taskIds.push(task.taskId);
return {
key,
siteId: task.siteId,
classId: task.classId,
classDate: task.classDate,
classStartTime: task.classStartTime,
beginTime: task.beginTime,
endTime: task.endTime,
onlyHighlight: task.onlyHighlight,
highlights: highlights || [],
taskIds,
status,
createdAt: existing ? existing.createdAt : Date.now(),
updatedAt: Date.now()
};
}
async prepareHighlights(task) {
const config = this.readConfig();
const maxDuration = Number(config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.maxDurationMs) ||
6 * 60 * 60 * 1000;
const records = await this.highlightService.fetchByClass(task.classId);
const byId = new Map();
for (const record of records) {
const item = normalizeHighlight(record, maxDuration);
if (item.classId !== task.classId || item.siteId !== task.siteId) continue;
if (task.beginTime != null &&
(item.beginTime < task.beginTime || item.endTime > task.endTime)) continue;
byId.set(item.highlightId, item);
}
return Array.from(byId.values()).sort((a, b) => a.highlightId - b.highlightId);
}
async scheduleHighlights(task, normalizedHighlights) {
const key = buildManifestKey(task);
this.inFlight.add(key);
try {
const queued = await this.highlightService.enqueue(normalizedHighlights, { skipSiteCheck: true });
const invalid = Array.isArray(queued) && queued.find(item => item.status === 'invalid');
if (invalid) throw new Error(`高光任务无效: ${invalid.error || invalid.highlightId}`);
const taskKeys = normalizedHighlights.map(item => `${item.siteId}:${item.highlightId}`);
const statuses = await this.highlightService.waitForTaskKeys(taskKeys);
const failed = statuses.find(item => item.status === 'failed' || item.status === 'not_found');
if (failed) throw new Error(`高光录制失败: ${failed.key || failed.status}`);
await this.updateTaskStatus(task, 2);
const manifest = this.loadManifest(key);
if (manifest) {
manifest.status = 'completed';
manifest.updatedAt = Date.now();
this.saveManifest(manifest);
}
} catch (error) {
const manifest = this.loadManifest(key);
if (manifest) {
manifest.status = 'failed';
manifest.updatedAt = Date.now();
this.saveManifest(manifest);
}
throw error;
} finally {
this.inFlight.delete(key);
}
}
async acceptTasks(body, handlers) {
const input = body || {};
const list = input.list;
if (!Array.isArray(list) || list.length === 0) {
throw new RecordingTaskValidationError('list 必须是非空数组');
}
if (list.length > 1000) throw new RecordingTaskValidationError('list 不能超过1000条');
const maxMedia = Number(input.maxMedia);
if (Number.isSafeInteger(maxMedia) && maxMedia > 0 && list.length > maxMedia) {
throw new RecordingTaskValidationError('数组长度超过设置的最大值');
}
const normalizedTasks = list.map(raw => normalizeRecordingTask(raw));
const recordFullClass = handlers && handlers.recordFullClass;
let accepted = 0;
let duplicates = 0;
let noMedia = 0;
const processTask = async task => {
const key = buildManifestKey(task);
const existing = this.loadManifest(key);
const sameTaskId = task.taskId && existing && Array.isArray(existing.taskIds) &&
existing.taskIds.includes(task.taskId);
const sameMode = existing && existing.onlyHighlight === task.onlyHighlight;
const completed = existing && (existing.status === 'completed' || existing.status === 'no_media');
const acceptedFullTask = sameTaskId && task.onlyHighlight === 0 && existing.status === 'accepted';
if (this.inFlight.has(key) || (sameMode && (completed || acceptedFullTask))) {
duplicates += 1;
return;
}
if (task.onlyHighlight === 0) {
const manifest = this.buildManifest(task, [], 'accepted');
this.saveManifest(manifest);
if (typeof recordFullClass === 'function') await recordFullClass(task);
accepted += 1;
return;
}
// 在请求上游高光数据前占位,避免 cron 并发重投同一课堂。
this.inFlight.add(key);
let backgroundStarted = false;
try {
const normalizedHighlights = await this.prepareHighlights(task);
const highlightFiles = normalizedHighlights.map(item => this.buildHighlightFile(item));
const manifest = this.buildManifest(task, highlightFiles,
normalizedHighlights.length === 0 ? 'no_media' : 'accepted');
this.saveManifest(manifest);
accepted += 1;
if (normalizedHighlights.length === 0) {
await this.updateTaskStatus(task, 3);
noMedia += 1;
return;
}
const backgroundTask = this.scheduleHighlights(task, normalizedHighlights);
backgroundStarted = true;
backgroundTask.catch(error => {
console.error(`V2高光录制任务执行失败 ${key}:`, error);
});
} finally {
if (!backgroundStarted) this.inFlight.delete(key);
}
};
const grouped = Array.from(normalizedTasks.reduce((result, task) => {
const key = buildManifestKey(task);
const tasks = result.get(key) || [];
tasks.push(task);
result.set(key, tasks);
return result;
}, new Map()).values());
for (let index = 0; index < grouped.length; index += 10) {
const batch = grouped.slice(index, index + 10);
await Promise.all(batch.map(async tasks => {
for (const task of tasks) await processTask(task);
}));
}
return { accepted, duplicates, noMedia };
}
async getFileResult(params) {
const input = params || {};
const siteId = String(input.siteId || '');
const classId = String(input.classId || '');
if (!isSafeIdentifier(siteId)) throw new RecordingTaskValidationError('siteId 无效', 2);
if (!isSafeIdentifier(classId)) throw new RecordingTaskValidationError('classId 无效', 3);
const key = buildManifestKey({ siteId, classId });
const manifest = this.loadManifest(key);
const requestedPeriod = normalizeTaskPeriod(input, null, false);
if (manifest && requestedPeriod && requestedPeriod.beginTime != null && manifest.beginTime != null &&
(requestedPeriod.beginTime !== manifest.beginTime || requestedPeriod.endTime !== manifest.endTime)) {
return { code: 1, message: '文件未生成', fileExists: false, files: [] };
}
let expected = [];
let onlyHighlight;
if (manifest) {
onlyHighlight = manifest.onlyHighlight;
expected = onlyHighlight === 1 ? (manifest.highlights || []) : [this.buildFullFile(manifest)];
} else {
onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight);
if (onlyHighlight === 1 || !requestedPeriod) {
return { code: 1, message: '文件未生成', fileExists: false, files: [] };
}
expected = [this.buildFullFile({ siteId, classId, classDate: requestedPeriod.classDate })];
}
if (expected.length === 0) {
return { code: 1, message: '文件未生成', fileExists: false, files: [] };
}
const statuses = [];
for (let index = 0; index < expected.length; index += 20) {
const batch = expected.slice(index, index + 20);
statuses.push(...await Promise.all(batch.map(async file => ({
file,
exists: await this.inspectObject(file.ossKey)
}))));
}
const fileExists = statuses.every(item => item.exists);
const files = fileExists ? statuses.map(item => {
const result = { type: item.file.type, url: item.file.url };
if (item.file.highlightId != null) result.highlightId = item.file.highlightId;
return result;
}) : [];
const response = {
code: fileExists ? 0 : 1,
message: fileExists ? '文件已生成' : '文件未生成',
fileExists,
onlyHighlight,
files
};
if (fileExists && onlyHighlight === 0) response.classUrl = files[0].url;
return response;
}
}
const recordingTaskService = new RecordingTaskService();
module.exports = {
RecordingTaskService,
RecordingTaskValidationError,
buildManifestKey,
normalizeClassDate,
normalizeOnlyHighlight,
normalizeRecordingTask,
normalizeTaskPeriod,
parseTaskTime,
recordingTaskService,
HighlightUpstreamError,
HighlightValidationError
};
... ...
... ... @@ -142,7 +142,6 @@ async function run() {
scheduledSiteService.readConfig = () => ({
highlight: {
enabled: true,
sourceMode: 'site',
siteIds: ['xdyui2'],
maxConcurrent: 1,
taskRetentionMs: 86400000
... ... @@ -159,34 +158,6 @@ async function run() {
assert.strictEqual(siteQuery.siteId, 'xdyui2');
assert.strictEqual(scheduledSite.received, 0);
const scheduledTaskService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
scheduledTaskService.readConfig = () => ({
highlight: {
enabled: true,
sourceMode: 'task',
siteIds: ['xdyui2'],
maxConcurrent: 1,
taskRetentionMs: 86400000
}
});
scheduledTaskService.fetchRecordingTasks = async () => [
{ meetingNumber: '1001', siteId: 'xdyui2', status: 0, onlyHighlight: 1 },
{ meetingNumber: '1002', siteId: 'xdyui2', status: 1, onlyHighlight: 1 },
{ meetingNumber: '1003', siteId: 'xdyui2', status: 0, onlyHighlight: 0 },
{ meetingNumber: '1004', siteId: 'other', status: 0, onlyHighlight: 1 }
];
const fetchedClasses = [];
scheduledTaskService.fetchByClass = async classId => {
fetchedClasses.push(classId);
return [Object.assign({}, item, { meetingNumber: classId, siteId: 'xdyui2' })];
};
scheduledTaskService.enqueue = async records => records.map(record => ({ status: 'queued', record }));
const scheduledTask = await scheduledTaskService.runScheduledRecording();
assert.deepStrictEqual(fetchedClasses, ['1001']);
assert.strictEqual(scheduledTask.sourceTasks, 4);
assert.strictEqual(scheduledTask.pendingTasks, 1);
assert.strictEqual(scheduledTask.queued, 1);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-highlight-'));
try {
const captureService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
... ...
const assert = require('assert');
const http = require('http');
const { highlightRecordingService } = require('../services/highlightRecordingService');
const {
HighlightValidationError,
highlightRecordingService
} = require('../services/highlightRecordingService');
const app = require('../app');
function request(server, method, route, body) {
... ... @@ -42,12 +45,20 @@ async function run() {
assert.strictEqual(invalidFiles.status, 400);
assert.strictEqual(invalidFiles.body.code, 10);
const disabledFiles = await request(server, 'POST', '/highlight/files', {
items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }]
});
assert.strictEqual(disabledFiles.status, 400);
assert.strictEqual(disabledFiles.body.code, 10);
assert.strictEqual(disabledFiles.body.message, '高光录制功能未启用');
const originalGetFileStatusesForDisabled = highlightRecordingService.getFileStatuses;
try {
highlightRecordingService.getFileStatuses = async () => {
throw new HighlightValidationError('高光录制功能未启用');
};
const disabledFiles = await request(server, 'POST', '/highlight/files', {
items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }]
});
assert.strictEqual(disabledFiles.status, 400);
assert.strictEqual(disabledFiles.body.code, 10);
assert.strictEqual(disabledFiles.body.message, '高光录制功能未启用');
} finally {
highlightRecordingService.getFileStatuses = originalGetFileStatusesForDisabled;
}
const invalidPreview = await request(server, 'POST', '/highlight/preview/by-class', {});
assert.strictEqual(invalidPreview.status, 400);
... ... @@ -57,13 +68,16 @@ async function run() {
assert.strictEqual(invalidClassFiles.status, 400);
assert.strictEqual(invalidClassFiles.body.code, 10);
const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled');
assert.strictEqual(disabledScheduled.status, 400);
assert.strictEqual(disabledScheduled.body.code, 10);
assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用');
const originalRunScheduledRecording = highlightRecordingService.runScheduledRecording;
try {
highlightRecordingService.runScheduledRecording = async () => {
throw new HighlightValidationError('高光录制功能未启用');
};
const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled');
assert.strictEqual(disabledScheduled.status, 400);
assert.strictEqual(disabledScheduled.body.code, 10);
assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用');
highlightRecordingService.runScheduledRecording = async () => ({
mode: 'site',
beginTime: 1785945600000,
... ...
const assert = require('assert');
const http = require('http');
const { recordingTaskService } = require('../services/recordingTaskService');
const app = require('../app');
function request(server, route, body) {
return new Promise((resolve, reject) => {
const address = server.address();
const payload = JSON.stringify(body || {});
const req = http.request({
host: '127.0.0.1',
port: address.port,
path: route,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}, response => {
let data = '';
response.setEncoding('utf8');
response.on('data', chunk => { data += chunk; });
response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(data) }));
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
async function run() {
const server = http.createServer(app);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const originalAcceptTasks = recordingTaskService.acceptTasks;
const originalGetFileResult = recordingTaskService.getFileResult;
let acceptedCalls = 0;
let fileCalls = 0;
const acceptedBodies = [];
try {
recordingTaskService.acceptTasks = async (body, handlers) => {
acceptedCalls += 1;
acceptedBodies.push(body);
assert.strictEqual(typeof handlers.recordFullClass, 'function');
return { accepted: 1, duplicates: 0, noMedia: 0 };
};
recordingTaskService.getFileResult = async body => {
fileCalls += 1;
assert.strictEqual(body.classId, '1001');
return {
code: 0,
message: '文件已生成',
fileExists: true,
onlyHighlight: 1,
files: [{ type: 'highlight', highlightId: 5, url: 'https://example/highlight.mp4' }]
};
};
const oldTask = await request(server, '/recordingTask', {});
assert.strictEqual(oldTask.status, 200);
assert.strictEqual(oldTask.body.code, '1');
assert.strictEqual(acceptedCalls, 0, '旧任务接口不得进入 V2 服务');
const highTask = await request(server, '/recordingTaskV2', {
list: [{
id: 'task-high-1',
siteId: 'doctest',
meetingNumber: '1001',
beginTime: '2026-08-05 10:00:00',
endTime: '2026-08-05 11:00:00',
onlyHighlight: 1
}]
});
assert.strictEqual(highTask.status, 200);
assert.deepStrictEqual(highTask.body, {
code: '0',
message: 'success',
accepted: 1,
duplicates: 0,
noMedia: 0,
v: 'v1.2.0.20251208'
});
assert.strictEqual(acceptedBodies[0].list[0].onlyHighlight, 1);
const compatibleFullTask = await request(server, '/recordingTaskV2', {
list: [{ siteId: 'doctest', classId: '1001', yymmdd: '20260805' }]
});
assert.strictEqual(compatibleFullTask.status, 200);
assert.strictEqual(compatibleFullTask.body.code, '0');
assert.strictEqual(acceptedCalls, 2);
assert.strictEqual(acceptedBodies[1].list[0].onlyHighlight, undefined);
const oldFile = await request(server, '/fileExists', {
siteId: 'doctest', classId: '1001'
});
assert.strictEqual(oldFile.status, 200);
assert.strictEqual(oldFile.body.code, 1);
assert.match(oldFile.body.classUrl, /classId=1001/);
assert.strictEqual(fileCalls, 0, '旧查询接口不得进入 V2 服务');
const newFile = await request(server, '/fileExistsV2', {
siteId: 'doctest', classId: '1001'
});
assert.strictEqual(newFile.status, 200);
assert.strictEqual(newFile.body.fileExists, true);
assert.strictEqual(newFile.body.onlyHighlight, 1);
assert.strictEqual(newFile.body.files.length, 1);
assert.strictEqual(fileCalls, 1);
} finally {
recordingTaskService.acceptTasks = originalAcceptTasks;
recordingTaskService.getFileResult = originalGetFileResult;
await new Promise(resolve => server.close(resolve));
}
console.log('recording V2 routes tests passed');
}
run().catch(error => {
console.error(error);
process.exitCode = 1;
});
... ...
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
RecordingTaskService,
buildManifestKey,
normalizeOnlyHighlight,
normalizeRecordingTask,
parseTaskTime
} = require('../services/recordingTaskService');
async function waitForBackground(service) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (service.inFlight.size === 0) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error('后台高光任务未在测试时间内结束');
}
async function run() {
assert.strictEqual(normalizeOnlyHighlight(undefined), 0);
assert.strictEqual(normalizeOnlyHighlight(0), 0);
assert.strictEqual(normalizeOnlyHighlight('1'), 1);
assert.strictEqual(normalizeOnlyHighlight(2), 0);
assert.deepStrictEqual(normalizeRecordingTask({
siteId: 'doctest',
classId: '1001',
yymmdd: '20260805'
}), {
taskId: '',
siteId: 'doctest',
classId: '1001',
onlyHighlight: 0,
beginTime: null,
endTime: null,
classDate: '20260805',
classStartTime: '20260805'
});
const saasTask = normalizeRecordingTask({
id: 'task-high-1',
siteId: 'doctest',
meetingNumber: '1002',
beginTime: '2026-08-05 10:00:00',
endTime: '2026-08-05 11:00:00',
onlyHighlight: 1
});
assert.strictEqual(saasTask.classId, '1002');
assert.strictEqual(saasTask.onlyHighlight, 1);
assert.strictEqual(saasTask.classDate, '20260805');
assert.strictEqual(saasTask.beginTime, 1785895200000);
assert.strictEqual(parseTaskTime(1785895200000, 'beginTime'), 1785895200000);
assert.throws(() => normalizeRecordingTask({
siteId: 'doctest', classId: '1002', beginTime: 1785895200000
}), /必须同时传入/);
assert.throws(() => normalizeRecordingTask({
siteId: 'doctest', classId: '1002', yymmdd: 1785895200000
}), /yyyyMMdd/);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-v2-task-'));
const configPath = path.join(tempRoot, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
PROJECTCATALOG: tempRoot,
HIGHLIGHTCONFIG: {
enabled: true,
siteIds: ['doctest'],
maxDurationMs: 3600000,
outputBaseUrl: 'https://xdymp4.xuedianyun.com',
outputNamespace: ''
}
}));
const records = [{
id: 5,
meetingNumber: '1002',
siteId: 'doctest',
beginTime: 1785895298000,
endTime: 1785895343000
}, {
id: 6,
meetingNumber: '1002',
siteId: 'doctest',
beginTime: 1785895498000,
endTime: 1785895543000
}, {
id: 7,
meetingNumber: '1002',
siteId: 'doctest',
beginTime: 1785899498000,
endTime: 1785899543000
}];
const calls = { fetch: 0, enqueue: 0, wait: 0 };
const highlightService = {
fetchByClass: async classId => {
calls.fetch += 1;
return classId === '1002' ? records : [];
},
enqueue: async items => {
calls.enqueue += 1;
return items.map(item => ({ status: 'queued', highlightId: item.highlightId }));
},
waitForTaskKeys: async keys => {
calls.wait += 1;
return keys.map(key => ({ key, status: 'uploading' }));
}
};
const objects = new Set();
const statusUpdates = [];
const service = new RecordingTaskService({
configPath,
highlightService,
inspectObject: async key => objects.has(key),
updateTaskStatus: async (task, status) => { statusUpdates.push(`${task.classId}:${status}`); }
});
const fullCalls = [];
const fullResult = await service.acceptTasks({
list: [{
id: 'task-full-1',
siteId: 'doctest',
classId: '1001',
yymmdd: '20260805'
}]
}, {
recordFullClass: task => { fullCalls.push(task); }
});
assert.deepStrictEqual(fullResult, { accepted: 1, duplicates: 0, noMedia: 0 });
assert.strictEqual(fullCalls.length, 1);
assert.strictEqual(fullCalls[0].onlyHighlight, 0);
assert.strictEqual(calls.fetch, 0, '整课任务不得查询高光接口');
let fullFile = await service.getFileResult({
siteId: 'doctest', classId: '1001', classStartTime: '20260805'
});
assert.strictEqual(fullFile.fileExists, false);
objects.add('oss/doctest/20260805/1001.mp4');
fullFile = await service.getFileResult({ siteId: 'doctest', classId: '1001' });
assert.strictEqual(fullFile.fileExists, true);
assert.strictEqual(fullFile.onlyHighlight, 0);
assert.strictEqual(fullFile.files.length, 1);
assert.strictEqual(fullFile.classUrl, fullFile.files[0].url);
const highResult = await service.acceptTasks({ list: [{
id: 'task-high-1',
siteId: 'doctest',
meetingNumber: '1002',
beginTime: '2026-08-05 10:00:00',
endTime: '2026-08-05 11:00:00',
onlyHighlight: 1
}] });
assert.deepStrictEqual(highResult, { accepted: 1, duplicates: 0, noMedia: 0 });
await waitForBackground(service);
assert.strictEqual(calls.fetch, 1);
assert.strictEqual(calls.enqueue, 1);
assert.strictEqual(calls.wait, 1);
assert.deepStrictEqual(statusUpdates, ['1002:2']);
const highManifest = service.loadManifest(buildManifestKey({ siteId: 'doctest', classId: '1002' }));
assert.strictEqual(highManifest.onlyHighlight, 1);
assert.strictEqual(highManifest.status, 'completed');
assert.deepStrictEqual(highManifest.highlights.map(item => item.highlightId), [5, 6]);
const duplicate = await service.acceptTasks({ list: [{
id: 'task-high-1',
siteId: 'doctest',
meetingNumber: '1002',
beginTime: '2026-08-05 10:00:00',
endTime: '2026-08-05 11:00:00',
onlyHighlight: 1
}] });
assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 });
assert.strictEqual(calls.fetch, 1);
let highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
assert.strictEqual(highFiles.fileExists, false);
objects.add('oss/doctest/20260805/1002_highlight_5.mp4');
objects.add('oss/doctest/20260805/1002_highlight_6.mp4');
highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
assert.strictEqual(highFiles.fileExists, true);
assert.strictEqual(highFiles.onlyHighlight, 1);
assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6]);
const noMedia = await service.acceptTasks({ list: [{
id: 'task-high-empty',
siteId: 'doctest',
classId: '1003',
yymmdd: '20260805',
onlyHighlight: 1
}] });
assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
assert.deepStrictEqual(statusUpdates, ['1002:2', '1003:3']);
const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' });
assert.strictEqual(emptyResult.fileExists, false);
const mappedNoMedia = await service.acceptTasks({ list: [{
siteId: 'doctest',
classId: '1006',
yymmdd: '20260805',
onlyHighlight: 1
}] });
assert.deepStrictEqual(mappedNoMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
assert.strictEqual(statusUpdates[statusUpdates.length - 1], '1006:3',
'没有 SaaS 任务 id 也必须回写状态');
const retryTask = normalizeRecordingTask({
id: 'task-high-retry',
siteId: 'doctest',
classId: '1004',
yymmdd: '20260805',
onlyHighlight: 1
});
service.saveManifest(service.buildManifest(retryTask, [], 'failed'));
const retryResult = await service.acceptTasks({ list: [{
id: 'task-high-retry',
siteId: 'doctest',
classId: '1004',
yymmdd: '20260805',
onlyHighlight: 1
}] });
assert.deepStrictEqual(retryResult, { accepted: 1, duplicates: 0, noMedia: 1 },
'失败的高光任务必须允许使用相同任务 ID 重试');
const fallback = await service.getFileResult({
siteId: 'doctest', classId: 'legacy', classStartTime: '20260805'
});
assert.strictEqual(fallback.fileExists, false);
objects.add('oss/doctest/20260805/legacy.mp4');
const generatedFallback = await service.getFileResult({
siteId: 'doctest', classId: 'legacy', classStartTime: '20260805'
});
assert.strictEqual(generatedFallback.fileExists, true);
let releaseConcurrentFetch;
let concurrentFetches = 0;
const concurrentService = new RecordingTaskService({
configPath,
highlightService: {
fetchByClass: async () => {
concurrentFetches += 1;
return new Promise(resolve => { releaseConcurrentFetch = resolve; });
}
},
updateTaskStatus: async () => {}
});
const concurrentTask = {
id: 'task-high-concurrent',
siteId: 'doctest',
classId: '1005',
yymmdd: '20260805',
onlyHighlight: 1
};
const firstConcurrent = concurrentService.acceptTasks({ list: [concurrentTask] });
await new Promise(resolve => setImmediate(resolve));
const duplicateConcurrent = await concurrentService.acceptTasks({ list: [concurrentTask] });
assert.deepStrictEqual(duplicateConcurrent, { accepted: 0, duplicates: 1, noMedia: 0 });
assert.strictEqual(concurrentFetches, 1, '并发重投不得重复查询 SaaS 高光接口');
releaseConcurrentFetch([]);
assert.deepStrictEqual(await firstConcurrent, { accepted: 1, duplicates: 0, noMedia: 1 });
fs.rmSync(tempRoot, { recursive: true, force: true });
console.log('recording task V2 service tests passed');
}
run().catch(error => {
console.error(error);
process.exitCode = 1;
});
... ...