正在显示
20 个修改的文件
包含
1449 行增加
和
1738 行删除
| @@ -28,12 +28,10 @@ | @@ -28,12 +28,10 @@ | ||
| 28 | "h":720 | 28 | "h":720 |
| 29 | }, | 29 | }, |
| 30 | "HIGHLIGHTCONFIG": { | 30 | "HIGHLIGHTCONFIG": { |
| 31 | - "enabled": false, | ||
| 32 | - "sourceMode": "site", | 31 | + "enabled": true, |
| 33 | "siteIds": ["xdyui2"], | 32 | "siteIds": ["xdyui2"], |
| 34 | "apiBaseUrl": "https://saas.xuedianyun.com", | 33 | "apiBaseUrl": "https://saas.xuedianyun.com", |
| 35 | "pageSize": 100, | 34 | "pageSize": 100, |
| 36 | - "taskPageSize": 100, | ||
| 37 | "maxPages": 1000, | 35 | "maxPages": 1000, |
| 38 | "maxConcurrent": 2, | 36 | "maxConcurrent": 2, |
| 39 | "maxDurationMs": 21600000, | 37 | "maxDurationMs": 21600000, |
docs/FILE_EXISTS_V2.md
0 → 100644
| 1 | +# 获取录制文件(V2) | ||
| 2 | + | ||
| 3 | +## 1. 接口说明 | ||
| 4 | + | ||
| 5 | +查询指定课堂的录制文件是否已经生成,并在文件全部可用时返回访问地址。 | ||
| 6 | + | ||
| 7 | +接口自动识别整课录制和仅高光录制,调用方不需要指定录制类型。查询操作不会创建、重新执行或修改录制任务。 | ||
| 8 | + | ||
| 9 | +| 项目 | 内容 | | ||
| 10 | +|---|---| | ||
| 11 | +| 接口版本 | V2 | | ||
| 12 | +| 请求方式 | `POST` | | ||
| 13 | +| 请求路径 | `/fileExistsV2` | | ||
| 14 | +| Content-Type | `application/json; charset=utf-8` | | ||
| 15 | +| 字符编码 | UTF-8 | | ||
| 16 | + | ||
| 17 | +实际请求域名及网关鉴权方式以部署环境提供的信息为准。本接口请求体不额外接收签名字段。 | ||
| 18 | + | ||
| 19 | +## 2. 请求参数 | ||
| 20 | + | ||
| 21 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 22 | +|---|---|:---:|---| | ||
| 23 | +| `siteId` | String | 是 | 站点 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 | | ||
| 24 | +| `classId` | String | 是 | 课堂 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 | | ||
| 25 | +| `classStartTime` | String | 否 | 兼容历史整课文件查询时使用,格式为 `yyyyMMdd`;V2 录制任务通常不需要传入 | | ||
| 26 | + | ||
| 27 | +### 请求示例 | ||
| 28 | + | ||
| 29 | +```http | ||
| 30 | +POST /fileExistsV2 HTTP/1.1 | ||
| 31 | +Host: {API 服务域名} | ||
| 32 | +Content-Type: application/json; charset=utf-8 | ||
| 33 | + | ||
| 34 | +{ | ||
| 35 | + "siteId": "doctest", | ||
| 36 | + "classId": "487012832" | ||
| 37 | +} | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +历史整课文件查询示例: | ||
| 41 | + | ||
| 42 | +```json | ||
| 43 | +{ | ||
| 44 | + "siteId": "doctest", | ||
| 45 | + "classId": "487012832", | ||
| 46 | + "classStartTime": "20260805" | ||
| 47 | +} | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +## 3. 响应参数 | ||
| 51 | + | ||
| 52 | +| 参数 | 类型 | 必定返回 | 说明 | | ||
| 53 | +|---|---|:---:|---| | ||
| 54 | +| `code` | Integer | 是 | 业务状态码,详见“状态码” | | ||
| 55 | +| `message` | String | 是 | 状态说明 | | ||
| 56 | +| `fileExists` | Boolean | 是 | `true` 表示本次录制对应的全部文件均已生成 | | ||
| 57 | +| `onlyHighlight` | Integer | 否 | `0` 表示整课录制,`1` 表示仅高光录制 | | ||
| 58 | +| `classUrl` | String | 否 | 整课录制文件地址;仅整课文件生成成功时返回 | | ||
| 59 | +| `files` | Array | 是 | 录制文件列表;文件未全部生成时返回空数组 | | ||
| 60 | + | ||
| 61 | +### `files` 元素 | ||
| 62 | + | ||
| 63 | +| 参数 | 类型 | 必定返回 | 说明 | | ||
| 64 | +|---|---|:---:|---| | ||
| 65 | +| `type` | String | 是 | 文件类型:`full` 为整课,`highlight` 为高光片段 | | ||
| 66 | +| `url` | String | 是 | 文件访问地址 | | ||
| 67 | +| `highlightId` | Integer | 否 | 高光记录 ID;仅 `type=highlight` 时返回 | | ||
| 68 | + | ||
| 69 | +## 4. 响应示例 | ||
| 70 | + | ||
| 71 | +### 4.1 整课录制文件已生成 | ||
| 72 | + | ||
| 73 | +HTTP 状态码:`200` | ||
| 74 | + | ||
| 75 | +```json | ||
| 76 | +{ | ||
| 77 | + "code": 0, | ||
| 78 | + "message": "文件已生成", | ||
| 79 | + "fileExists": true, | ||
| 80 | + "onlyHighlight": 0, | ||
| 81 | + "classUrl": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4", | ||
| 82 | + "files": [ | ||
| 83 | + { | ||
| 84 | + "type": "full", | ||
| 85 | + "url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4" | ||
| 86 | + } | ||
| 87 | + ] | ||
| 88 | +} | ||
| 89 | +``` | ||
| 90 | + | ||
| 91 | +### 4.2 高光录制文件已生成 | ||
| 92 | + | ||
| 93 | +高光任务可能返回多个文件。只有全部高光文件都可用时,`fileExists` 才会返回 `true`。 | ||
| 94 | + | ||
| 95 | +HTTP 状态码:`200` | ||
| 96 | + | ||
| 97 | +```json | ||
| 98 | +{ | ||
| 99 | + "code": 0, | ||
| 100 | + "message": "文件已生成", | ||
| 101 | + "fileExists": true, | ||
| 102 | + "onlyHighlight": 1, | ||
| 103 | + "files": [ | ||
| 104 | + { | ||
| 105 | + "type": "highlight", | ||
| 106 | + "highlightId": 5, | ||
| 107 | + "url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_5.mp4" | ||
| 108 | + }, | ||
| 109 | + { | ||
| 110 | + "type": "highlight", | ||
| 111 | + "highlightId": 6, | ||
| 112 | + "url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_6.mp4" | ||
| 113 | + } | ||
| 114 | + ] | ||
| 115 | +} | ||
| 116 | +``` | ||
| 117 | + | ||
| 118 | +### 4.3 文件尚未生成 | ||
| 119 | + | ||
| 120 | +HTTP 状态码:`200` | ||
| 121 | + | ||
| 122 | +```json | ||
| 123 | +{ | ||
| 124 | + "code": 1, | ||
| 125 | + "message": "文件未生成", | ||
| 126 | + "fileExists": false, | ||
| 127 | + "files": [] | ||
| 128 | +} | ||
| 129 | +``` | ||
| 130 | + | ||
| 131 | +`fileExists=false` 表示当前没有可交付的完整录制结果,可能处于录制中、文件同步中或没有生成录制文件。调用方可在业务允许的时间范围内继续查询。 | ||
| 132 | + | ||
| 133 | +### 4.4 请求参数错误 | ||
| 134 | + | ||
| 135 | +HTTP 状态码:`400` | ||
| 136 | + | ||
| 137 | +```json | ||
| 138 | +{ | ||
| 139 | + "code": 3, | ||
| 140 | + "message": "classId 无效", | ||
| 141 | + "fileExists": false, | ||
| 142 | + "files": [] | ||
| 143 | +} | ||
| 144 | +``` | ||
| 145 | + | ||
| 146 | +### 4.5 服务异常 | ||
| 147 | + | ||
| 148 | +HTTP 状态码:`500` | ||
| 149 | + | ||
| 150 | +```json | ||
| 151 | +{ | ||
| 152 | + "code": -1, | ||
| 153 | + "message": "服务器内部错误", | ||
| 154 | + "fileExists": false, | ||
| 155 | + "files": [] | ||
| 156 | +} | ||
| 157 | +``` | ||
| 158 | + | ||
| 159 | +## 5. 状态码 | ||
| 160 | + | ||
| 161 | +| HTTP 状态码 | `code` | 说明 | | ||
| 162 | +|---:|---:|---| | ||
| 163 | +| `200` | `0` | 录制文件已全部生成,可以使用 `files` 中的地址 | | ||
| 164 | +| `200` | `1` | 当前没有可交付的完整录制结果 | | ||
| 165 | +| `400` | `2` | `siteId` 或兼容日期参数无效 | | ||
| 166 | +| `400` | `3` | `classId` 无效 | | ||
| 167 | +| `500` | `-1` | 服务内部异常 | | ||
| 168 | + | ||
| 169 | +业务处理应同时判断 HTTP 状态码、`code` 和 `fileExists`。文件可交付的唯一判定条件为: | ||
| 170 | + | ||
| 171 | +```text | ||
| 172 | +HTTP 200 && code == 0 && fileExists == true | ||
| 173 | +``` | ||
| 174 | + | ||
| 175 | +## 6. 调用建议 | ||
| 176 | + | ||
| 177 | +1. 本接口为幂等查询接口,可以重复调用。 | ||
| 178 | +2. 建议轮询间隔不低于 10 秒,避免高频查询。 | ||
| 179 | +3. `fileExists=false` 时不会返回部分文件地址。 | ||
| 180 | +4. 收到 HTTP `500` 时,可采用逐步延长间隔的方式重试。 | ||
| 181 | +5. 调用方应设置业务侧最长等待时间,避免无限轮询。 | ||
| 182 | + | ||
| 183 | +## 7. 版本记录 | ||
| 184 | + | ||
| 185 | +| 版本 | 日期 | 说明 | | ||
| 186 | +|---|---|---| | ||
| 187 | +| V2 | 2026-08-10 | 支持统一查询整课录制和仅高光录制结果 | |
| 1 | -# WebScreen 高光录制接口文档 | 1 | +# 高光录制内部说明 |
| 2 | 2 | ||
| 3 | -## 1. 文档范围 | ||
| 4 | - | ||
| 5 | -本文汇总 WebScreen 对外提供的全部高光接口,以及 WebScreen 依赖的 SaaS 内部接口。 | ||
| 6 | - | ||
| 7 | -高光录制与整堂录制是两套独立业务: | ||
| 8 | - | ||
| 9 | -- 原整堂录制接口保持不变。 | ||
| 10 | -- 所有高光接口统一使用 `/highlight` 前缀。 | ||
| 11 | -- `HIGHLIGHTCONFIG.enabled` 只控制高光查询、录制和文件检查,不影响原整堂录制;`GET /highlight/status` 始终可用于健康检查。 | ||
| 12 | -- 高光文件使用 `{classId}_highlight_{highlightId}.mp4`,不会覆盖整堂录像 `{classId}.mp4`。 | ||
| 13 | - | ||
| 14 | -示例服务地址: | 3 | +正式入口是 `/recordingTaskV2`,正式查询是 `/fileExistsV2`。原录制接口保持不变。 |
| 15 | 4 | ||
| 16 | ```text | 5 | ```text |
| 17 | -http://127.0.0.1:3001 | ||
| 18 | -``` | ||
| 19 | - | ||
| 20 | -除 `GET /highlight/status` 外,请求均使用: | ||
| 21 | - | ||
| 22 | -```http | ||
| 23 | -Content-Type: application/json | ||
| 24 | -``` | ||
| 25 | - | ||
| 26 | -当前 WebScreen 接口没有应用层鉴权,只应开放给可信内网或经过访问控制的调用方。 | ||
| 27 | - | ||
| 28 | -## 2. 接口总览 | ||
| 29 | - | ||
| 30 | -| 方法 | 路径 | 用途 | 是否启动录制 | | ||
| 31 | -|---|---|---|---| | ||
| 32 | -| POST | `/highlight/preview/by-class` | 预览指定课堂的全部高光及目标文件信息 | 否 | | ||
| 33 | -| POST | `/highlight/preview/by-site` | 预览指定站点、时间范围内的全部高光 | 否 | | ||
| 34 | -| POST | `/highlight/recording/by-class` | 提交指定课堂的全部高光录制 | 是 | | ||
| 35 | -| POST | `/highlight/recording/by-site` | 提交指定站点、时间范围内的全部高光录制 | 是 | | ||
| 36 | -| POST | `/highlight/recording/scheduled` | 运行一次独立的高光定时任务 | 是 | | ||
| 37 | -| POST | `/highlight/fileExists` | 查询指定课堂的全部高光文件状态 | 否 | | ||
| 38 | -| POST | `/highlight/files` | 根据高光明细批量查询文件状态 | 否 | | ||
| 39 | -| GET | `/highlight/status` | 查询当前进程内的高光队列状态 | 否 | | ||
| 40 | - | ||
| 41 | -## 3. 公共约定 | ||
| 42 | - | ||
| 43 | -### 3.1 高光字段 | ||
| 44 | - | ||
| 45 | -| 字段 | 类型 | 说明 | | ||
| 46 | -|---|---|---| | ||
| 47 | -| `highlightId` | Integer | 高光唯一 ID,对应 SaaS 高光记录的 `id` | | ||
| 48 | -| `classId` | String | 课堂号,对应 SaaS 高光记录的 `meetingNumber` | | ||
| 49 | -| `siteId` | String | 机构编码,必须位于 `HIGHLIGHTCONFIG.siteIds` | | ||
| 50 | -| `beginTime` | Integer | 高光开始时间,13 位毫秒时间戳 | | ||
| 51 | -| `endTime` | Integer | 高光结束时间,13 位毫秒时间戳 | | ||
| 52 | - | ||
| 53 | -`classId`、`siteId` 只允许字母、数字、下划线和短横线,最长 128 个字符。 | ||
| 54 | - | ||
| 55 | -### 3.2 任务状态 | ||
| 56 | - | ||
| 57 | -| 状态 | 说明 | | ||
| 58 | -|---|---| | ||
| 59 | -| `not_generated` | 本地、OSS 和当前任务队列均未发现文件 | | ||
| 60 | -| `queued` | 已进入等待队列 | | ||
| 61 | -| `recording` | `web_capture_c` 正在录制 | | ||
| 62 | -| `uploading` | 本地文件已生成,等待服务器搬运到 OSS | | ||
| 63 | -| `generated` | OSS 文件已经存在,可以返回播放地址 | | ||
| 64 | -| `invalid` | 高光字段、站点或时间段校验失败 | | ||
| 65 | -| `failed` | 录制任务执行失败 | | ||
| 66 | - | ||
| 67 | -### 3.3 HTTP 状态和错误响应 | ||
| 68 | - | ||
| 69 | -| HTTP 状态 | `code` | 说明 | | ||
| 70 | -|---|---:|---| | ||
| 71 | -| 200 | 0 或 1 | 请求正常完成;文件查询接口使用 1 表示尚未全部生成 | | ||
| 72 | -| 400 | 10 | 请求参数错误、站点未启用或高光功能未启用 | | ||
| 73 | -| 502 | SaaS 返回码或 -1 | SaaS 高光接口、录制任务接口或 OSS 查询失败 | | ||
| 74 | -| 500 | -1 | WebScreen 内部异常 | | ||
| 75 | - | ||
| 76 | -错误示例: | ||
| 77 | - | ||
| 78 | -```json | ||
| 79 | -{ | ||
| 80 | - "code": 10, | ||
| 81 | - "message": "高光录制功能未启用" | ||
| 82 | -} | ||
| 83 | -``` | ||
| 84 | - | ||
| 85 | -## 4. 预览课堂高光 | ||
| 86 | - | ||
| 87 | -```http | ||
| 88 | -POST /highlight/preview/by-class | ||
| 89 | -``` | ||
| 90 | - | ||
| 91 | -该接口查询 SaaS 高光数据并计算录制地址、文件名和当前文件状态,但不会加入录制队列。 | ||
| 92 | - | ||
| 93 | -请求: | ||
| 94 | - | ||
| 95 | -```json | ||
| 96 | -{ | ||
| 97 | - "classId": "2008975651" | ||
| 98 | -} | ||
| 99 | -``` | ||
| 100 | - | ||
| 101 | -成功响应: | ||
| 102 | - | ||
| 103 | -```json | ||
| 104 | -{ | ||
| 105 | - "code": 0, | ||
| 106 | - "message": "success", | ||
| 107 | - "data": [ | ||
| 108 | - { | ||
| 109 | - "highlightId": 5, | ||
| 110 | - "classId": "2008975651", | ||
| 111 | - "siteId": "xdyui2", | ||
| 112 | - "beginTime": 1785895298000, | ||
| 113 | - "endTime": 1785895343000, | ||
| 114 | - "duration": 45000, | ||
| 115 | - "status": "not_generated", | ||
| 116 | - "fileName": "2008975651_highlight_5.mp4", | ||
| 117 | - "localPath": "/root/web_capture_release/media/xdyui2/20260805/2008975651_highlight_5.mp4", | ||
| 118 | - "ossKey": "oss/xdyui2/20260805/2008975651_highlight_5.mp4", | ||
| 119 | - "url": null, | ||
| 120 | - "playbackUrl": "https://pclive.xuedianyun.com/...&recBeginTime=1785895298000&recEndTime=1785895343000" | ||
| 121 | - } | ||
| 122 | - ] | ||
| 123 | -} | ||
| 124 | -``` | ||
| 125 | - | ||
| 126 | -没有高光时返回 `code=0`、`message=无高光数据`、`data=[]`。 | ||
| 127 | - | ||
| 128 | -## 5. 预览站点高光 | ||
| 129 | - | ||
| 130 | -```http | ||
| 131 | -POST /highlight/preview/by-site | ||
| 132 | -``` | ||
| 133 | - | ||
| 134 | -请求字段: | 6 | +onlyHighlight 不传或为 0 |
| 7 | + → 原整课录制 | ||
| 135 | 8 | ||
| 136 | -| 字段 | 类型 | 必填 | 说明 | | ||
| 137 | -|---|---|---|---| | ||
| 138 | -| `siteId` | String | 是 | 机构编码 | | ||
| 139 | -| `beginTime` | Integer | 否 | 查询开始时间,13 位毫秒时间戳 | | ||
| 140 | -| `endTime` | Integer | 否 | 查询结束时间,13 位毫秒时间戳 | | ||
| 141 | -| `pageSize` | Integer | 否 | 调用 SaaS 时的分页大小,范围 1~1000 | | ||
| 142 | - | ||
| 143 | -请求: | ||
| 144 | - | ||
| 145 | -```json | ||
| 146 | -{ | ||
| 147 | - "siteId": "xdyui2", | ||
| 148 | - "beginTime": 1785859200000, | ||
| 149 | - "endTime": 1785945599999, | ||
| 150 | - "pageSize": 100 | ||
| 151 | -} | ||
| 152 | -``` | ||
| 153 | - | ||
| 154 | -WebScreen 会自动读取全部分页。响应项与 `/highlight/preview/by-class` 相同。 | ||
| 155 | - | ||
| 156 | -## 6. 按课堂提交高光录制 | ||
| 157 | - | ||
| 158 | -```http | ||
| 159 | -POST /highlight/recording/by-class | 9 | +onlyHighlight=1 |
| 10 | + → getByClassPrivate.do | ||
| 11 | + → 每条高光录制一个 MP4 | ||
| 160 | ``` | 12 | ``` |
| 161 | 13 | ||
| 162 | -请求: | ||
| 163 | - | ||
| 164 | -```json | ||
| 165 | -{ | ||
| 166 | - "classId": "2008975651" | ||
| 167 | -} | ||
| 168 | -``` | ||
| 169 | - | ||
| 170 | -成功响应: | ||
| 171 | - | ||
| 172 | -```json | ||
| 173 | -{ | ||
| 174 | - "code": 0, | ||
| 175 | - "message": "success", | ||
| 176 | - "data": [ | ||
| 177 | - { | ||
| 178 | - "highlightId": 5, | ||
| 179 | - "classId": "2008975651", | ||
| 180 | - "siteId": "xdyui2", | ||
| 181 | - "beginTime": 1785895298000, | ||
| 182 | - "endTime": 1785895343000, | ||
| 183 | - "status": "queued" | ||
| 184 | - } | ||
| 185 | - ] | ||
| 186 | -} | ||
| 187 | -``` | ||
| 188 | - | ||
| 189 | -接口返回表示任务已经接收,不表示 MP4 已经生成。调用方应继续查询 `/highlight/fileExists` 或 `/highlight/status`。 | ||
| 190 | - | ||
| 191 | -重复提交同一 `siteId + highlightId` 时,WebScreen 会检查内存任务、本地文件和 OSS,不会重复录制已经存在的高光。 | ||
| 192 | - | ||
| 193 | -## 7. 按站点提交高光录制 | ||
| 194 | - | ||
| 195 | -```http | ||
| 196 | -POST /highlight/recording/by-site | ||
| 197 | -``` | ||
| 198 | - | ||
| 199 | -请求字段与 `/highlight/preview/by-site` 相同。 | ||
| 200 | - | ||
| 201 | -请求: | ||
| 202 | - | ||
| 203 | -```json | ||
| 204 | -{ | ||
| 205 | - "siteId": "xdyui2", | ||
| 206 | - "beginTime": 1785859200000, | ||
| 207 | - "endTime": 1785945599999 | ||
| 208 | -} | ||
| 209 | -``` | ||
| 210 | - | ||
| 211 | -成功响应: | ||
| 212 | - | ||
| 213 | -```json | ||
| 214 | -{ | ||
| 215 | - "code": 0, | ||
| 216 | - "message": "success", | ||
| 217 | - "data": { | ||
| 218 | - "received": 3, | ||
| 219 | - "queued": 2, | ||
| 220 | - "recording": 1, | ||
| 221 | - "uploading": 0, | ||
| 222 | - "generated": 0, | ||
| 223 | - "invalid": 0, | ||
| 224 | - "failed": 0 | ||
| 225 | - } | ||
| 226 | -} | ||
| 227 | -``` | ||
| 228 | - | ||
| 229 | -这里的状态数量是本次提交结果的快照,不是全局队列统计。 | ||
| 230 | - | ||
| 231 | -## 8. 运行高光定时任务 | ||
| 232 | - | ||
| 233 | -```http | ||
| 234 | -POST /highlight/recording/scheduled | ||
| 235 | -``` | ||
| 236 | - | ||
| 237 | -无请求体。该接口与原整堂录制 `GET /recording` 完全独立。 | ||
| 238 | - | ||
| 239 | -当 `sourceMode=site` 时: | ||
| 240 | - | ||
| 241 | -1. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。 | ||
| 242 | -2. 遍历 `HIGHLIGHTCONFIG.siteIds`。 | ||
| 243 | -3. 查询并提交所有有效高光。 | ||
| 244 | - | ||
| 245 | -响应: | ||
| 246 | - | ||
| 247 | -```json | ||
| 248 | -{ | ||
| 249 | - "code": 0, | ||
| 250 | - "message": "success", | ||
| 251 | - "data": { | ||
| 252 | - "mode": "site", | ||
| 253 | - "beginTime": 1785859200000, | ||
| 254 | - "endTime": 1785945599999, | ||
| 255 | - "received": 3, | ||
| 256 | - "queued": 3, | ||
| 257 | - "recording": 0, | ||
| 258 | - "uploading": 0, | ||
| 259 | - "generated": 0, | ||
| 260 | - "invalid": 0, | ||
| 261 | - "failed": 0 | ||
| 262 | - } | ||
| 263 | -} | ||
| 264 | -``` | ||
| 265 | - | ||
| 266 | -当 `sourceMode=task` 时,只处理同时满足以下条件的 SaaS 录制任务: | ||
| 267 | - | ||
| 268 | -```text | ||
| 269 | -status = 0 | ||
| 270 | -onlyHighlight = 1 | ||
| 271 | -siteId 位于 HIGHLIGHTCONFIG.siteIds | ||
| 272 | -``` | 14 | +高光任务只按课堂调用 `/3m/api/highlight/getByClassPrivate.do`。`getBySitePrivate.do` 仅保留给内部批量排查和补录,不应对每个课堂重复调用两个查询接口。 |
| 273 | 15 | ||
| 274 | -响应额外包含: | ||
| 275 | - | ||
| 276 | -```json | ||
| 277 | -{ | ||
| 278 | - "mode": "task", | ||
| 279 | - "sourceTasks": 10, | ||
| 280 | - "pendingTasks": 2 | ||
| 281 | -} | ||
| 282 | -``` | ||
| 283 | - | ||
| 284 | -任务模式目前尚未实现向 SaaS 回写完成状态,不建议在正式环境启用。 | ||
| 285 | - | ||
| 286 | -建议的独立 cron: | ||
| 287 | - | ||
| 288 | -```cron | ||
| 289 | -57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1 | ||
| 290 | -``` | ||
| 291 | - | ||
| 292 | -原整堂录制 cron 保持原样,两者不要使用同一个接口。 | ||
| 293 | - | ||
| 294 | -## 9. 查询课堂全部高光文件 | ||
| 295 | - | ||
| 296 | -```http | ||
| 297 | -POST /highlight/fileExists | ||
| 298 | -``` | ||
| 299 | - | ||
| 300 | -请求: | ||
| 301 | - | ||
| 302 | -```json | ||
| 303 | -{ | ||
| 304 | - "siteId": "xdyui2", | ||
| 305 | - "classId": "2008975651" | ||
| 306 | -} | ||
| 307 | -``` | ||
| 308 | - | ||
| 309 | -全部生成时: | ||
| 310 | - | ||
| 311 | -```json | ||
| 312 | -{ | ||
| 313 | - "code": 0, | ||
| 314 | - "message": "文件已生成", | ||
| 315 | - "onlyHighlight": 1, | ||
| 316 | - "classUrlList": [ | ||
| 317 | - { | ||
| 318 | - "highlightId": 5, | ||
| 319 | - "classId": "2008975651", | ||
| 320 | - "siteId": "xdyui2", | ||
| 321 | - "beginTime": 1785895298000, | ||
| 322 | - "generated": true, | ||
| 323 | - "status": "generated", | ||
| 324 | - "url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260805/2008975651_highlight_5.mp4" | ||
| 325 | - } | ||
| 326 | - ] | ||
| 327 | -} | ||
| 328 | -``` | ||
| 329 | - | ||
| 330 | -只要存在未生成项,就返回: | ||
| 331 | - | ||
| 332 | -```json | ||
| 333 | -{ | ||
| 334 | - "code": 1, | ||
| 335 | - "message": "部分文件未生成", | ||
| 336 | - "onlyHighlight": 1, | ||
| 337 | - "classUrlList": [] | ||
| 338 | -} | ||
| 339 | -``` | ||
| 340 | - | ||
| 341 | -实际 `classUrlList` 仍包含所有已生成和未生成项目;上例省略了项目明细。课堂没有高光时返回 `code=1`、`message=文件未生成`。 | ||
| 342 | - | ||
| 343 | -## 10. 按明细批量查询高光文件 | ||
| 344 | - | ||
| 345 | -```http | ||
| 346 | -POST /highlight/files | ||
| 347 | -``` | ||
| 348 | - | ||
| 349 | -适合调用方已经持有高光 ID 和开始时间、不希望 WebScreen 再按课堂查询 SaaS 的场景。一次最多 1000 条。 | ||
| 350 | - | ||
| 351 | -请求: | ||
| 352 | - | ||
| 353 | -```json | ||
| 354 | -{ | ||
| 355 | - "items": [ | ||
| 356 | - { | ||
| 357 | - "highlightId": 5, | ||
| 358 | - "classId": "2008975651", | ||
| 359 | - "siteId": "xdyui2", | ||
| 360 | - "beginTime": 1785895298000 | ||
| 361 | - } | ||
| 362 | - ] | ||
| 363 | -} | ||
| 364 | -``` | ||
| 365 | - | ||
| 366 | -响应: | ||
| 367 | - | ||
| 368 | -```json | ||
| 369 | -{ | ||
| 370 | - "code": 0, | ||
| 371 | - "message": "success", | ||
| 372 | - "data": [ | ||
| 373 | - { | ||
| 374 | - "highlightId": 5, | ||
| 375 | - "classId": "2008975651", | ||
| 376 | - "siteId": "xdyui2", | ||
| 377 | - "beginTime": 1785895298000, | ||
| 378 | - "generated": false, | ||
| 379 | - "status": "recording", | ||
| 380 | - "url": null | ||
| 381 | - } | ||
| 382 | - ] | ||
| 383 | -} | ||
| 384 | -``` | ||
| 385 | - | ||
| 386 | -## 11. 查询高光队列状态 | ||
| 387 | - | ||
| 388 | -```http | ||
| 389 | -GET /highlight/status | ||
| 390 | -``` | ||
| 391 | - | ||
| 392 | -响应: | ||
| 393 | - | ||
| 394 | -```json | ||
| 395 | -{ | ||
| 396 | - "code": 0, | ||
| 397 | - "message": "success", | ||
| 398 | - "data": { | ||
| 399 | - "queued": 2, | ||
| 400 | - "recording": 1, | ||
| 401 | - "knownTasks": 5 | ||
| 402 | - } | ||
| 403 | -} | ||
| 404 | -``` | ||
| 405 | - | ||
| 406 | -| 字段 | 说明 | | ||
| 407 | -|---|---| | ||
| 408 | -| `queued` | 当前等待录制的任务数 | | ||
| 409 | -| `recording` | 当前正在录制的任务数 | | ||
| 410 | -| `knownTasks` | 当前进程内保留的全部已知任务数 | | ||
| 411 | - | ||
| 412 | -队列状态只保存在当前 Node.js 进程内,PM2 重启后会清空;本地文件和 OSS 文件不会丢失。 | ||
| 413 | - | ||
| 414 | -## 12. 配置 | ||
| 415 | - | ||
| 416 | -配置文件:`config/config.json`。 | ||
| 417 | - | ||
| 418 | -```json | ||
| 419 | -"HIGHLIGHTCONFIG": { | ||
| 420 | - "enabled": true, | ||
| 421 | - "sourceMode": "site", | ||
| 422 | - "siteIds": ["xdyui2"], | ||
| 423 | - "apiBaseUrl": "https://saas.xuedianyun.com", | ||
| 424 | - "pageSize": 100, | ||
| 425 | - "taskPageSize": 100, | ||
| 426 | - "maxPages": 1000, | ||
| 427 | - "maxConcurrent": 2, | ||
| 428 | - "maxDurationMs": 21600000, | ||
| 429 | - "apiTimeoutMs": 10000, | ||
| 430 | - "apiRetryCount": 2, | ||
| 431 | - "apiRetryBaseDelayMs": 500, | ||
| 432 | - "loadGraceMs": 60000, | ||
| 433 | - "endGraceMs": 10000, | ||
| 434 | - "taskRetentionMs": 86400000, | ||
| 435 | - "outputNamespace": "", | ||
| 436 | - "outputBaseUrl": "https://xdymp4.xuedianyun.com" | ||
| 437 | -} | ||
| 438 | -``` | ||
| 439 | - | ||
| 440 | -| 字段 | 说明 | | ||
| 441 | -|---|---| | ||
| 442 | -| `enabled` | 高光业务开关;不影响整堂录制,关闭后状态接口仍可访问 | | ||
| 443 | -| `sourceMode` | 定时入口的数据源:`site` 或 `task` | | ||
| 444 | -| `siteIds` | 允许处理的机构编码列表 | | ||
| 445 | -| `pageSize` | SaaS 高光站点查询分页大小 | | ||
| 446 | -| `taskPageSize` | SaaS 录制任务查询分页大小 | | ||
| 447 | -| `maxPages` | 自动分页安全上限 | | ||
| 448 | -| `maxConcurrent` | 高光录制最大并发数 | | ||
| 449 | -| `maxDurationMs` | 单条高光允许的最大时长 | | ||
| 450 | -| `apiTimeoutMs` | SaaS 接口超时时间 | | ||
| 451 | -| `apiRetryCount` | SaaS 接口失败重试次数 | | ||
| 452 | -| `loadGraceMs` | 回放页面加载预留时间 | | ||
| 453 | -| `endGraceMs` | 录制结束预留时间 | | ||
| 454 | -| `taskRetentionMs` | 完成任务在内存中的保留时间 | | ||
| 455 | -| `outputNamespace` | 可选输出隔离目录;正式环境通常留空 | | ||
| 456 | -| `outputBaseUrl` | OSS 对外访问地址 | | ||
| 457 | - | ||
| 458 | -OSS 文件查询还需要环境变量: | ||
| 459 | - | ||
| 460 | -```text | ||
| 461 | -ALIBABA_CLOUD_ACCESS_KEY_ID | ||
| 462 | -ALIBABA_CLOUD_ACCESS_KEY_SECRET | ||
| 463 | -``` | ||
| 464 | - | ||
| 465 | -## 13. 文件规则 | ||
| 466 | - | ||
| 467 | -```text | ||
| 468 | -文件名:{classId}_highlight_{highlightId}.mp4 | ||
| 469 | -本地: {PROJECTCATALOG}/media/{siteId}/{yyyyMMdd}/{fileName} | ||
| 470 | -OSS: oss/{siteId}/{yyyyMMdd}/{fileName} | ||
| 471 | -URL: {outputBaseUrl}/oss/{siteId}/{yyyyMMdd}/{fileName} | ||
| 472 | -``` | ||
| 473 | - | ||
| 474 | -`yyyyMMdd` 按 `beginTime` 对应的 Asia/Shanghai 日期计算。 | ||
| 475 | - | ||
| 476 | -录制先写入 `{PROJECTCATALOG}/.highlight_tmp`,完成后再移动到正式 `media` 目录。WebScreen 不主动上传 OSS,由服务器已有搬运程序处理,并使用 `download.json` 作为目录完成标记。 | ||
| 477 | - | ||
| 478 | -## 14. WebScreen 依赖的 SaaS 内部接口 | ||
| 479 | - | ||
| 480 | -以下接口由 WebScreen 内部调用,不应由普通前端直接调用。 | ||
| 481 | - | ||
| 482 | -| 方法 | 路径 | 用途 | | ||
| 483 | -|---|---|---| | ||
| 484 | -| POST | `/3m/api/highlight/getByClassPrivate.do` | 根据课堂号获取全部高光 | | ||
| 485 | -| POST | `/3m/api/highlight/getBySitePrivate.do` | 根据站点和时间范围分页获取高光 | | ||
| 486 | -| POST | `/3m/api/recording/getRecordingTasksPrivate.do` | `sourceMode=task` 时获取录制任务 | | ||
| 487 | - | ||
| 488 | -SaaS 高光记录示例: | ||
| 489 | - | ||
| 490 | -```json | ||
| 491 | -{ | ||
| 492 | - "id": 5, | ||
| 493 | - "meetingNumber": "2008975651", | ||
| 494 | - "siteId": "xdyui2", | ||
| 495 | - "beginTime": 1785895298000, | ||
| 496 | - "endTime": 1785895343000, | ||
| 497 | - "type": 0, | ||
| 498 | - "more": "" | ||
| 499 | -} | ||
| 500 | -``` | ||
| 501 | - | ||
| 502 | -字段映射: | ||
| 503 | - | ||
| 504 | -```text | ||
| 505 | -id → highlightId | ||
| 506 | -meetingNumber → classId | ||
| 507 | -``` | ||
| 508 | - | ||
| 509 | -更详细的 SaaS 请求签名和响应说明见 [getByClassPrivate.md](./getByClassPrivate.md) 与 [getBySitePrivate.md](./getBySitePrivate.md)。 | ||
| 510 | - | ||
| 511 | -## 15. 调用顺序建议 | ||
| 512 | - | ||
| 513 | -单课堂人工验证: | ||
| 514 | - | ||
| 515 | -```text | ||
| 516 | -POST /highlight/preview/by-class | ||
| 517 | - → POST /highlight/recording/by-class | ||
| 518 | - → GET /highlight/status | ||
| 519 | - → POST /highlight/fileExists | ||
| 520 | -``` | ||
| 521 | - | ||
| 522 | -每日自动录制: | ||
| 523 | - | ||
| 524 | -```text | ||
| 525 | -cron | ||
| 526 | - → POST /highlight/recording/scheduled | ||
| 527 | - → 查询前一天高光 | ||
| 528 | - → 加入高光队列 | ||
| 529 | - → 生成本地 MP4 和 download.json | ||
| 530 | - → 服务器搬运到 OSS | ||
| 531 | - → POST /highlight/fileExists 查询结果 | ||
| 532 | -``` | 16 | +已有 `/highlight/*` 路由属于内部验证工具,不是正式客户调用链路。 |
| 1 | -# WebScreen 高光录制部署文档 | 1 | +# V2 录制部署说明 |
| 2 | 2 | ||
| 3 | -## 1. 部署范围 | 3 | +## 接口边界 |
| 4 | 4 | ||
| 5 | -当前仅部署到新验证服务器,只启用 `xdyui2`。 | 5 | +- 原 `/recordingTask`、`/fileExists` 保持不变。 |
| 6 | +- `/recordingTaskV2` 兼容原整课请求并支持 `onlyHighlight=1`。 | ||
| 7 | +- `/fileExistsV2` 是新功能的客户查询接口。 | ||
| 8 | +- `/highlight/*` 仅允许运维内网访问。 | ||
| 6 | 9 | ||
| 7 | -不要直接替换现有正式录制服务器。CrazyTalk 等 xdyui2 验证通过后再添加。 | 10 | +## 配置 |
| 8 | 11 | ||
| 9 | -## 2. 环境要求 | ||
| 10 | - | ||
| 11 | -- Node.js 与现有 WebScreen 生产版本一致。 | ||
| 12 | -- `web_capture_c` 可执行文件及依赖完整。 | ||
| 13 | -- PCLive 回放地址能从服务器访问。 | ||
| 14 | -- 服务器时钟已通过 NTP 同步。 | ||
| 15 | -- OSS AccessKey 环境变量已配置,供文件状态查询使用。 | ||
| 16 | -- 原本地文件搬运至 OSS 的定时任务已部署。 | ||
| 17 | - | ||
| 18 | -WebScreen 只监听端口 `3001`。 | ||
| 19 | - | ||
| 20 | -## 3. 安装 | ||
| 21 | - | ||
| 22 | -```bash | ||
| 23 | -cd /root/webScreen | ||
| 24 | -npm install | ||
| 25 | -npm test | ||
| 26 | -``` | ||
| 27 | - | ||
| 28 | -确认录制程序可执行: | ||
| 29 | - | ||
| 30 | -```bash | ||
| 31 | -test -x /root/web_capture_release/linux-x64/web_capture_c | ||
| 32 | -``` | ||
| 33 | - | ||
| 34 | -## 4. 配置 | ||
| 35 | - | ||
| 36 | -保留服务器原 `GETCLASSURLPARAMETER`、`PROJECTWINCATALOG`、`PROJECTCATALOG` 和 `BACKMEDIACONFIG`,增加: | 12 | +启用高光前设置: |
| 37 | 13 | ||
| 38 | ```json | 14 | ```json |
| 39 | "HIGHLIGHTCONFIG": { | 15 | "HIGHLIGHTCONFIG": { |
| 40 | "enabled": true, | 16 | "enabled": true, |
| 41 | - "sourceMode": "site", | ||
| 42 | - "siteIds": ["xdyui2"], | ||
| 43 | "apiBaseUrl": "https://saas.xuedianyun.com", | 17 | "apiBaseUrl": "https://saas.xuedianyun.com", |
| 44 | - "pageSize": 100, | ||
| 45 | - "taskPageSize": 100, | ||
| 46 | - "maxPages": 1000, | ||
| 47 | "maxConcurrent": 2, | 18 | "maxConcurrent": 2, |
| 48 | "maxDurationMs": 21600000, | 19 | "maxDurationMs": 21600000, |
| 49 | - "apiTimeoutMs": 10000, | ||
| 50 | - "apiRetryCount": 2, | ||
| 51 | - "apiRetryBaseDelayMs": 500, | ||
| 52 | - "loadGraceMs": 60000, | ||
| 53 | - "endGraceMs": 10000, | ||
| 54 | - "taskRetentionMs": 86400000, | ||
| 55 | - "outputNamespace": "", | ||
| 56 | "outputBaseUrl": "https://xdymp4.xuedianyun.com" | 20 | "outputBaseUrl": "https://xdymp4.xuedianyun.com" |
| 57 | } | 21 | } |
| 58 | ``` | 22 | ``` |
| 59 | 23 | ||
| 60 | -验证 JSON: | ||
| 61 | - | ||
| 62 | -```bash | ||
| 63 | -node -e "JSON.parse(require('fs').readFileSync('config/config.json')); console.log('config ok')" | ||
| 64 | -``` | ||
| 65 | - | ||
| 66 | -## 5. 启动 | ||
| 67 | - | ||
| 68 | -```bash | ||
| 69 | -npm run pm2 | ||
| 70 | -pm2 show webScreen | ||
| 71 | -curl http://127.0.0.1:3001/highlight/status | ||
| 72 | -``` | 24 | +还需配置 OSS AccessKey、`web_capture_c`、显示服务及现有 OSS 搬运任务。 |
| 73 | 25 | ||
| 74 | -预期: | 26 | +不录制站点已由 xdySDK 上游流程筛除,WebScreen V2 不再维护第二份站点策略。 |
| 75 | 27 | ||
| 76 | -```json | ||
| 77 | -{"code":0,"message":"success","data":{"queued":0,"recording":0,"knownTasks":0}} | ||
| 78 | -``` | ||
| 79 | - | ||
| 80 | -## 6. 首次手工验证 | ||
| 81 | - | ||
| 82 | -先查询某节已知课堂,不启动录制: | ||
| 83 | - | ||
| 84 | -```bash | ||
| 85 | -curl -X POST http://127.0.0.1:3001/highlight/preview/by-class \ | ||
| 86 | - -H 'Content-Type: application/json' \ | ||
| 87 | - -d '{"classId":"课堂号"}' | ||
| 88 | -``` | ||
| 89 | - | ||
| 90 | -检查响应中的: | ||
| 91 | - | ||
| 92 | -- `siteId` 必须是 `xdyui2`。 | ||
| 93 | -- `playbackUrl` 包含 `recBeginTime` 和 `recEndTime`。 | ||
| 94 | -- 文件名包含 `_highlight_高光ID.mp4`。 | ||
| 95 | -- `duration` 等于 `endTime-beginTime`。 | ||
| 96 | - | ||
| 97 | -再手工触发单课堂: | ||
| 98 | - | ||
| 99 | -```bash | ||
| 100 | -curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \ | ||
| 101 | - -H 'Content-Type: application/json' \ | ||
| 102 | - -d '{"classId":"课堂号"}' | ||
| 103 | -``` | ||
| 104 | - | ||
| 105 | -观察: | ||
| 106 | - | ||
| 107 | -```bash | ||
| 108 | -tail -f log/$(date +%Y%m%d).txt | ||
| 109 | -find /root/web_capture_release/media/xdyui2 -type f | ||
| 110 | -``` | 28 | +## 外部 cron |
| 111 | 29 | ||
| 112 | -一个课堂有 N 条高光时,应出现 N 个不同文件。 | 30 | +正式任务链路由现有外部 cron 分发,不使用 WebScreen 本机的 |
| 31 | +`/highlight/recording/scheduled` 作为正式入口。 | ||
| 113 | 32 | ||
| 114 | -## 7. 验证多文件查询 | ||
| 115 | - | ||
| 116 | -```bash | ||
| 117 | -curl -X POST http://127.0.0.1:3001/highlight/fileExists \ | ||
| 118 | - -H 'Content-Type: application/json' \ | ||
| 119 | - -d '{"siteId":"xdyui2","classId":"课堂号"}' | ||
| 120 | -``` | ||
| 121 | - | ||
| 122 | -本地文件等待搬运时,状态应为 `uploading`;OSS 可见后应为 `generated` 并返回 URL。 | ||
| 123 | - | ||
| 124 | -## 8. 验证 cron 全量模式 | ||
| 125 | - | ||
| 126 | -先手工执行独立的高光 cron 入口: | ||
| 127 | - | ||
| 128 | -```bash | ||
| 129 | -curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled | ||
| 130 | -``` | ||
| 131 | - | ||
| 132 | -响应 `data.mode` 应为 `site`,时间窗应为 Asia/Shanghai 前一天。 | ||
| 133 | - | ||
| 134 | -确认无误后配置: | ||
| 135 | - | ||
| 136 | -```cron | ||
| 137 | -57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1 | ||
| 138 | -``` | ||
| 139 | - | ||
| 140 | -## 9. 切换任务模式 | ||
| 141 | - | ||
| 142 | -xdyui2 全量验证完成后,可改为: | ||
| 143 | - | ||
| 144 | -```json | ||
| 145 | -"sourceMode": "task" | ||
| 146 | -``` | ||
| 147 | - | ||
| 148 | -任务模式只处理: | ||
| 149 | - | ||
| 150 | -```text | ||
| 151 | -status = 0 | ||
| 152 | -onlyHighlight = 1 | ||
| 153 | -siteId = xdyui2 | ||
| 154 | -``` | ||
| 155 | - | ||
| 156 | -当前没有任务状态回写接口。重复 cron 依靠队列、本地文件和 OSS 文件跳过,代码中保留 TODO。后端接口确定后再补领取和结果回写。 | ||
| 157 | - | ||
| 158 | -## 10. OSS 搬运验证 | ||
| 159 | - | ||
| 160 | -高光录制完成后,本地目录应包含: | 33 | +cron 继续调用 SaaS 的 `getRecordingTasksPrivate.do`,但需要将目标地址从: |
| 161 | 34 | ||
| 162 | ```text | 35 | ```text |
| 163 | -{classId}_highlight_{highlightId}.mp4 | ||
| 164 | -download.json | 36 | +POST /recordingTask |
| 165 | ``` | 37 | ``` |
| 166 | 38 | ||
| 167 | -等待现有搬运任务执行,再检查: | 39 | +改为: |
| 168 | 40 | ||
| 169 | ```text | 41 | ```text |
| 170 | -https://xdymp4.xuedianyun.com/oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 | 42 | +POST /recordingTaskV2 |
| 171 | ``` | 43 | ``` |
| 172 | 44 | ||
| 173 | -确认搬运程序不会只匹配旧 `{classId}.mp4` 文件名。 | ||
| 174 | - | ||
| 175 | -## 11. 回滚 | ||
| 176 | - | ||
| 177 | -最快业务回滚: | 45 | +原来的字段映射可以继续使用,只需增加 `onlyHighlight`: |
| 178 | 46 | ||
| 179 | ```json | 47 | ```json |
| 180 | -"HIGHLIGHTCONFIG": { | ||
| 181 | - "enabled": false | 48 | +{ |
| 49 | + "classId": "task.meetingNumber", | ||
| 50 | + "siteId": "task.siteId", | ||
| 51 | + "yymmdd": "原有课堂日期", | ||
| 52 | + "onlyHighlight": "task.onlyHighlight" | ||
| 182 | } | 53 | } |
| 183 | ``` | 54 | ``` |
| 184 | 55 | ||
| 185 | -然后: | ||
| 186 | - | ||
| 187 | -```bash | ||
| 188 | -pm2 restart webScreen | ||
| 189 | -``` | 56 | +也可以不做映射,直接转发 SaaS 的完整 `taskList`;V2 同时兼容 |
| 57 | +`classId/meetingNumber`、`taskId/id` 两种字段名。`id` 和 | ||
| 58 | +`beginTime/endTime` 不是状态回写的必填字段。 | ||
| 190 | 59 | ||
| 191 | -关闭后,高光查询、录制和文件检查接口停止处理,状态接口仍可访问;`GET /recording` 始终运行原整堂录制逻辑。高光使用独立文件名,不覆盖旧整堂 MP4。 | 60 | +## 灰度与回滚 |
| 192 | 61 | ||
| 193 | -## 12. 上线 CrazyTalk 前检查 | 62 | +1. 用旧格式调用 `/recordingTaskV2`,确认只生成整课文件。 |
| 63 | +2. 用 `onlyHighlight=1` 调用,确认只生成高光文件。 | ||
| 64 | +3. 验证 `/fileExistsV2` 可分别查询两种任务。 | ||
| 65 | +4. 确认两个老接口的请求和响应没有变化。 | ||
| 194 | 66 | ||
| 195 | -1. 获取 CrazyTalk 准确、区分大小写的 `siteId`。 | ||
| 196 | -2. xdyui2 连续验证多天,无重复、缺段和错误路径。 | ||
| 197 | -3. 确认 OSS 搬运支持高光文件名。 | ||
| 198 | -4. 确认任务状态回写方案。 | ||
| 199 | -5. 将 CrazyTalk 加入 `HIGHLIGHTCONFIG.siteIds`,不要改旧 `GETCLASSURLPARAMETER.siteId`。 | 67 | +停用新功能时停止调用两个 V2 接口即可,原接口继续运行。 |
| 1 | -# WebScreen 高光录制部署、使用与测试手册 | 1 | +# V2 录制验收测试 |
| 2 | 2 | ||
| 3 | -## 1. 范围 | 3 | +## 1. 整课兼容 |
| 4 | 4 | ||
| 5 | -本文用于同事在新 Linux 服务器部署 WebScreen,并只对 `xdyui2` 验证高光 MP4 录制。 | 5 | +向 `/recordingTaskV2` 提交不含 `onlyHighlight` 的原请求,预期只调用原整课录制代码,不调用 SaaS 高光接口。 |
| 6 | 6 | ||
| 7 | -当前规则: | 7 | +## 2. 仅高光 |
| 8 | 8 | ||
| 9 | -- 一个高光时间段生成一个 MP4。 | ||
| 10 | -- 一个课堂有 N 条高光,生成 N 个 MP4。 | ||
| 11 | -- 全量模式录制 xdyui2 前一天全部高光。 | ||
| 12 | -- 任务模式只处理 `status=0 && onlyHighlight=1`。 | ||
| 13 | -- WebScreen 只生成本地文件,服务器原任务负责搬运到 OSS。 | ||
| 14 | -- 暂不启用 CrazyTalk。 | ||
| 15 | - | ||
| 16 | -不要直接覆盖现有正式录制服务器。 | ||
| 17 | - | ||
| 18 | -## 2. 交付物和外部依赖 | ||
| 19 | - | ||
| 20 | -项目包: | ||
| 21 | - | ||
| 22 | -```text | ||
| 23 | -webScreen-full-latest.zip | ||
| 24 | -``` | ||
| 25 | - | ||
| 26 | -包内含源码、Git 历史、`.env`、`node_modules`、文档和测试。`node_modules` 来自 macOS,Linux 必须重新安装。 | ||
| 27 | - | ||
| 28 | -项目不包含 `web_capture_c`。需从现有录制服务器复制完整运行目录: | ||
| 29 | - | ||
| 30 | -```text | ||
| 31 | -/root/web_capture_release | ||
| 32 | -/root/web_capture_release/linux-x64/web_capture_c | ||
| 33 | -``` | ||
| 34 | - | ||
| 35 | -同时复制其动态库、字体、浏览器运行环境和显示服务配置。 | ||
| 36 | - | ||
| 37 | -## 3. 部署前检查 | ||
| 38 | - | ||
| 39 | -```bash | ||
| 40 | -date | ||
| 41 | -timedatectl | ||
| 42 | -node -v | ||
| 43 | -npm -v | ||
| 44 | -ss -lntp | grep ':3001' | ||
| 45 | -df -h /root | ||
| 46 | -test -x /root/web_capture_release/linux-x64/web_capture_c && echo CAPTURE_OK | ||
| 47 | -``` | ||
| 48 | - | ||
| 49 | -要求: | ||
| 50 | - | ||
| 51 | -- 时区为 `Asia/Shanghai`,NTP 已同步。 | ||
| 52 | -- Node.js 与现有正式服务器一致。 | ||
| 53 | -- 端口 `3001` 未被占用。 | ||
| 54 | -- 录制程序可执行,磁盘空间充足。 | ||
| 55 | - | ||
| 56 | -## 4. 解压和安装 | ||
| 57 | - | ||
| 58 | -```bash | ||
| 59 | -cd /root | ||
| 60 | -unzip webScreen-full-latest.zip | ||
| 61 | -cd /root/webScreen | ||
| 62 | -mv node_modules node_modules.macos.bak | ||
| 63 | -npm install | ||
| 64 | -npm test | ||
| 65 | -``` | ||
| 66 | - | ||
| 67 | -预期测试输出: | ||
| 68 | - | ||
| 69 | -```text | ||
| 70 | -highlightRecordingService tests passed | ||
| 71 | -highlight routes tests passed | ||
| 72 | -``` | ||
| 73 | - | ||
| 74 | -## 5. 环境变量 | ||
| 75 | - | ||
| 76 | -`.env` 需要包含: | ||
| 77 | - | ||
| 78 | -```text | ||
| 79 | -ALIBABA_CLOUD_ACCESS_KEY_ID=... | ||
| 80 | -ALIBABA_CLOUD_ACCESS_KEY_SECRET=... | ||
| 81 | -``` | ||
| 82 | - | ||
| 83 | -只检查是否存在: | ||
| 84 | - | ||
| 85 | -```bash | ||
| 86 | -grep -q '^ALIBABA_CLOUD_ACCESS_KEY_ID=' .env && echo ACCESS_KEY_ID_OK | ||
| 87 | -grep -q '^ALIBABA_CLOUD_ACCESS_KEY_SECRET=' .env && echo ACCESS_KEY_SECRET_OK | ||
| 88 | -``` | ||
| 89 | - | ||
| 90 | -这两个变量只用于查询 OSS 状态;WebScreen 不主动上传文件。 | ||
| 91 | - | ||
| 92 | -## 6. 配置 | ||
| 93 | - | ||
| 94 | -编辑 `/root/webScreen/config/config.json`。保留服务器原有: | ||
| 95 | - | ||
| 96 | -- `GETCLASSURL` | ||
| 97 | -- `GETCLASSURLPARAMETER` | ||
| 98 | -- `PROJECTWINCATALOG` | ||
| 99 | -- `PROJECTCATALOG` | ||
| 100 | -- `BACKMEDIACONFIG` | ||
| 101 | -- `classLastNumber` | ||
| 102 | - | ||
| 103 | -目录应与服务器一致: | ||
| 104 | - | ||
| 105 | -```json | ||
| 106 | -"PROJECTWINCATALOG": "/root/web_capture_release/linux-x64", | ||
| 107 | -"PROJECTCATALOG": "/root/web_capture_release" | ||
| 108 | -``` | ||
| 109 | - | ||
| 110 | -`BACKMEDIACONFIG.url` 必须指向已支持 `recBeginTime/recEndTime` 的 PCLive 测试版本。保留验证服务器已确认可用的 `devback` 或测试路径,不在代码中硬编码。 | ||
| 111 | - | ||
| 112 | -高光配置: | ||
| 113 | - | ||
| 114 | -```json | ||
| 115 | -"HIGHLIGHTCONFIG": { | ||
| 116 | - "enabled": false, | ||
| 117 | - "sourceMode": "site", | ||
| 118 | - "siteIds": ["xdyui2"], | ||
| 119 | - "apiBaseUrl": "https://saas.xuedianyun.com", | ||
| 120 | - "pageSize": 100, | ||
| 121 | - "taskPageSize": 100, | ||
| 122 | - "maxPages": 1000, | ||
| 123 | - "maxConcurrent": 2, | ||
| 124 | - "maxDurationMs": 21600000, | ||
| 125 | - "apiTimeoutMs": 10000, | ||
| 126 | - "apiRetryCount": 2, | ||
| 127 | - "apiRetryBaseDelayMs": 500, | ||
| 128 | - "loadGraceMs": 60000, | ||
| 129 | - "endGraceMs": 10000, | ||
| 130 | - "taskRetentionMs": 86400000, | ||
| 131 | - "outputNamespace": "", | ||
| 132 | - "outputBaseUrl": "https://xdymp4.xuedianyun.com" | ||
| 133 | -} | ||
| 134 | -``` | ||
| 135 | - | ||
| 136 | -首次启动保持 `enabled=false`。验证 JSON: | ||
| 137 | - | ||
| 138 | -```bash | ||
| 139 | -node -e "JSON.parse(require('fs').readFileSync('config/config.json')); console.log('config ok')" | ||
| 140 | -``` | ||
| 141 | - | ||
| 142 | -## 7. 启动服务 | ||
| 143 | - | ||
| 144 | -测试期间先不要配置 cron。 | ||
| 145 | - | ||
| 146 | -```bash | ||
| 147 | -npm run pm2 | ||
| 148 | -pm2 show webScreen | ||
| 149 | -curl http://127.0.0.1:3001/highlight/status | ||
| 150 | -``` | ||
| 151 | - | ||
| 152 | -若 PM2 已有同名进程: | ||
| 153 | - | ||
| 154 | -```bash | ||
| 155 | -pm2 restart webScreen | ||
| 156 | -``` | ||
| 157 | - | ||
| 158 | -状态接口预期: | 9 | +提交: |
| 159 | 10 | ||
| 160 | ```json | 11 | ```json |
| 161 | { | 12 | { |
| 162 | - "code": 0, | ||
| 163 | - "message": "success", | ||
| 164 | - "data": { | ||
| 165 | - "queued": 0, | ||
| 166 | - "recording": 0, | ||
| 167 | - "knownTasks": 0 | ||
| 168 | - } | 13 | + "list": [{ |
| 14 | + "id": "task-high-1", | ||
| 15 | + "siteId": "doctest", | ||
| 16 | + "meetingNumber": "487012832", | ||
| 17 | + "beginTime": "2026-08-05 10:00:00", | ||
| 18 | + "endTime": "2026-08-05 11:00:00", | ||
| 19 | + "onlyHighlight": 1 | ||
| 20 | + }] | ||
| 169 | } | 21 | } |
| 170 | ``` | 22 | ``` |
| 171 | 23 | ||
| 172 | -查看日志: | ||
| 173 | - | ||
| 174 | -```bash | ||
| 175 | -pm2 logs webScreen --lines 100 | ||
| 176 | -``` | ||
| 177 | - | ||
| 178 | -## 8. 开启 xdyui2 | ||
| 179 | - | ||
| 180 | -基础服务正常后修改: | ||
| 181 | - | ||
| 182 | -```json | ||
| 183 | -"enabled": true, | ||
| 184 | -"sourceMode": "site", | ||
| 185 | -"siteIds": ["xdyui2"] | ||
| 186 | -``` | ||
| 187 | - | ||
| 188 | -```bash | ||
| 189 | -pm2 restart webScreen | ||
| 190 | -``` | ||
| 191 | - | ||
| 192 | -## 9. 准备测试课堂 | ||
| 193 | - | ||
| 194 | -选择一个 xdyui2 已结束课堂: | ||
| 195 | - | ||
| 196 | -- 至少两条高光。 | ||
| 197 | -- 回放正常。 | ||
| 198 | -- 最好含教师、学生音视频、屏幕共享和声音。 | ||
| 199 | - | ||
| 200 | -记录: | ||
| 201 | - | ||
| 202 | -```text | ||
| 203 | -siteId:xdyui2 | ||
| 204 | -classId:__________ | ||
| 205 | -高光数量:__________ | ||
| 206 | -课堂日期:__________ | ||
| 207 | -``` | ||
| 208 | - | ||
| 209 | -## 10. 只读预览 | ||
| 210 | - | ||
| 211 | -预览不会启动录制程序: | ||
| 212 | - | ||
| 213 | -```bash | ||
| 214 | -curl -X POST http://127.0.0.1:3001/highlight/preview/by-class \ | ||
| 215 | - -H 'Content-Type: application/json' \ | ||
| 216 | - -d '{"classId":"替换为课堂号"}' | ||
| 217 | -``` | ||
| 218 | - | ||
| 219 | -逐条检查: | ||
| 220 | - | ||
| 221 | -- `siteId` 是 `xdyui2`。 | ||
| 222 | -- `highlightId` 不重复。 | ||
| 223 | -- `classId` 正确。 | ||
| 224 | -- `beginTime/endTime` 是 13 位毫秒时间戳。 | ||
| 225 | -- `duration = endTime - beginTime`。 | ||
| 226 | -- `playbackUrl` 含正确的 `recBeginTime/recEndTime`。 | ||
| 227 | -- 文件名为 `{classId}_highlight_{highlightId}.mp4`。 | ||
| 228 | -- 路径位于 `/root/web_capture_release/media/xdyui2/{yyyyMMdd}/`。 | ||
| 229 | - | ||
| 230 | -返回“无高光数据”时,先让后端确认高光表确实存在记录。 | ||
| 231 | - | ||
| 232 | -## 11. 单课堂录制 | ||
| 233 | - | ||
| 234 | -```bash | ||
| 235 | -curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \ | ||
| 236 | - -H 'Content-Type: application/json' \ | ||
| 237 | - -d '{"classId":"替换为课堂号"}' | ||
| 238 | -``` | ||
| 239 | - | ||
| 240 | -新任务初始状态应为 `queued`;已有文件可能返回 `uploading` 或 `generated`。 | ||
| 241 | - | ||
| 242 | -观察队列和进程: | 24 | +验证: |
| 243 | 25 | ||
| 244 | -```bash | ||
| 245 | -watch -n 2 'curl -s http://127.0.0.1:3001/highlight/status' | ||
| 246 | -tail -f /root/webScreen/log/$(date +%Y%m%d).txt | ||
| 247 | -ps -ef | grep '[w]eb_capture_c' | ||
| 248 | -``` | ||
| 249 | - | ||
| 250 | -响应返回后不能立即停止 PM2;录制在后台队列继续执行。 | 26 | +- 只调用一次 `getByClassPrivate.do`。 |
| 27 | +- N 条有效高光生成 N 个文件,不生成整课文件。 | ||
| 28 | +- 时间范围外或站点、课堂不匹配的数据被忽略。 | ||
| 29 | +- 正常空数组计入 `noMedia` 并回写 `status=3`。 | ||
| 30 | +- 有高光时在全部本地录制完成后回写 `status=2`,返回 `code=0` 即成功。 | ||
| 31 | +- 接口超时或错误不能回写 `status=3`。 | ||
| 251 | 32 | ||
| 252 | -## 12. 本地文件验证 | 33 | +## 3. 查询 |
| 253 | 34 | ||
| 254 | -```bash | ||
| 255 | -find /root/web_capture_release/media/xdyui2 -type f -name '课堂号_highlight_*.mp4' -ls | ||
| 256 | -``` | ||
| 257 | - | ||
| 258 | -要求: | ||
| 259 | - | ||
| 260 | -- N 条高光生成 N 个文件。 | ||
| 261 | -- 文件名中的 `highlightId` 不同。 | ||
| 262 | -- 文件大小大于 0。 | ||
| 263 | -- 不覆盖旧 `{classId}.mp4`。 | ||
| 264 | -- 日期目录包含 `download.json`。 | ||
| 265 | -- `.highlight_tmp` 无本次任务残留。 | ||
| 266 | - | ||
| 267 | -如有 ffprobe: | ||
| 268 | - | ||
| 269 | -```bash | ||
| 270 | -ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 /完整/文件路径.mp4 | ||
| 271 | -``` | 35 | +- `/fileExistsV2` 不触发录制或 SaaS 查询。 |
| 36 | +- 整课任务兼容返回 `classUrl`。 | ||
| 37 | +- 高光任务返回全部高光 URL。 | ||
| 38 | +- 任一目标文件缺失时 `fileExists=false`。 | ||
| 272 | 39 | ||
| 273 | -人工播放检查: | 40 | +## 4. 隔离 |
| 274 | 41 | ||
| 275 | -- 开始位置接近 `beginTime`。 | ||
| 276 | -- 结束位置接近 `endTime`。 | ||
| 277 | -- 教师、学生、屏幕共享画面正常。 | ||
| 278 | -- 声音正常。 | ||
| 279 | -- 文件不是整堂课堂。 | ||
| 280 | - | ||
| 281 | -## 13. 重复录制验证 | ||
| 282 | - | ||
| 283 | -再次调用同一课堂的 `/highlight/recording/by-class`。 | ||
| 284 | - | ||
| 285 | -要求: | ||
| 286 | - | ||
| 287 | -- 不再启动新的 `web_capture_c`。 | ||
| 288 | -- 文件数量不增加。 | ||
| 289 | -- 原文件不被覆盖。 | ||
| 290 | -- OSS 已有文件时也不重复录制。 | ||
| 291 | - | ||
| 292 | -## 14. OSS 搬运和多地址查询 | ||
| 293 | - | ||
| 294 | -等待服务器原搬运任务执行。预期 OSS Key: | ||
| 295 | - | ||
| 296 | -```text | ||
| 297 | -oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 | ||
| 298 | -``` | ||
| 299 | - | ||
| 300 | -查询: | ||
| 301 | - | ||
| 302 | -```bash | ||
| 303 | -curl -X POST http://127.0.0.1:3001/highlight/fileExists \ | ||
| 304 | - -H 'Content-Type: application/json' \ | ||
| 305 | - -d '{"siteId":"xdyui2","classId":"替换为课堂号"}' | ||
| 306 | -``` | ||
| 307 | - | ||
| 308 | -OSS 尚不可见时: | ||
| 309 | - | ||
| 310 | -```text | ||
| 311 | -status = uploading | ||
| 312 | -generated = false | ||
| 313 | -``` | ||
| 314 | - | ||
| 315 | -OSS 可见后: | ||
| 316 | - | ||
| 317 | -```text | ||
| 318 | -status = generated | ||
| 319 | -generated = true | ||
| 320 | -url = https://xdymp4.xuedianyun.com/oss/... | ||
| 321 | -``` | ||
| 322 | - | ||
| 323 | -N 条高光时,`classUrlList` 必须有 N 条记录。 | ||
| 324 | - | ||
| 325 | -## 15. 前一天全量验证 | ||
| 326 | - | ||
| 327 | -保持: | ||
| 328 | - | ||
| 329 | -```json | ||
| 330 | -"sourceMode": "site", | ||
| 331 | -"siteIds": ["xdyui2"] | ||
| 332 | -``` | ||
| 333 | - | ||
| 334 | -手工调用 cron 入口: | ||
| 335 | - | ||
| 336 | -```bash | ||
| 337 | -curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled | ||
| 338 | -``` | ||
| 339 | - | ||
| 340 | -检查: | ||
| 341 | - | ||
| 342 | -- `code` 为 `"0"`。 | ||
| 343 | -- `data.mode` 为 `site`。 | ||
| 344 | -- `beginTime/endTime` 是 Asia/Shanghai 前一天。 | ||
| 345 | -- 只查询 xdyui2。 | ||
| 346 | -- 前一天每条有效高光都进入队列。 | ||
| 347 | - | ||
| 348 | -## 16. 任务模式验证 | ||
| 349 | - | ||
| 350 | -全量模式通过后改为: | ||
| 351 | - | ||
| 352 | -```json | ||
| 353 | -"sourceMode": "task" | ||
| 354 | -``` | ||
| 355 | - | ||
| 356 | -```bash | ||
| 357 | -pm2 restart webScreen | ||
| 358 | -``` | ||
| 359 | - | ||
| 360 | -请后端创建四组任务: | ||
| 361 | - | ||
| 362 | -1. `status=0, onlyHighlight=1, siteId=xdyui2`:应录制。 | ||
| 363 | -2. `status!=0, onlyHighlight=1`:不录制。 | ||
| 364 | -3. `status=0, onlyHighlight=0`:不进入高光录制。 | ||
| 365 | -4. 其他站点 `status=0, onlyHighlight=1`:不录制。 | ||
| 366 | - | ||
| 367 | -```bash | ||
| 368 | -curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled | ||
| 369 | -``` | ||
| 370 | - | ||
| 371 | -检查: | ||
| 372 | - | ||
| 373 | -- `data.mode` 为 `task`。 | ||
| 374 | -- `pendingTasks` 只统计第一类任务。 | ||
| 375 | -- 使用 `taskList.meetingNumber` 查询课堂高光。 | ||
| 376 | - | ||
| 377 | -后端尚未确认任务状态回写接口。当前依靠内存队列、本地文件和 OSS 文件避免重复录制。 | ||
| 378 | - | ||
| 379 | -## 17. 启用每日 cron | ||
| 380 | - | ||
| 381 | -只有单课堂、重复录制、OSS 搬运和全量模式全部通过后才启用: | ||
| 382 | - | ||
| 383 | -```cron | ||
| 384 | -57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1 | ||
| 385 | -``` | ||
| 386 | - | ||
| 387 | -```bash | ||
| 388 | -crontab -l | ||
| 389 | -``` | ||
| 390 | - | ||
| 391 | -任务每天 `07:57` 执行。任务模式中新任务最多等待约 24 小时,业务已确认可接受。 | ||
| 392 | - | ||
| 393 | -## 18. 日常使用 | ||
| 394 | - | ||
| 395 | -```bash | ||
| 396 | -pm2 show webScreen | ||
| 397 | -curl http://127.0.0.1:3001/highlight/status | ||
| 398 | -pm2 logs webScreen --lines 200 | ||
| 399 | -tail -n 200 /root/webScreen/log/$(date +%Y%m%d).txt | ||
| 400 | -``` | ||
| 401 | - | ||
| 402 | -手工补录: | ||
| 403 | - | ||
| 404 | -```bash | ||
| 405 | -curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \ | ||
| 406 | - -H 'Content-Type: application/json' \ | ||
| 407 | - -d '{"classId":"课堂号"}' | ||
| 408 | -``` | ||
| 409 | - | ||
| 410 | -查询地址: | ||
| 411 | - | ||
| 412 | -```bash | ||
| 413 | -curl -X POST http://127.0.0.1:3001/highlight/fileExists \ | ||
| 414 | - -H 'Content-Type: application/json' \ | ||
| 415 | - -d '{"siteId":"xdyui2","classId":"课堂号"}' | ||
| 416 | -``` | ||
| 417 | - | ||
| 418 | -## 19. 故障排查 | ||
| 419 | - | ||
| 420 | -### 高光录制功能未启用 | ||
| 421 | - | ||
| 422 | -检查 `HIGHLIGHTCONFIG.enabled=true`,修改后执行 `pm2 restart webScreen`。 | ||
| 423 | - | ||
| 424 | -### SaaS 接口 code=4 | ||
| 425 | - | ||
| 426 | -检查服务器时间、NTP、`apiBaseUrl` 和最新代码。签名时间戳必须是 13 位毫秒。 | ||
| 427 | - | ||
| 428 | -### 无高光数据 | ||
| 429 | - | ||
| 430 | -检查高光表记录、`meetingNumber`、`siteId=xdyui2`,以及全量模式时间窗是否为前一天。 | ||
| 431 | - | ||
| 432 | -### web_capture_c 启动失败 | ||
| 433 | - | ||
| 434 | -```bash | ||
| 435 | -ls -l /root/web_capture_release/linux-x64/web_capture_c | ||
| 436 | -ldd /root/web_capture_release/linux-x64/web_capture_c | ||
| 437 | -echo "$DISPLAY" | ||
| 438 | -``` | ||
| 439 | - | ||
| 440 | -比较正式服务器的显示服务、字体、浏览器依赖和 PM2 环境变量。 | ||
| 441 | - | ||
| 442 | -### 一直是 uploading | ||
| 443 | - | ||
| 444 | -检查搬运任务是否运行,是否支持 `{classId}_highlight_{highlightId}.mp4`、`download.json` 和 `oss/xdyui2/{yyyyMMdd}/`。 | ||
| 445 | - | ||
| 446 | -### OSS文件状态查询失败 | ||
| 447 | - | ||
| 448 | -检查 `.env`、AccessKey 权限、OSS 网络和 bucket `xdymp4`。OSS 查询异常不会被当作“文件不存在”。 | ||
| 449 | - | ||
| 450 | -### 视频时间错误 | ||
| 451 | - | ||
| 452 | -确认 `playbackUrl` 的 `recBeginTime/recEndTime` 与接口原值完全一致。不能传 duration,不能换算相对秒数。 | ||
| 453 | - | ||
| 454 | -## 20. 回滚 | ||
| 455 | - | ||
| 456 | -优先使用配置回滚: | ||
| 457 | - | ||
| 458 | -```json | ||
| 459 | -"HIGHLIGHTCONFIG": { | ||
| 460 | - "enabled": false | ||
| 461 | -} | ||
| 462 | -``` | ||
| 463 | - | ||
| 464 | -```bash | ||
| 465 | -pm2 restart webScreen | ||
| 466 | -``` | ||
| 467 | - | ||
| 468 | -关闭后,高光查询、录制和文件检查接口停止处理,状态接口仍可访问;`GET /recording` 始终执行原整堂录制逻辑。`POST /recording`、`POST /recordingTask`、`POST /fileExists` 和实时录制接口不变。 | ||
| 469 | - | ||
| 470 | -## 21. 测试记录模板 | ||
| 471 | - | ||
| 472 | -```text | ||
| 473 | -测试服务器: | ||
| 474 | -测试日期: | ||
| 475 | -测试人员: | ||
| 476 | -Git 提交(执行 git rev-parse --short HEAD): | ||
| 477 | -站点:xdyui2 | ||
| 478 | -课堂号: | ||
| 479 | -高光记录数: | ||
| 480 | -本地 MP4 数量: | ||
| 481 | -OSS MP4 数量: | ||
| 482 | -预览接口:通过 / 失败 | ||
| 483 | -单课堂录制:通过 / 失败 | ||
| 484 | -时间范围:通过 / 失败 | ||
| 485 | -音频:通过 / 失败 | ||
| 486 | -教师视频:通过 / 失败 | ||
| 487 | -学生视频:通过 / 失败 | ||
| 488 | -屏幕共享:通过 / 失败 | ||
| 489 | -重复录制:通过 / 失败 | ||
| 490 | -OSS 搬运:通过 / 失败 | ||
| 491 | -多地址查询:通过 / 失败 | ||
| 492 | -前一天全量:通过 / 失败 | ||
| 493 | -任务模式:通过 / 失败 / 未测试 | ||
| 494 | -问题记录: | ||
| 495 | -结论:可继续验证 / 需要修复 | ||
| 496 | -``` | 42 | +- `/recordingTask` 不进入 V2 服务。 |
| 43 | +- `/fileExists` 不进入 V2 服务。 | ||
| 44 | +- V2 停用后原整课录制与查询继续工作。 |
| 1 | -# WebScreen 高光时刻 MP4 录制 Spec | 1 | +# 高光 MP4 内部约定 |
| 2 | 2 | ||
| 3 | -## 1. 目标 | 3 | +- 一个 SaaS 高光记录生成一个独立 MP4。 |
| 4 | +- 文件名为 `{classId}_highlight_{highlightId}.mp4`。 | ||
| 5 | +- 回放地址携带绝对毫秒时间戳 `recBeginTime/recEndTime`。 | ||
| 6 | +- 录制进程使用参数数组和 `spawn(..., {shell:false})`,不拼接未校验的 shell 输入。 | ||
| 7 | +- 临时文件完成并校验非空后,原子移动到正式 `media` 目录。 | ||
| 8 | +- 相同 `siteId + highlightId` 不重复进入队列。 | ||
| 9 | +- 本地文件或 OSS 对象已存在时不重复录制。 | ||
| 10 | +- SaaS 高光接口失败不能当作“没有高光”。 | ||
| 11 | +- V2 客户查询只读取任务创建时保存的高光清单。 | ||
| 4 | 12 | ||
| 5 | -WebScreen 根据 SaaS 高光记录,把每个 `beginTime/endTime` 时间段录制成独立 MP4。 | ||
| 6 | - | ||
| 7 | -已确认: | ||
| 8 | - | ||
| 9 | -- 一个课堂可以有多条高光。 | ||
| 10 | -- 一个高光时间段生成一个 MP4;N 条高光生成 N 个文件。 | ||
| 11 | -- 当前只在 `xdyui2` 测试,验证后再增加 CrazyTalk。 | ||
| 12 | -- WebScreen 只生成本地文件;服务器已有任务定时移动到 OSS。 | ||
| 13 | -- 高光定时任务使用独立的 `POST /highlight/recording/scheduled`,不占用整堂录制入口。 | ||
| 14 | -- 任务模式暂时只处理 `status=0`;后端状态回写规则待确认。 | ||
| 15 | - | ||
| 16 | -## 2. 兼容原则 | ||
| 17 | - | ||
| 18 | -原 `GET /recording` 始终执行整堂录制逻辑,不读取高光开关。 | ||
| 19 | - | ||
| 20 | -```json | ||
| 21 | -{ | ||
| 22 | - "HIGHLIGHTCONFIG": { | ||
| 23 | - "enabled": false | ||
| 24 | - } | ||
| 25 | -} | ||
| 26 | -``` | ||
| 27 | - | ||
| 28 | -只有显式配置 `enabled: true` 才能调用 `/highlight/*` 高光业务接口。关闭高光不会关闭或改变整堂录制。 | ||
| 29 | - | ||
| 30 | -以下接口保持原行为: | ||
| 31 | - | ||
| 32 | -- `GET /recording` | ||
| 33 | -- `POST /recording` | ||
| 34 | -- `POST /recordingTask` | ||
| 35 | -- `POST /fileExists` | ||
| 36 | -- `POST /mp4record/recording/:id` | ||
| 37 | - | ||
| 38 | -高光文件查询使用独立接口 `POST /highlight/fileExists`,不改变原 `/fileExists` 单文件响应。 | ||
| 39 | - | ||
| 40 | -## 3. 定时触发 | ||
| 41 | - | ||
| 42 | -WebScreen 本身不创建 cron。高光需要独立 cron: | ||
| 43 | - | ||
| 44 | -```cron | ||
| 45 | -57 7 * * * curl -fsS -X POST http://127.0.0.1:3001/highlight/recording/scheduled >/dev/null 2>&1 | ||
| 46 | -``` | ||
| 47 | - | ||
| 48 | -`POST /highlight/recording/scheduled` 读取 `HIGHLIGHTCONFIG.sourceMode`: | ||
| 49 | - | ||
| 50 | -- `site`:全量高光模式。 | ||
| 51 | -- `task`:指定课堂任务模式。 | ||
| 52 | - | ||
| 53 | -这里“全量”表示录制指定站点前一天的全部高光,不表示录制整堂课堂。 | ||
| 54 | - | ||
| 55 | -## 4. 配置 | ||
| 56 | - | ||
| 57 | -当前 xdyui2 测试配置: | ||
| 58 | - | ||
| 59 | -```json | ||
| 60 | -{ | ||
| 61 | - "HIGHLIGHTCONFIG": { | ||
| 62 | - "enabled": true, | ||
| 63 | - "sourceMode": "site", | ||
| 64 | - "siteIds": ["xdyui2"], | ||
| 65 | - "apiBaseUrl": "https://saas.xuedianyun.com", | ||
| 66 | - "pageSize": 100, | ||
| 67 | - "taskPageSize": 100, | ||
| 68 | - "maxPages": 1000, | ||
| 69 | - "maxConcurrent": 2, | ||
| 70 | - "maxDurationMs": 21600000, | ||
| 71 | - "apiTimeoutMs": 10000, | ||
| 72 | - "apiRetryCount": 2, | ||
| 73 | - "apiRetryBaseDelayMs": 500, | ||
| 74 | - "loadGraceMs": 60000, | ||
| 75 | - "endGraceMs": 10000, | ||
| 76 | - "taskRetentionMs": 86400000, | ||
| 77 | - "outputNamespace": "", | ||
| 78 | - "outputBaseUrl": "https://xdymp4.xuedianyun.com" | ||
| 79 | - } | ||
| 80 | -} | ||
| 81 | -``` | ||
| 82 | - | ||
| 83 | -`BACKMEDIACONFIG.url` 继续由服务器配置决定,代码不硬编码 `dev`、`devback` 或 `release`。 | ||
| 84 | - | ||
| 85 | -## 5. 全量高光模式 | ||
| 86 | - | ||
| 87 | -配置: | ||
| 88 | - | ||
| 89 | -```json | ||
| 90 | -"sourceMode": "site" | ||
| 91 | -``` | ||
| 92 | - | ||
| 93 | -流程: | ||
| 94 | - | ||
| 95 | -1. cron 调用 `POST /highlight/recording/scheduled`。 | ||
| 96 | -2. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。 | ||
| 97 | -3. 遍历 `HIGHLIGHTCONFIG.siteIds`。 | ||
| 98 | -4. 分页调用 `getBySitePrivate.do`。 | ||
| 99 | -5. 每条有效高光加入录制队列。 | ||
| 100 | -6. 一个时间段生成一个本地 MP4。 | ||
| 101 | - | ||
| 102 | -接口: | ||
| 103 | - | ||
| 104 | -```http | ||
| 105 | -POST /3m/api/highlight/getBySitePrivate.do | ||
| 106 | -Content-Type: application/x-www-form-urlencoded | ||
| 107 | -``` | ||
| 108 | - | ||
| 109 | -签名: | ||
| 110 | - | ||
| 111 | -```text | ||
| 112 | -authId = MD5(siteId + timestamp) | ||
| 113 | -``` | ||
| 114 | - | ||
| 115 | -## 6. 指定课堂任务模式 | ||
| 116 | - | ||
| 117 | -配置: | ||
| 118 | - | ||
| 119 | -```json | ||
| 120 | -"sourceMode": "task" | ||
| 121 | -``` | ||
| 122 | - | ||
| 123 | -流程: | ||
| 124 | - | ||
| 125 | -1. 分页调用 `getRecordingTasksPrivate.do`。 | ||
| 126 | -2. 只保留 `status=0 && onlyHighlight=1`。 | ||
| 127 | -3. 只保留 `siteIds` 白名单内的任务。 | ||
| 128 | -4. `taskList.meetingNumber` 作为 `classId`。 | ||
| 129 | -5. 调用 `getByClassPrivate.do` 获取该课堂全部高光。 | ||
| 130 | -6. 每条高光加入录制队列。 | ||
| 131 | - | ||
| 132 | -任务接口签名: | ||
| 133 | - | ||
| 134 | -```text | ||
| 135 | -authId = MD5(pageNo + pageSize + timestamp) | ||
| 136 | -``` | ||
| 137 | - | ||
| 138 | -课堂高光接口签名: | ||
| 139 | - | ||
| 140 | -```text | ||
| 141 | -authId = MD5(classId + timestamp) | ||
| 142 | -``` | ||
| 143 | - | ||
| 144 | -代码必须保留: | ||
| 145 | - | ||
| 146 | -```js | ||
| 147 | -// TODO: 等后端明确录制任务状态流转及完成回写接口。 | ||
| 148 | -``` | ||
| 149 | - | ||
| 150 | -任务状态回写接口确定前,使用内存队列、本地文件和 OSS 文件共同避免重复录制。 | ||
| 151 | - | ||
| 152 | -## 7. 字段映射 | ||
| 153 | - | ||
| 154 | -SaaS 高光记录: | ||
| 155 | - | ||
| 156 | -```json | ||
| 157 | -{ | ||
| 158 | - "id": 4, | ||
| 159 | - "meetingNumber": "1486758620", | ||
| 160 | - "siteId": "xdyui2", | ||
| 161 | - "beginTime": 1779159793000, | ||
| 162 | - "endTime": 1779159893000 | ||
| 163 | -} | ||
| 164 | -``` | ||
| 165 | - | ||
| 166 | -WebScreen 内部统一为: | ||
| 167 | - | ||
| 168 | -```text | ||
| 169 | -highlightId = id | ||
| 170 | -classId = meetingNumber | ||
| 171 | -``` | ||
| 172 | - | ||
| 173 | -校验: | ||
| 174 | - | ||
| 175 | -- `id` 为正整数。 | ||
| 176 | -- `meetingNumber`、`siteId` 只包含安全字符。 | ||
| 177 | -- 时间为 13 位毫秒时间戳。 | ||
| 178 | -- `endTime > beginTime`。 | ||
| 179 | -- 时长不超过 `maxDurationMs`。 | ||
| 180 | -- 站点必须属于 `HIGHLIGHTCONFIG.siteIds`。 | ||
| 181 | - | ||
| 182 | -唯一键: | ||
| 183 | - | ||
| 184 | -```text | ||
| 185 | -siteId:highlightId | ||
| 186 | -``` | ||
| 187 | - | ||
| 188 | -## 8. 回放录制地址 | ||
| 189 | - | ||
| 190 | -使用 `URLSearchParams` 在现有 `BACKMEDIACONFIG.url` 上增加: | ||
| 191 | - | ||
| 192 | -```text | ||
| 193 | -classId={meetingNumber} | ||
| 194 | -recordMp4=true | ||
| 195 | -playRecord=1 | ||
| 196 | -recBeginTime={beginTime} | ||
| 197 | -recEndTime={endTime} | ||
| 198 | -``` | ||
| 199 | - | ||
| 200 | -`recBeginTime` 和 `recEndTime` 都是绝对毫秒时间戳,不换算为相对秒数。 | ||
| 201 | - | ||
| 202 | -调用 `web_capture_c` 时使用参数数组和 `spawn(..., { shell: false })`,不把接口字段拼接进 shell 命令。 | ||
| 203 | - | ||
| 204 | -## 9. 文件与上传 | ||
| 205 | - | ||
| 206 | -文件名: | ||
| 207 | - | ||
| 208 | -```text | ||
| 209 | -{classId}_highlight_{highlightId}.mp4 | ||
| 210 | -``` | ||
| 211 | - | ||
| 212 | -路径: | 13 | +文件位置: |
| 213 | 14 | ||
| 214 | ```text | 15 | ```text |
| 215 | 本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 | 16 | 本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 |
| 216 | -OSS: oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 | ||
| 217 | -URL: https://xdymp4.xuedianyun.com/oss/{siteId}/{yyyyMMdd}/{fileName} | ||
| 218 | -``` | ||
| 219 | - | ||
| 220 | -日期按高光 `beginTime` 的 Asia/Shanghai 日期计算。 | ||
| 221 | - | ||
| 222 | -录制过程先写入 `PROJECTCATALOG/.highlight_tmp`,完成后原子移动到 `media`,避免 OSS 搬运程序读取半成品。队列完成后在涉及的日期目录写入 `download.json`,兼容原搬运机制。 | ||
| 223 | - | ||
| 224 | -WebScreen 不主动上传 OSS。 | ||
| 225 | - | ||
| 226 | -## 10. 队列和去重 | ||
| 227 | - | ||
| 228 | -- 全局并发由 `maxConcurrent` 控制。 | ||
| 229 | -- 同一课堂高光串行,避免同时加载同一课堂回放。 | ||
| 230 | -- 不同课堂可以并行。 | ||
| 231 | -- 队列中已有相同 `siteId:highlightId` 时不重复加入。 | ||
| 232 | -- 本地最终文件存在时不重复录制。 | ||
| 233 | -- OSS 文件存在时不重复录制。 | ||
| 234 | -- OSS 查询异常时停止本轮处理,不能把查询失败当作文件不存在。 | ||
| 235 | - | ||
| 236 | -## 11. 高光多文件查询 | ||
| 237 | - | ||
| 238 | -```http | ||
| 239 | -POST /highlight/fileExists | ||
| 240 | -Content-Type: application/json | 17 | +OSS:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 |
| 241 | ``` | 18 | ``` |
| 242 | - | ||
| 243 | -请求: | ||
| 244 | - | ||
| 245 | -```json | ||
| 246 | -{ | ||
| 247 | - "siteId": "xdyui2", | ||
| 248 | - "classId": "1486758620" | ||
| 249 | -} | ||
| 250 | -``` | ||
| 251 | - | ||
| 252 | -WebScreen 调用 `getByClassPrivate.do` 获取该课堂全部高光,再逐条检查本地任务状态和 OSS。 | ||
| 253 | - | ||
| 254 | -响应: | ||
| 255 | - | ||
| 256 | -```json | ||
| 257 | -{ | ||
| 258 | - "code": 0, | ||
| 259 | - "message": "文件已生成", | ||
| 260 | - "onlyHighlight": 1, | ||
| 261 | - "classUrlList": [ | ||
| 262 | - { | ||
| 263 | - "highlightId": 4, | ||
| 264 | - "classId": "1486758620", | ||
| 265 | - "siteId": "xdyui2", | ||
| 266 | - "beginTime": 1779159793000, | ||
| 267 | - "generated": true, | ||
| 268 | - "status": "generated", | ||
| 269 | - "url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260519/1486758620_highlight_4.mp4" | ||
| 270 | - } | ||
| 271 | - ] | ||
| 272 | -} | ||
| 273 | -``` | ||
| 274 | - | ||
| 275 | -全部生成时 `code=0`;无文件或存在未生成文件时 `code=1`,但仍返回每条记录状态。 | ||
| 276 | - | ||
| 277 | -## 12. 验收标准 | ||
| 278 | - | ||
| 279 | -1. `xdyui2` 一个课堂有 N 条高光时生成 N 个不同 MP4。 | ||
| 280 | -2. 每个录制 URL 包含正确的 `recBeginTime/recEndTime`。 | ||
| 281 | -3. 重复执行 cron 不重复录制本地或 OSS 已存在文件。 | ||
| 282 | -4. `sourceMode=site` 只查询前一天 xdyui2 高光。 | ||
| 283 | -5. `sourceMode=task` 只处理 `status=0 && onlyHighlight=1`。 | ||
| 284 | -6. `/highlight/fileExists` 返回课堂全部高光文件状态和地址。 | ||
| 285 | -7. 原 `/fileExists` 响应不变。 | ||
| 286 | -8. 删除或关闭 `HIGHLIGHTCONFIG` 后,高光接口拒绝处理,原 `GET /recording` 仍执行整堂录制。 | ||
| 287 | - | ||
| 288 | -## 13. 已知待办 | ||
| 289 | - | ||
| 290 | -- 后端确认任务领取、成功、失败状态及回写接口。 | ||
| 291 | -- xdyui2 验证通过后再增加 CrazyTalk 的准确 `siteId`。 | ||
| 292 | -- 正式部署前确认服务器 OSS 搬运任务会处理新文件名和 `download.json`。 |
| 1 | # WebScreen 文档目录 | 1 | # WebScreen 文档目录 |
| 2 | 2 | ||
| 3 | -## 高光录制 | 3 | +- [V2 录制任务与文件查询接口](./RECORDING_API.md) |
| 4 | +- [获取录制文件 V2(标准对外接口文档)](./FILE_EXISTS_V2.md) | ||
| 5 | +- [高光录制内部说明](./HIGHLIGHT_API.md) | ||
| 6 | +- [高光 MP4 约定](./HIGHLIGHT_MP4_SPEC.md) | ||
| 7 | +- [部署说明](./HIGHLIGHT_DEPLOYMENT.md) | ||
| 8 | +- [部署与验收测试](./HIGHLIGHT_DEPLOYMENT_USAGE_TEST.md) | ||
| 4 | 9 | ||
| 5 | -- [高光录制接口文档](./HIGHLIGHT_API.md) | ||
| 6 | -- [高光 MP4 录制 Spec](./HIGHLIGHT_MP4_SPEC.md) | ||
| 7 | -- [高光录制部署文档](./HIGHLIGHT_DEPLOYMENT.md) | ||
| 8 | -- [高光录制部署、使用与测试手册](./HIGHLIGHT_DEPLOYMENT_USAGE_TEST.md) | 10 | +SaaS 已有接口: |
| 9 | 11 | ||
| 10 | -## SaaS 内部接口 | 12 | +- `/3m/api/recording/getRecordingTasksPrivate.do` |
| 13 | +- `/3m/api/recording/updateRecordingTask.do` | ||
| 14 | +- `/3m/api/highlight/getByClassPrivate.do` | ||
| 11 | 15 | ||
| 12 | -- [根据课堂获取高光](./getByClassPrivate.md) | ||
| 13 | -- [根据站点获取高光](./getBySitePrivate.md) | 16 | +接口契约以 3m 仓库及 SaaS 提供的对接文档为准。 |
docs/RECORDING_API.md
0 → 100644
| 1 | +# WebScreen V2 录制接口 | ||
| 2 | + | ||
| 3 | +## 1. 范围 | ||
| 4 | + | ||
| 5 | +原接口保持原实现不变: | ||
| 6 | + | ||
| 7 | +```http | ||
| 8 | +POST /recordingTask | ||
| 9 | +POST /fileExists | ||
| 10 | +``` | ||
| 11 | + | ||
| 12 | +新增: | ||
| 13 | + | ||
| 14 | +```http | ||
| 15 | +POST /recordingTaskV2 | ||
| 16 | +POST /fileExistsV2 | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +V2 兼容原整课录制格式,并通过 SaaS 字段 `onlyHighlight=1` 支持仅录高光。不支持也不需要“整课和高光同时录制”。不录制站点已经由 xdySDK 上游流程筛除。 | ||
| 20 | + | ||
| 21 | +| `onlyHighlight` | 行为 | | ||
| 22 | +|---:|---| | ||
| 23 | +| 不传或 `0` | 复用原整课录制实现 | | ||
| 24 | +| `1` | 仅录制该课堂的高光时刻 | | ||
| 25 | + | ||
| 26 | +## 2. 创建 V2 录制任务 | ||
| 27 | + | ||
| 28 | +```http | ||
| 29 | +POST /recordingTaskV2 | ||
| 30 | +Content-Type: application/json | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +### 2.1 兼容原整课请求 | ||
| 34 | + | ||
| 35 | +```json | ||
| 36 | +{ | ||
| 37 | + "list": [ | ||
| 38 | + { | ||
| 39 | + "siteId": "doctest", | ||
| 40 | + "classId": "487012832", | ||
| 41 | + "yymmdd": "20260805" | ||
| 42 | + } | ||
| 43 | + ], | ||
| 44 | + "maxMedia": 1 | ||
| 45 | +} | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +未传 `onlyHighlight` 时,V2 调用现有整课录制代码,文件仍为 `{classId}.mp4`。 | ||
| 49 | + | ||
| 50 | +### 2.2 仅高光请求 | ||
| 51 | + | ||
| 52 | +V2 可直接接受 SaaS `getRecordingTasksPrivate.do` 返回项的字段形式: | ||
| 53 | + | ||
| 54 | +```json | ||
| 55 | +{ | ||
| 56 | + "list": [ | ||
| 57 | + { | ||
| 58 | + "id": "ff808081956a495901956a498d0f0001", | ||
| 59 | + "siteId": "doctest", | ||
| 60 | + "meetingNumber": "487012832", | ||
| 61 | + "beginTime": "2026-08-05 10:00:00", | ||
| 62 | + "endTime": "2026-08-05 11:00:00", | ||
| 63 | + "status": 0, | ||
| 64 | + "onlyHighlight": 1 | ||
| 65 | + } | ||
| 66 | + ] | ||
| 67 | +} | ||
| 68 | +``` | ||
| 69 | + | ||
| 70 | +字段兼容关系: | ||
| 71 | + | ||
| 72 | +| V2 字段 | 兼容字段 | 说明 | | ||
| 73 | +|---|---|---| | ||
| 74 | +| `classId` | `meetingNumber` | 课堂号,二者任选其一 | | ||
| 75 | +| `taskId` | `id` | 可选;SaaS 任务 ID,用于辅助追踪和幂等 | | ||
| 76 | +| `yymmdd` | `classStartTime` | 原整课目录日期,格式 `yyyyMMdd` | | ||
| 77 | +| `beginTime/endTime` | — | 可选;用于精确限定课堂高光时间范围,接受 13 位毫秒时间戳或 SaaS 时间格式 | | ||
| 78 | +| `onlyHighlight` | — | 只有值 `1` 表示仅高光,其他值按整课兼容 | | ||
| 79 | + | ||
| 80 | +如果现有 cron 已经把 SaaS 任务映射为 `classId/siteId/yymmdd`,只需在原映射中 | ||
| 81 | +增加 `onlyHighlight`。高光状态回写使用 `siteId + classId`,不依赖 SaaS 任务 `id`。 | ||
| 82 | + | ||
| 83 | +仅高光流程: | ||
| 84 | + | ||
| 85 | +1. 调用 `/3m/api/highlight/getByClassPrivate.do` 获取课堂高光。 | ||
| 86 | +2. 只保留站点、课堂一致且位于课堂时间范围内的数据。 | ||
| 87 | +3. 每条高光生成一个 MP4。 | ||
| 88 | +4. 全部高光本地录制完成后向 SaaS 回写 `status=2`;返回 `code=0` 即表示更新成功。 | ||
| 89 | +5. SaaS 正常返回空高光数组时回写 `status=3`;接口失败不能当作无高光。 | ||
| 90 | + | ||
| 91 | +`getBySitePrivate.do` 不属于单课堂任务主流程,只用于批量排查或补录。 | ||
| 92 | + | ||
| 93 | +### 2.3 响应 | ||
| 94 | + | ||
| 95 | +```json | ||
| 96 | +{ | ||
| 97 | + "code": "0", | ||
| 98 | + "message": "success", | ||
| 99 | + "accepted": 1, | ||
| 100 | + "duplicates": 0, | ||
| 101 | + "noMedia": 0, | ||
| 102 | + "v": "v1.2.0.20251208" | ||
| 103 | +} | ||
| 104 | +``` | ||
| 105 | + | ||
| 106 | +| 字段 | 说明 | | ||
| 107 | +|---|---| | ||
| 108 | +| `accepted` | 本次接收的整课或高光任务数 | | ||
| 109 | +| `duplicates` | 已处理或正在执行的重复任务数 | | ||
| 110 | +| `noMedia` | `onlyHighlight=1` 但 SaaS 正常返回零条高光的任务数 | | ||
| 111 | + | ||
| 112 | +## 3. 查询 V2 录制文件 | ||
| 113 | + | ||
| 114 | +```http | ||
| 115 | +POST /fileExistsV2 | ||
| 116 | +Content-Type: application/json | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +请求: | ||
| 120 | + | ||
| 121 | +```json | ||
| 122 | +{ | ||
| 123 | + "siteId": "doctest", | ||
| 124 | + "classId": "487012832" | ||
| 125 | +} | ||
| 126 | +``` | ||
| 127 | + | ||
| 128 | +也兼容原整课查询日期: | ||
| 129 | + | ||
| 130 | +```json | ||
| 131 | +{ | ||
| 132 | + "siteId": "doctest", | ||
| 133 | + "classId": "487012832", | ||
| 134 | + "classStartTime": "20260805" | ||
| 135 | +} | ||
| 136 | +``` | ||
| 137 | + | ||
| 138 | +V2 根据 `/recordingTaskV2` 保存的任务快照判断文件类型,客户不需要再次传 `onlyHighlight`。 | ||
| 139 | + | ||
| 140 | +整课成功响应: | ||
| 141 | + | ||
| 142 | +```json | ||
| 143 | +{ | ||
| 144 | + "code": 0, | ||
| 145 | + "message": "文件已生成", | ||
| 146 | + "fileExists": true, | ||
| 147 | + "onlyHighlight": 0, | ||
| 148 | + "classUrl": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4", | ||
| 149 | + "files": [ | ||
| 150 | + { | ||
| 151 | + "type": "full", | ||
| 152 | + "url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832.mp4" | ||
| 153 | + } | ||
| 154 | + ] | ||
| 155 | +} | ||
| 156 | +``` | ||
| 157 | + | ||
| 158 | +高光成功响应: | ||
| 159 | + | ||
| 160 | +```json | ||
| 161 | +{ | ||
| 162 | + "code": 0, | ||
| 163 | + "message": "文件已生成", | ||
| 164 | + "fileExists": true, | ||
| 165 | + "onlyHighlight": 1, | ||
| 166 | + "files": [ | ||
| 167 | + { | ||
| 168 | + "type": "highlight", | ||
| 169 | + "highlightId": 5, | ||
| 170 | + "url": "https://xdymp4.xuedianyun.com/oss/doctest/20260805/487012832_highlight_5.mp4" | ||
| 171 | + } | ||
| 172 | + ] | ||
| 173 | +} | ||
| 174 | +``` | ||
| 175 | + | ||
| 176 | +任一目标文件尚未生成时: | ||
| 177 | + | ||
| 178 | +```json | ||
| 179 | +{ | ||
| 180 | + "code": 1, | ||
| 181 | + "message": "文件未生成", | ||
| 182 | + "fileExists": false, | ||
| 183 | + "files": [] | ||
| 184 | +} | ||
| 185 | +``` | ||
| 186 | + | ||
| 187 | +查询接口只检查明确的 OSS Key,不触发录制,也不重新调用 SaaS 高光接口。 | ||
| 188 | + | ||
| 189 | +## 4. 文件规则 | ||
| 190 | + | ||
| 191 | +```text | ||
| 192 | +整课:oss/{siteId}/{yyyyMMdd}/{classId}.mp4 | ||
| 193 | +高光:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4 | ||
| 194 | +``` | ||
| 195 | + | ||
| 196 | +高光日期按每条高光 `beginTime` 的 Asia/Shanghai 日期计算。 |
docs/getByClassPrivate.md
已删除
100644 → 0
| 1 | - | ||
| 2 | -# 获取课堂内精彩时刻 | ||
| 3 | -内部接口,获取课堂内精彩时刻。 | ||
| 4 | - | ||
| 5 | -**`POST`** | ||
| 6 | -```/3m/api/highlight/getByClassPrivate.do``` | ||
| 7 | - | ||
| 8 | -## 接口参数 | ||
| 9 | - | ||
| 10 | -|字段|类型|必选|描述| | ||
| 11 | -|-------|-------|--------|--------| | ||
| 12 | -|classId|String|是|课堂ID| | ||
| 13 | -|timestamp|String|是|时间戳| | ||
| 14 | -|authId|String|是|MD5(classId+timestamp)| | ||
| 15 | - | ||
| 16 | -## 请求示例 | ||
| 17 | - | ||
| 18 | -```http | ||
| 19 | -POST /3m/api/highlight/getByClassPrivate.do HTTP/1.1 | ||
| 20 | -Host: 127.0.0.1:8080 | ||
| 21 | -Content-Type: application/x-www-form-urlencoded | ||
| 22 | -Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB | ||
| 23 | -Content-Length: 82 | ||
| 24 | - | ||
| 25 | -classId=1486758620&authId=a66749831542e5e090e41d72f455918d×tamp=1784628401782 | ||
| 26 | -``` | ||
| 27 | - | ||
| 28 | -## 返回结果 | ||
| 29 | -Success 200 | ||
| 30 | - | ||
| 31 | -|字段|类型|描述| | ||
| 32 | -|----|----|----| | ||
| 33 | -|code|int|0.正常 <br>1.classId不存在 <br>4.authId错误 <br>10.报文格式错误| | ||
| 34 | - | ||
| 35 | -## 返回示例 | ||
| 36 | -```json | ||
| 37 | -{ | ||
| 38 | - "code": 0, | ||
| 39 | - "data": [ | ||
| 40 | - { | ||
| 41 | - "meetingNumber": "1486758620", | ||
| 42 | - "more": "ext", | ||
| 43 | - "siteId": "doctest", | ||
| 44 | - "beginTime": 1779159793000, | ||
| 45 | - "endTime": 1779159893000, | ||
| 46 | - "id": 4, | ||
| 47 | - "type": 0, | ||
| 48 | - "userName": "xu", | ||
| 49 | - "userRole": 8, | ||
| 50 | - "userId": "xuid" | ||
| 51 | - }, | ||
| 52 | - { | ||
| 53 | - "meetingNumber": "1486758620", | ||
| 54 | - "more": "test", | ||
| 55 | - "siteId": "doctest", | ||
| 56 | - "beginTime": 1779159793000, | ||
| 57 | - "endTime": 1779159893000, | ||
| 58 | - "id": 5, | ||
| 59 | - "type": 0, | ||
| 60 | - "userName": "xu", | ||
| 61 | - "userRole": 8, | ||
| 62 | - "userId": "sss" | ||
| 63 | - } | ||
| 64 | - ] | ||
| 65 | -} | ||
| 66 | -``` |
docs/getBySitePrivate.md
已删除
100644 → 0
| 1 | - | ||
| 2 | -# 获取站点内精彩时刻 | ||
| 3 | -内部接口,获取站点内精彩时刻。 | ||
| 4 | - | ||
| 5 | -**`POST`** | ||
| 6 | -```/3m/api/highlight/getBySitePrivate.do``` | ||
| 7 | - | ||
| 8 | -## 接口参数 | ||
| 9 | - | ||
| 10 | -|字段|类型|必选|描述| | ||
| 11 | -|-------|-------|--------|--------| | ||
| 12 | -|siteId|String|是|站点ID| | ||
| 13 | -|pageNo|Integer|是|页码| | ||
| 14 | -|pageSize|Integer|是|每页记录数| | ||
| 15 | -|beginTime|Timestamp|否|开始时间:13为时间戳| | ||
| 16 | -|beginTime|Timestamp|否|结束时间:13为时间戳| | ||
| 17 | -|pageSize|Integer|是|每页记录数| | ||
| 18 | -|timestamp|String|是|时间戳| | ||
| 19 | -|authId|String|是|MD5(siteId+timestamp)| | ||
| 20 | - | ||
| 21 | -## 请求示例 | ||
| 22 | - | ||
| 23 | -```http | ||
| 24 | -POST /3m/api/highlight/getBySitePrivate.do HTTP/1.1 | ||
| 25 | -Host: 127.0.0.1:8080 | ||
| 26 | -Content-Type: application/x-www-form-urlencoded | ||
| 27 | -Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB | ||
| 28 | -Content-Length: 145 | ||
| 29 | - | ||
| 30 | -siteId=doctest&pageNo=1&pageSize=10&beginTime=1779159593000&endTime=1779159993000&authId=9ead85fffcaab547ba68edf0ea2b0170×tamp=1784628642310 | ||
| 31 | -``` | ||
| 32 | - | ||
| 33 | -## 返回结果 | ||
| 34 | -Success 200 | ||
| 35 | - | ||
| 36 | -|字段|类型|描述| | ||
| 37 | -|----|----|----| | ||
| 38 | -|code|int|0.正常 <br>1.站点错误或已过期 <br>2.分页参数错误 <br>4.authId错误 <br>10.报文格式错误| | ||
| 39 | - | ||
| 40 | -## 返回示例 | ||
| 41 | -```json | ||
| 42 | -{ | ||
| 43 | - "code": 0, | ||
| 44 | - "data": [ | ||
| 45 | - { | ||
| 46 | - "meetingNumber": "1486758620", | ||
| 47 | - "more": "test", | ||
| 48 | - "siteId": "doctest", | ||
| 49 | - "beginTime": 1779159793000, | ||
| 50 | - "endTime": 1779159893000, | ||
| 51 | - "id": 5, | ||
| 52 | - "type": 0, | ||
| 53 | - "userName": "xu", | ||
| 54 | - "userRole": 8, | ||
| 55 | - "userId": "sss" | ||
| 56 | - }, | ||
| 57 | - { | ||
| 58 | - "meetingNumber": "1486758620", | ||
| 59 | - "more": "ext", | ||
| 60 | - "siteId": "doctest", | ||
| 61 | - "beginTime": 1779159793000, | ||
| 62 | - "endTime": 1779159893000, | ||
| 63 | - "id": 4, | ||
| 64 | - "type": 0, | ||
| 65 | - "userName": "xu", | ||
| 66 | - "userRole": 8, | ||
| 67 | - "userId": "xuid" | ||
| 68 | - } | ||
| 69 | - ], | ||
| 70 | - "pageNo": 1, | ||
| 71 | - "count": 2, | ||
| 72 | - "pageSize": 10 | ||
| 73 | -} | ||
| 74 | -``` |
| @@ -5,7 +5,7 @@ | @@ -5,7 +5,7 @@ | ||
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "start": "node ./bin/www", | 6 | "start": "node ./bin/www", |
| 7 | "pm2": "pm2 start ./bin/www --name webScreen", | 7 | "pm2": "pm2 start ./bin/www --name webScreen", |
| 8 | - "test": "node ./test/highlightRecordingService.test.js && node ./test/highlightRoutes.test.js" | 8 | + "test": "node ./test/highlightRecordingService.test.js && node ./test/highlightRoutes.test.js && node ./test/recordingTaskService.test.js && node ./test/recordingRoutes.test.js" |
| 9 | }, | 9 | }, |
| 10 | "dependencies": { | 10 | "dependencies": { |
| 11 | "ali-oss": "^6.22.0", | 11 | "ali-oss": "^6.22.0", |
不能预览此文件类型
| @@ -8,6 +8,12 @@ require('dotenv').config(); // 加载环境变量 | @@ -8,6 +8,12 @@ require('dotenv').config(); // 加载环境变量 | ||
| 8 | 8 | ||
| 9 | const method = require("../config/method") | 9 | const method = require("../config/method") |
| 10 | const config = require("../config/config") | 10 | const config = require("../config/config") |
| 11 | +const { | ||
| 12 | + HighlightUpstreamError, | ||
| 13 | + HighlightValidationError, | ||
| 14 | + RecordingTaskValidationError, | ||
| 15 | + recordingTaskService | ||
| 16 | +} = require('../services/recordingTaskService'); | ||
| 11 | const version ='v1.2.0.20251208'; | 17 | const version ='v1.2.0.20251208'; |
| 12 | // const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config | 18 | // const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config |
| 13 | const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method | 19 | const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method |
| @@ -354,6 +360,29 @@ router.post('/fileExists', async (req, res) => { | @@ -354,6 +360,29 @@ router.post('/fileExists', async (req, res) => { | ||
| 354 | res.status(500).send({ code: -1, message: "服务器内部错误", error: err.message }); | 360 | res.status(500).send({ code: -1, message: "服务器内部错误", error: err.message }); |
| 355 | } | 361 | } |
| 356 | }); | 362 | }); |
| 363 | + | ||
| 364 | +router.post('/fileExistsV2', async (req, res) => { | ||
| 365 | + try { | ||
| 366 | + const result = await recordingTaskService.getFileResult(req.body || {}); | ||
| 367 | + return res.send(result); | ||
| 368 | + } catch (err) { | ||
| 369 | + if (err instanceof RecordingTaskValidationError) { | ||
| 370 | + return res.status(400).send({ | ||
| 371 | + code: err.apiCode, | ||
| 372 | + message: err.message, | ||
| 373 | + fileExists: false, | ||
| 374 | + files: [] | ||
| 375 | + }); | ||
| 376 | + } | ||
| 377 | + console.error('Error checking file existence:', err); | ||
| 378 | + return res.status(500).send({ | ||
| 379 | + code: -1, | ||
| 380 | + message: "服务器内部错误", | ||
| 381 | + fileExists: false, | ||
| 382 | + files: [] | ||
| 383 | + }); | ||
| 384 | + } | ||
| 385 | +}); | ||
| 357 | router.post('/recordingTask', async function (req, res, next) { | 386 | router.post('/recordingTask', async function (req, res, next) { |
| 358 | new MediaCreat().wrieLog("录制启动:------>") | 387 | new MediaCreat().wrieLog("录制启动:------>") |
| 359 | let fileConfig = new MediaCreat().getConfigFileJson() | 388 | let fileConfig = new MediaCreat().getConfigFileJson() |
| @@ -381,4 +410,35 @@ router.post('/recordingTask', async function (req, res, next) { | @@ -381,4 +410,35 @@ router.post('/recordingTask', async function (req, res, next) { | ||
| 381 | res.send({ code: "0",message:"success",v:version }); | 410 | res.send({ code: "0",message:"success",v:version }); |
| 382 | }) | 411 | }) |
| 383 | 412 | ||
| 413 | +router.post('/recordingTaskV2', async function (req, res, next) { | ||
| 414 | + try { | ||
| 415 | + const result = await recordingTaskService.acceptTasks(req.body || {}, { | ||
| 416 | + recordFullClass: task => { | ||
| 417 | + new MediaCreat().recordingCreat( | ||
| 418 | + task.classId, | ||
| 419 | + task.siteId, | ||
| 420 | + 'post', | ||
| 421 | + task.classStartTime || task.classDate | ||
| 422 | + ); | ||
| 423 | + } | ||
| 424 | + }); | ||
| 425 | + return res.send({ | ||
| 426 | + code: "0", | ||
| 427 | + message: "success", | ||
| 428 | + accepted: result.accepted, | ||
| 429 | + duplicates: result.duplicates, | ||
| 430 | + noMedia: result.noMedia, | ||
| 431 | + v: version | ||
| 432 | + }); | ||
| 433 | + } catch (error) { | ||
| 434 | + if (error instanceof RecordingTaskValidationError || error instanceof HighlightValidationError) { | ||
| 435 | + return res.status(400).send({ code: "1", message: error.message, data: [], v: version }); | ||
| 436 | + } | ||
| 437 | + if (error instanceof HighlightUpstreamError) { | ||
| 438 | + return res.status(502).send({ code: String(error.upstreamCode), message: error.message, data: [], v: version }); | ||
| 439 | + } | ||
| 440 | + return next(error); | ||
| 441 | + } | ||
| 442 | +}) | ||
| 443 | + | ||
| 384 | module.exports = router | 444 | module.exports = router |
| @@ -9,11 +9,9 @@ require('dotenv').config(); | @@ -9,11 +9,9 @@ require('dotenv').config(); | ||
| 9 | 9 | ||
| 10 | const DEFAULT_CONFIG = { | 10 | const DEFAULT_CONFIG = { |
| 11 | enabled: false, | 11 | enabled: false, |
| 12 | - sourceMode: 'site', | ||
| 13 | siteIds: [], | 12 | siteIds: [], |
| 14 | apiBaseUrl: 'https://saas.xuedianyun.com', | 13 | apiBaseUrl: 'https://saas.xuedianyun.com', |
| 15 | pageSize: 100, | 14 | pageSize: 100, |
| 16 | - taskPageSize: 100, | ||
| 17 | maxPages: 1000, | 15 | maxPages: 1000, |
| 18 | maxConcurrent: 2, | 16 | maxConcurrent: 2, |
| 19 | maxDurationMs: 6 * 60 * 60 * 1000, | 17 | maxDurationMs: 6 * 60 * 60 * 1000, |
| @@ -187,8 +185,10 @@ class HighlightRecordingService { | @@ -187,8 +185,10 @@ class HighlightRecordingService { | ||
| 187 | constructor(options) { | 185 | constructor(options) { |
| 188 | const opts = options || {}; | 186 | const opts = options || {}; |
| 189 | this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json'); | 187 | this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json'); |
| 188 | + this.completionMarkersEnabled = opts.completionMarkersEnabled !== false; | ||
| 190 | this.queue = []; | 189 | this.queue = []; |
| 191 | this.tasks = new Map(); | 190 | this.tasks = new Map(); |
| 191 | + this.taskWaiters = new Map(); | ||
| 192 | this.activeClassIds = new Set(); | 192 | this.activeClassIds = new Set(); |
| 193 | this.activeCount = 0; | 193 | this.activeCount = 0; |
| 194 | this.scheduledRunActive = false; | 194 | this.scheduledRunActive = false; |
| @@ -310,67 +310,6 @@ class HighlightRecordingService { | @@ -310,67 +310,6 @@ class HighlightRecordingService { | ||
| 310 | return result.data; | 310 | return result.data; |
| 311 | } | 311 | } |
| 312 | 312 | ||
| 313 | - async fetchRecordingTasks() { | ||
| 314 | - this.ensureFeatureEnabled(); | ||
| 315 | - const config = this.readConfig().highlight; | ||
| 316 | - const pageSize = Number(config.taskPageSize || config.pageSize); | ||
| 317 | - if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 1000) { | ||
| 318 | - throw new HighlightValidationError('taskPageSize 无效'); | ||
| 319 | - } | ||
| 320 | - | ||
| 321 | - const records = []; | ||
| 322 | - let pageNo = 1; | ||
| 323 | - while (pageNo <= config.maxPages) { | ||
| 324 | - const url = new URL( | ||
| 325 | - '/3m/api/recording/getRecordingTasksPrivate.do', | ||
| 326 | - `${String(config.apiBaseUrl).replace(/\/$/, '')}/` | ||
| 327 | - ).toString(); | ||
| 328 | - let response; | ||
| 329 | - for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) { | ||
| 330 | - const timestamp = String(Date.now()); | ||
| 331 | - const body = { | ||
| 332 | - pageNo, | ||
| 333 | - pageSize, | ||
| 334 | - timestamp, | ||
| 335 | - authId: md5(`${pageNo}${pageSize}${timestamp}`) | ||
| 336 | - }; | ||
| 337 | - try { | ||
| 338 | - response = await axios.post(url, querystring.stringify(body), { | ||
| 339 | - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | ||
| 340 | - timeout: config.apiTimeoutMs | ||
| 341 | - }); | ||
| 342 | - break; | ||
| 343 | - } catch (error) { | ||
| 344 | - const status = error && error.response && error.response.status; | ||
| 345 | - const retryable = !status || status >= 500; | ||
| 346 | - if (!retryable || attempt >= config.apiRetryCount) { | ||
| 347 | - throw new HighlightUpstreamError(-1, `录制任务接口请求失败${status ? ` HTTP ${status}` : ''}`); | ||
| 348 | - } | ||
| 349 | - await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt)); | ||
| 350 | - } | ||
| 351 | - } | ||
| 352 | - | ||
| 353 | - const result = response.data || {}; | ||
| 354 | - if (Number(result.code) !== 0) { | ||
| 355 | - throw new HighlightUpstreamError(Number(result.code), `录制任务接口返回错误 code=${result.code}`); | ||
| 356 | - } | ||
| 357 | - if (!Array.isArray(result.taskList)) { | ||
| 358 | - throw new HighlightUpstreamError(10, '录制任务接口 taskList 不是数组'); | ||
| 359 | - } | ||
| 360 | - records.push(...result.taskList); | ||
| 361 | - | ||
| 362 | - const taskCount = Number(result.taskCount); | ||
| 363 | - if (result.taskList.length < pageSize || (Number.isFinite(taskCount) && pageNo * pageSize >= taskCount)) { | ||
| 364 | - break; | ||
| 365 | - } | ||
| 366 | - pageNo += 1; | ||
| 367 | - } | ||
| 368 | - if (pageNo > config.maxPages) { | ||
| 369 | - throw new HighlightUpstreamError(2, '录制任务接口分页超过安全上限'); | ||
| 370 | - } | ||
| 371 | - return records; | ||
| 372 | - } | ||
| 373 | - | ||
| 374 | async fetchBySite(params) { | 313 | async fetchBySite(params) { |
| 375 | this.ensureFeatureEnabled(); | 314 | this.ensureFeatureEnabled(); |
| 376 | const input = params || {}; | 315 | const input = params || {}; |
| @@ -533,16 +472,17 @@ class HighlightRecordingService { | @@ -533,16 +472,17 @@ class HighlightRecordingService { | ||
| 533 | return output; | 472 | return output; |
| 534 | } | 473 | } |
| 535 | 474 | ||
| 536 | - async enqueue(records) { | 475 | + async enqueue(records, options) { |
| 537 | this.cleanupTasks(); | 476 | this.cleanupTasks(); |
| 538 | const config = this.readConfig(); | 477 | const config = this.readConfig(); |
| 539 | const allowedSiteIds = new Set(this.getAllowedSiteIds()); | 478 | const allowedSiteIds = new Set(this.getAllowedSiteIds()); |
| 479 | + const skipSiteCheck = Boolean(options && options.skipSiteCheck); | ||
| 540 | const output = []; | 480 | const output = []; |
| 541 | for (const raw of records || []) { | 481 | for (const raw of records || []) { |
| 542 | let item; | 482 | let item; |
| 543 | try { | 483 | try { |
| 544 | item = normalizeHighlight(raw, config.highlight.maxDurationMs); | 484 | item = normalizeHighlight(raw, config.highlight.maxDurationMs); |
| 545 | - if (!allowedSiteIds.has(item.siteId)) { | 485 | + if (!skipSiteCheck && !allowedSiteIds.has(item.siteId)) { |
| 546 | throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`); | 486 | throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`); |
| 547 | } | 487 | } |
| 548 | } catch (error) { | 488 | } catch (error) { |
| @@ -612,6 +552,7 @@ class HighlightRecordingService { | @@ -612,6 +552,7 @@ class HighlightRecordingService { | ||
| 612 | task.updatedAt = Date.now(); | 552 | task.updatedAt = Date.now(); |
| 613 | this.writeLog(`失败 key=${task.key} error=${task.error}`); | 553 | this.writeLog(`失败 key=${task.key} error=${task.error}`); |
| 614 | }).finally(() => { | 554 | }).finally(() => { |
| 555 | + this.resolveTaskWaiters(task); | ||
| 615 | this.activeCount -= 1; | 556 | this.activeCount -= 1; |
| 616 | this.activeClassIds.delete(activeClassKey); | 557 | this.activeClassIds.delete(activeClassKey); |
| 617 | this.writeCompletionMarkersIfIdle(); | 558 | this.writeCompletionMarkersIfIdle(); |
| @@ -719,6 +660,7 @@ class HighlightRecordingService { | @@ -719,6 +660,7 @@ class HighlightRecordingService { | ||
| 719 | } | 660 | } |
| 720 | 661 | ||
| 721 | writeCompletionMarkersIfIdle() { | 662 | writeCompletionMarkersIfIdle() { |
| 663 | + if (!this.completionMarkersEnabled) return; | ||
| 722 | if (this.activeCount !== 0 || this.queue.length !== 0 || this.completedDirs.size === 0) return; | 664 | if (this.activeCount !== 0 || this.queue.length !== 0 || this.completedDirs.size === 0) return; |
| 723 | for (const localDir of this.completedDirs) { | 665 | for (const localDir of this.completedDirs) { |
| 724 | try { | 666 | try { |
| @@ -730,6 +672,27 @@ class HighlightRecordingService { | @@ -730,6 +672,27 @@ class HighlightRecordingService { | ||
| 730 | this.completedDirs.clear(); | 672 | this.completedDirs.clear(); |
| 731 | } | 673 | } |
| 732 | 674 | ||
| 675 | + resolveTaskWaiters(task) { | ||
| 676 | + const waiters = this.taskWaiters.get(task.key) || []; | ||
| 677 | + this.taskWaiters.delete(task.key); | ||
| 678 | + for (const resolve of waiters) resolve(this.snapshot(task)); | ||
| 679 | + } | ||
| 680 | + | ||
| 681 | + waitForTaskKeys(keys) { | ||
| 682 | + const terminal = new Set(['uploading', 'generated', 'failed']); | ||
| 683 | + return Promise.all((keys || []).map(key => { | ||
| 684 | + const task = this.tasks.get(key); | ||
| 685 | + if (!task || terminal.has(task.status)) { | ||
| 686 | + return Promise.resolve(task ? this.snapshot(task) : { key, status: 'not_found' }); | ||
| 687 | + } | ||
| 688 | + return new Promise(resolve => { | ||
| 689 | + const waiters = this.taskWaiters.get(key) || []; | ||
| 690 | + waiters.push(resolve); | ||
| 691 | + this.taskWaiters.set(key, waiters); | ||
| 692 | + }); | ||
| 693 | + })); | ||
| 694 | + } | ||
| 695 | + | ||
| 733 | summarizeTasks(tasks) { | 696 | summarizeTasks(tasks) { |
| 734 | const summary = { | 697 | const summary = { |
| 735 | received: tasks.length, | 698 | received: tasks.length, |
| @@ -755,14 +718,10 @@ class HighlightRecordingService { | @@ -755,14 +718,10 @@ class HighlightRecordingService { | ||
| 755 | } | 718 | } |
| 756 | this.scheduledRunActive = true; | 719 | this.scheduledRunActive = true; |
| 757 | try { | 720 | try { |
| 758 | - const config = this.readConfig().highlight; | ||
| 759 | - const mode = String(config.sourceMode || 'site'); | ||
| 760 | const siteIds = this.getAllowedSiteIds(); | 721 | const siteIds = this.getAllowedSiteIds(); |
| 761 | if (siteIds.length === 0) { | 722 | if (siteIds.length === 0) { |
| 762 | throw new HighlightValidationError('未配置高光站点'); | 723 | throw new HighlightValidationError('未配置高光站点'); |
| 763 | } | 724 | } |
| 764 | - | ||
| 765 | - if (mode === 'site') { | ||
| 766 | const range = getPreviousShanghaiDayRange(now); | 725 | const range = getPreviousShanghaiDayRange(now); |
| 767 | const records = []; | 726 | const records = []; |
| 768 | for (const siteId of siteIds) { | 727 | for (const siteId of siteIds) { |
| @@ -774,32 +733,11 @@ class HighlightRecordingService { | @@ -774,32 +733,11 @@ class HighlightRecordingService { | ||
| 774 | records.push(...siteRecords); | 733 | records.push(...siteRecords); |
| 775 | } | 734 | } |
| 776 | const tasks = await this.enqueue(records); | 735 | const tasks = await this.enqueue(records); |
| 777 | - return Object.assign({ mode, beginTime: range.beginTime, endTime: range.endTime }, this.summarizeTasks(tasks)); | ||
| 778 | - } | ||
| 779 | - | ||
| 780 | - if (mode === 'task') { | ||
| 781 | - const rawTasks = await this.fetchRecordingTasks(); | ||
| 782 | - // TODO: 等后端明确录制任务状态流转及完成回写接口。 | ||
| 783 | - const pendingTasks = rawTasks.filter(task => | ||
| 784 | - Number(task && task.status) === 0 && | ||
| 785 | - Number(task && task.onlyHighlight) === 1 && | ||
| 786 | - siteIds.includes(String(task && task.siteId || '')) | ||
| 787 | - ); | ||
| 788 | - const classKeys = new Set(); | ||
| 789 | - const records = []; | ||
| 790 | - for (const task of pendingTasks) { | ||
| 791 | - const classId = String(task.meetingNumber || task.classId || ''); | ||
| 792 | - const classKey = `${task.siteId}:${classId}`; | ||
| 793 | - if (!isSafeIdentifier(classId) || classKeys.has(classKey)) continue; | ||
| 794 | - classKeys.add(classKey); | ||
| 795 | - const classRecords = await this.fetchByClass(classId); | ||
| 796 | - records.push(...classRecords.filter(record => String(record.siteId || '') === String(task.siteId))); | ||
| 797 | - } | ||
| 798 | - const tasks = await this.enqueue(records); | ||
| 799 | - return Object.assign({ mode, sourceTasks: rawTasks.length, pendingTasks: pendingTasks.length }, this.summarizeTasks(tasks)); | ||
| 800 | - } | ||
| 801 | - | ||
| 802 | - throw new HighlightValidationError('HIGHLIGHTCONFIG.sourceMode 只能是 site 或 task'); | 736 | + return Object.assign({ |
| 737 | + mode: 'site', | ||
| 738 | + beginTime: range.beginTime, | ||
| 739 | + endTime: range.endTime | ||
| 740 | + }, this.summarizeTasks(tasks)); | ||
| 803 | } finally { | 741 | } finally { |
| 804 | this.scheduledRunActive = false; | 742 | this.scheduledRunActive = false; |
| 805 | } | 743 | } |
services/recordingTaskService.js
0 → 100644
| 1 | +const crypto = require('crypto'); | ||
| 2 | +const fs = require('fs'); | ||
| 3 | +const path = require('path'); | ||
| 4 | +const querystring = require('querystring'); | ||
| 5 | +const axios = require('axios'); | ||
| 6 | +const OSS = require('ali-oss'); | ||
| 7 | +const { | ||
| 8 | + HighlightRecordingService, | ||
| 9 | + HighlightUpstreamError, | ||
| 10 | + HighlightValidationError, | ||
| 11 | + formatShanghaiDate, | ||
| 12 | + normalizeHighlight | ||
| 13 | +} = require('./highlightRecordingService'); | ||
| 14 | + | ||
| 15 | +class RecordingTaskValidationError extends Error { | ||
| 16 | + constructor(message, apiCode) { | ||
| 17 | + super(message); | ||
| 18 | + this.name = 'RecordingTaskValidationError'; | ||
| 19 | + this.apiCode = apiCode == null ? 2 : apiCode; | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +function isSafeIdentifier(value) { | ||
| 24 | + return /^[A-Za-z0-9_-]{1,128}$/.test(String(value || '')); | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +function normalizeOnlyHighlight(value) { | ||
| 28 | + return Number(value) === 1 ? 1 : 0; | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +function normalizeClassDate(raw) { | ||
| 32 | + const value = raw == null ? '' : String(raw).trim(); | ||
| 33 | + if (/^\d{8}$/.test(value)) return value; | ||
| 34 | + throw new RecordingTaskValidationError('classStartTime/yymmdd 必须是 yyyyMMdd 日期'); | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +function parseTaskTime(raw, fieldName) { | ||
| 38 | + if (raw == null || raw === '') return null; | ||
| 39 | + const value = String(raw).trim(); | ||
| 40 | + if (/^\d{13}$/.test(value)) { | ||
| 41 | + const timestamp = Number(value); | ||
| 42 | + if (Number.isSafeInteger(timestamp)) return timestamp; | ||
| 43 | + } | ||
| 44 | + const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/); | ||
| 45 | + if (matched) { | ||
| 46 | + const parts = matched.slice(1).map(Number); | ||
| 47 | + const timestamp = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3] - 8, parts[4], parts[5]); | ||
| 48 | + if (Number.isSafeInteger(timestamp)) return timestamp; | ||
| 49 | + } | ||
| 50 | + throw new RecordingTaskValidationError(`${fieldName} 必须是13位毫秒时间戳或 yyyy-MM-dd HH:mm:ss`); | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +function normalizeTaskPeriod(input, now, allowDefaultDate) { | ||
| 54 | + const hasBeginTime = input.beginTime != null && input.beginTime !== ''; | ||
| 55 | + const hasEndTime = input.endTime != null && input.endTime !== ''; | ||
| 56 | + if (hasBeginTime || hasEndTime) { | ||
| 57 | + if (!hasBeginTime || !hasEndTime) { | ||
| 58 | + throw new RecordingTaskValidationError('beginTime 和 endTime 必须同时传入'); | ||
| 59 | + } | ||
| 60 | + const beginTime = parseTaskTime(input.beginTime, 'beginTime'); | ||
| 61 | + const endTime = parseTaskTime(input.endTime, 'endTime'); | ||
| 62 | + if (endTime <= beginTime) { | ||
| 63 | + throw new RecordingTaskValidationError('endTime 必须大于 beginTime'); | ||
| 64 | + } | ||
| 65 | + return { | ||
| 66 | + beginTime, | ||
| 67 | + endTime, | ||
| 68 | + classDate: formatShanghaiDate(beginTime), | ||
| 69 | + classStartTime: '' | ||
| 70 | + }; | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + const legacyValue = input.classStartTime != null ? input.classStartTime : input.yymmdd; | ||
| 74 | + if (legacyValue != null && legacyValue !== '') { | ||
| 75 | + const classDate = normalizeClassDate(legacyValue); | ||
| 76 | + return { beginTime: null, endTime: null, classDate, classStartTime: classDate }; | ||
| 77 | + } | ||
| 78 | + if (!allowDefaultDate) return null; | ||
| 79 | + const classDate = formatShanghaiDate(Number(now == null ? Date.now() : now)); | ||
| 80 | + return { beginTime: null, endTime: null, classDate, classStartTime: '' }; | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +function normalizeRecordingTask(raw, now) { | ||
| 84 | + const input = raw || {}; | ||
| 85 | + const siteId = String(input.siteId || ''); | ||
| 86 | + const classId = String(input.classId || input.meetingNumber || ''); | ||
| 87 | + if (!isSafeIdentifier(siteId)) throw new RecordingTaskValidationError('siteId 无效', 2); | ||
| 88 | + if (!isSafeIdentifier(classId)) throw new RecordingTaskValidationError('classId 无效', 3); | ||
| 89 | + const period = normalizeTaskPeriod(input, now, true); | ||
| 90 | + return { | ||
| 91 | + taskId: input.taskId == null ? (input.id == null ? '' : String(input.id)) : String(input.taskId), | ||
| 92 | + siteId, | ||
| 93 | + classId, | ||
| 94 | + onlyHighlight: normalizeOnlyHighlight(input.onlyHighlight), | ||
| 95 | + beginTime: period.beginTime, | ||
| 96 | + endTime: period.endTime, | ||
| 97 | + classDate: period.classDate, | ||
| 98 | + classStartTime: period.classStartTime | ||
| 99 | + }; | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +function buildManifestKey(task) { | ||
| 103 | + return `${task.siteId}:${task.classId}`; | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +class RecordingTaskService { | ||
| 107 | + constructor(options) { | ||
| 108 | + const opts = options || {}; | ||
| 109 | + this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json'); | ||
| 110 | + this.highlightService = opts.highlightService || new HighlightRecordingService({ configPath: this.configPath }); | ||
| 111 | + this.inspectObjectOverride = opts.inspectObject; | ||
| 112 | + this.updateTaskStatusOverride = opts.updateTaskStatus; | ||
| 113 | + this.ossClient = opts.ossClient || null; | ||
| 114 | + this.inFlight = new Set(); | ||
| 115 | + this.objectStatusCache = new Map(); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + readConfig() { | ||
| 119 | + return JSON.parse(fs.readFileSync(this.configPath, 'utf8')); | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + getStateDir() { | ||
| 123 | + const config = this.readConfig(); | ||
| 124 | + const configured = config.RECORDINGV2CONFIG && config.RECORDINGV2CONFIG.stateDir; | ||
| 125 | + return configured || path.join(config.PROJECTCATALOG, '.recording_tasks_v2'); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + getManifestPath(key) { | ||
| 129 | + const digest = crypto.createHash('sha256').update(key, 'utf8').digest('hex'); | ||
| 130 | + return path.join(this.getStateDir(), `${digest}.json`); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + loadManifest(key) { | ||
| 134 | + try { | ||
| 135 | + return JSON.parse(fs.readFileSync(this.getManifestPath(key), 'utf8')); | ||
| 136 | + } catch (error) { | ||
| 137 | + if (error && error.code === 'ENOENT') return null; | ||
| 138 | + throw error; | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + saveManifest(manifest) { | ||
| 143 | + const stateDir = this.getStateDir(); | ||
| 144 | + fs.mkdirSync(stateDir, { recursive: true }); | ||
| 145 | + const target = this.getManifestPath(manifest.key); | ||
| 146 | + const temp = `${target}.${process.pid}.tmp`; | ||
| 147 | + fs.writeFileSync(temp, JSON.stringify(manifest, null, 2)); | ||
| 148 | + fs.renameSync(temp, target); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + getOutputBaseUrl() { | ||
| 152 | + const config = this.readConfig(); | ||
| 153 | + return String((config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.outputBaseUrl) || | ||
| 154 | + 'https://xdymp4.xuedianyun.com').replace(/\/$/, ''); | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + buildFullFile(task) { | ||
| 158 | + const ossKey = `oss/${task.siteId}/${task.classDate}/${task.classId}.mp4`; | ||
| 159 | + return { type: 'full', ossKey, url: `${this.getOutputBaseUrl()}/${ossKey}` }; | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + buildHighlightFile(item) { | ||
| 163 | + const config = this.readConfig(); | ||
| 164 | + const namespace = String((config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.outputNamespace) || '').trim(); | ||
| 165 | + const prefix = namespace ? `oss/${namespace}` : 'oss'; | ||
| 166 | + const date = formatShanghaiDate(item.beginTime); | ||
| 167 | + const ossKey = `${prefix}/${item.siteId}/${date}/${item.classId}_highlight_${item.highlightId}.mp4`; | ||
| 168 | + return { | ||
| 169 | + type: 'highlight', | ||
| 170 | + highlightId: item.highlightId, | ||
| 171 | + beginTime: item.beginTime, | ||
| 172 | + endTime: item.endTime, | ||
| 173 | + ossKey, | ||
| 174 | + url: `${this.getOutputBaseUrl()}/${ossKey}` | ||
| 175 | + }; | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + getOssClient() { | ||
| 179 | + if (this.ossClient) return this.ossClient; | ||
| 180 | + if (!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID || !process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET) { | ||
| 181 | + throw new Error('OSS环境变量未配置'); | ||
| 182 | + } | ||
| 183 | + this.ossClient = new OSS({ | ||
| 184 | + region: 'oss-cn-beijing', | ||
| 185 | + accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID, | ||
| 186 | + accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET, | ||
| 187 | + authorizationV4: true, | ||
| 188 | + bucket: 'xdymp4' | ||
| 189 | + }); | ||
| 190 | + return this.ossClient; | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + async inspectObject(ossKey) { | ||
| 194 | + if (this.inspectObjectOverride) return Boolean(await this.inspectObjectOverride(ossKey)); | ||
| 195 | + const cached = this.objectStatusCache.get(ossKey); | ||
| 196 | + if (cached && cached.expiresAt > Date.now()) return cached.exists; | ||
| 197 | + let exists; | ||
| 198 | + try { | ||
| 199 | + await this.getOssClient().head(ossKey); | ||
| 200 | + exists = true; | ||
| 201 | + } catch (error) { | ||
| 202 | + const status = error && (error.status || (error.res && error.res.status)); | ||
| 203 | + if (status === 404 || (error && error.code === 'NoSuchKey')) exists = false; | ||
| 204 | + else throw error; | ||
| 205 | + } | ||
| 206 | + this.objectStatusCache.set(ossKey, { exists, expiresAt: Date.now() + 5000 }); | ||
| 207 | + return exists; | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + async updateTaskStatus(task, status) { | ||
| 211 | + if (this.updateTaskStatusOverride) { | ||
| 212 | + await this.updateTaskStatusOverride(task, status); | ||
| 213 | + return; | ||
| 214 | + } | ||
| 215 | + const config = this.readConfig().HIGHLIGHTCONFIG || {}; | ||
| 216 | + const timestamp = String(Date.now()); | ||
| 217 | + const body = { | ||
| 218 | + siteId: task.siteId, | ||
| 219 | + classId: task.classId, | ||
| 220 | + status, | ||
| 221 | + timestamp, | ||
| 222 | + authId: crypto.createHash('md5') | ||
| 223 | + .update(`${task.siteId}${task.classId}${timestamp}`, 'utf8') | ||
| 224 | + .digest('hex') | ||
| 225 | + }; | ||
| 226 | + const url = new URL( | ||
| 227 | + '/3m/api/recording/updateRecordingTask.do', | ||
| 228 | + `${String(config.apiBaseUrl || 'https://saas.xuedianyun.com').replace(/\/$/, '')}/` | ||
| 229 | + ).toString(); | ||
| 230 | + let response; | ||
| 231 | + const retryCount = Number(config.apiRetryCount) || 0; | ||
| 232 | + for (let attempt = 0; attempt <= retryCount; attempt += 1) { | ||
| 233 | + try { | ||
| 234 | + response = await axios.post(url, querystring.stringify(body), { | ||
| 235 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | ||
| 236 | + timeout: Number(config.apiTimeoutMs) || 10000 | ||
| 237 | + }); | ||
| 238 | + break; | ||
| 239 | + } catch (error) { | ||
| 240 | + if (attempt >= retryCount) { | ||
| 241 | + throw new HighlightUpstreamError(-1, '录制任务状态回写失败'); | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + } | ||
| 245 | + const result = response && response.data || {}; | ||
| 246 | + if (Number(result.code) !== 0) { | ||
| 247 | + throw new HighlightUpstreamError(Number(result.code), `录制任务状态回写错误 code=${result.code}`); | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + buildManifest(task, highlights, status) { | ||
| 252 | + const key = buildManifestKey(task); | ||
| 253 | + const existing = this.loadManifest(key); | ||
| 254 | + const taskIds = existing && Array.isArray(existing.taskIds) ? existing.taskIds.slice() : []; | ||
| 255 | + if (task.taskId && !taskIds.includes(task.taskId)) taskIds.push(task.taskId); | ||
| 256 | + return { | ||
| 257 | + key, | ||
| 258 | + siteId: task.siteId, | ||
| 259 | + classId: task.classId, | ||
| 260 | + classDate: task.classDate, | ||
| 261 | + classStartTime: task.classStartTime, | ||
| 262 | + beginTime: task.beginTime, | ||
| 263 | + endTime: task.endTime, | ||
| 264 | + onlyHighlight: task.onlyHighlight, | ||
| 265 | + highlights: highlights || [], | ||
| 266 | + taskIds, | ||
| 267 | + status, | ||
| 268 | + createdAt: existing ? existing.createdAt : Date.now(), | ||
| 269 | + updatedAt: Date.now() | ||
| 270 | + }; | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + async prepareHighlights(task) { | ||
| 274 | + const config = this.readConfig(); | ||
| 275 | + const maxDuration = Number(config.HIGHLIGHTCONFIG && config.HIGHLIGHTCONFIG.maxDurationMs) || | ||
| 276 | + 6 * 60 * 60 * 1000; | ||
| 277 | + const records = await this.highlightService.fetchByClass(task.classId); | ||
| 278 | + const byId = new Map(); | ||
| 279 | + for (const record of records) { | ||
| 280 | + const item = normalizeHighlight(record, maxDuration); | ||
| 281 | + if (item.classId !== task.classId || item.siteId !== task.siteId) continue; | ||
| 282 | + if (task.beginTime != null && | ||
| 283 | + (item.beginTime < task.beginTime || item.endTime > task.endTime)) continue; | ||
| 284 | + byId.set(item.highlightId, item); | ||
| 285 | + } | ||
| 286 | + return Array.from(byId.values()).sort((a, b) => a.highlightId - b.highlightId); | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + async scheduleHighlights(task, normalizedHighlights) { | ||
| 290 | + const key = buildManifestKey(task); | ||
| 291 | + this.inFlight.add(key); | ||
| 292 | + try { | ||
| 293 | + const queued = await this.highlightService.enqueue(normalizedHighlights, { skipSiteCheck: true }); | ||
| 294 | + const invalid = Array.isArray(queued) && queued.find(item => item.status === 'invalid'); | ||
| 295 | + if (invalid) throw new Error(`高光任务无效: ${invalid.error || invalid.highlightId}`); | ||
| 296 | + const taskKeys = normalizedHighlights.map(item => `${item.siteId}:${item.highlightId}`); | ||
| 297 | + const statuses = await this.highlightService.waitForTaskKeys(taskKeys); | ||
| 298 | + const failed = statuses.find(item => item.status === 'failed' || item.status === 'not_found'); | ||
| 299 | + if (failed) throw new Error(`高光录制失败: ${failed.key || failed.status}`); | ||
| 300 | + await this.updateTaskStatus(task, 2); | ||
| 301 | + const manifest = this.loadManifest(key); | ||
| 302 | + if (manifest) { | ||
| 303 | + manifest.status = 'completed'; | ||
| 304 | + manifest.updatedAt = Date.now(); | ||
| 305 | + this.saveManifest(manifest); | ||
| 306 | + } | ||
| 307 | + } catch (error) { | ||
| 308 | + const manifest = this.loadManifest(key); | ||
| 309 | + if (manifest) { | ||
| 310 | + manifest.status = 'failed'; | ||
| 311 | + manifest.updatedAt = Date.now(); | ||
| 312 | + this.saveManifest(manifest); | ||
| 313 | + } | ||
| 314 | + throw error; | ||
| 315 | + } finally { | ||
| 316 | + this.inFlight.delete(key); | ||
| 317 | + } | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + async acceptTasks(body, handlers) { | ||
| 321 | + const input = body || {}; | ||
| 322 | + const list = input.list; | ||
| 323 | + if (!Array.isArray(list) || list.length === 0) { | ||
| 324 | + throw new RecordingTaskValidationError('list 必须是非空数组'); | ||
| 325 | + } | ||
| 326 | + if (list.length > 1000) throw new RecordingTaskValidationError('list 不能超过1000条'); | ||
| 327 | + const maxMedia = Number(input.maxMedia); | ||
| 328 | + if (Number.isSafeInteger(maxMedia) && maxMedia > 0 && list.length > maxMedia) { | ||
| 329 | + throw new RecordingTaskValidationError('数组长度超过设置的最大值'); | ||
| 330 | + } | ||
| 331 | + | ||
| 332 | + const normalizedTasks = list.map(raw => normalizeRecordingTask(raw)); | ||
| 333 | + const recordFullClass = handlers && handlers.recordFullClass; | ||
| 334 | + let accepted = 0; | ||
| 335 | + let duplicates = 0; | ||
| 336 | + let noMedia = 0; | ||
| 337 | + | ||
| 338 | + const processTask = async task => { | ||
| 339 | + const key = buildManifestKey(task); | ||
| 340 | + const existing = this.loadManifest(key); | ||
| 341 | + const sameTaskId = task.taskId && existing && Array.isArray(existing.taskIds) && | ||
| 342 | + existing.taskIds.includes(task.taskId); | ||
| 343 | + const sameMode = existing && existing.onlyHighlight === task.onlyHighlight; | ||
| 344 | + const completed = existing && (existing.status === 'completed' || existing.status === 'no_media'); | ||
| 345 | + const acceptedFullTask = sameTaskId && task.onlyHighlight === 0 && existing.status === 'accepted'; | ||
| 346 | + if (this.inFlight.has(key) || (sameMode && (completed || acceptedFullTask))) { | ||
| 347 | + duplicates += 1; | ||
| 348 | + return; | ||
| 349 | + } | ||
| 350 | + | ||
| 351 | + if (task.onlyHighlight === 0) { | ||
| 352 | + const manifest = this.buildManifest(task, [], 'accepted'); | ||
| 353 | + this.saveManifest(manifest); | ||
| 354 | + if (typeof recordFullClass === 'function') await recordFullClass(task); | ||
| 355 | + accepted += 1; | ||
| 356 | + return; | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + // 在请求上游高光数据前占位,避免 cron 并发重投同一课堂。 | ||
| 360 | + this.inFlight.add(key); | ||
| 361 | + let backgroundStarted = false; | ||
| 362 | + try { | ||
| 363 | + const normalizedHighlights = await this.prepareHighlights(task); | ||
| 364 | + const highlightFiles = normalizedHighlights.map(item => this.buildHighlightFile(item)); | ||
| 365 | + const manifest = this.buildManifest(task, highlightFiles, | ||
| 366 | + normalizedHighlights.length === 0 ? 'no_media' : 'accepted'); | ||
| 367 | + this.saveManifest(manifest); | ||
| 368 | + accepted += 1; | ||
| 369 | + if (normalizedHighlights.length === 0) { | ||
| 370 | + await this.updateTaskStatus(task, 3); | ||
| 371 | + noMedia += 1; | ||
| 372 | + return; | ||
| 373 | + } | ||
| 374 | + const backgroundTask = this.scheduleHighlights(task, normalizedHighlights); | ||
| 375 | + backgroundStarted = true; | ||
| 376 | + backgroundTask.catch(error => { | ||
| 377 | + console.error(`V2高光录制任务执行失败 ${key}:`, error); | ||
| 378 | + }); | ||
| 379 | + } finally { | ||
| 380 | + if (!backgroundStarted) this.inFlight.delete(key); | ||
| 381 | + } | ||
| 382 | + }; | ||
| 383 | + | ||
| 384 | + const grouped = Array.from(normalizedTasks.reduce((result, task) => { | ||
| 385 | + const key = buildManifestKey(task); | ||
| 386 | + const tasks = result.get(key) || []; | ||
| 387 | + tasks.push(task); | ||
| 388 | + result.set(key, tasks); | ||
| 389 | + return result; | ||
| 390 | + }, new Map()).values()); | ||
| 391 | + for (let index = 0; index < grouped.length; index += 10) { | ||
| 392 | + const batch = grouped.slice(index, index + 10); | ||
| 393 | + await Promise.all(batch.map(async tasks => { | ||
| 394 | + for (const task of tasks) await processTask(task); | ||
| 395 | + })); | ||
| 396 | + } | ||
| 397 | + return { accepted, duplicates, noMedia }; | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + async getFileResult(params) { | ||
| 401 | + const input = params || {}; | ||
| 402 | + const siteId = String(input.siteId || ''); | ||
| 403 | + const classId = String(input.classId || ''); | ||
| 404 | + if (!isSafeIdentifier(siteId)) throw new RecordingTaskValidationError('siteId 无效', 2); | ||
| 405 | + if (!isSafeIdentifier(classId)) throw new RecordingTaskValidationError('classId 无效', 3); | ||
| 406 | + | ||
| 407 | + const key = buildManifestKey({ siteId, classId }); | ||
| 408 | + const manifest = this.loadManifest(key); | ||
| 409 | + const requestedPeriod = normalizeTaskPeriod(input, null, false); | ||
| 410 | + if (manifest && requestedPeriod && requestedPeriod.beginTime != null && manifest.beginTime != null && | ||
| 411 | + (requestedPeriod.beginTime !== manifest.beginTime || requestedPeriod.endTime !== manifest.endTime)) { | ||
| 412 | + return { code: 1, message: '文件未生成', fileExists: false, files: [] }; | ||
| 413 | + } | ||
| 414 | + | ||
| 415 | + let expected = []; | ||
| 416 | + let onlyHighlight; | ||
| 417 | + if (manifest) { | ||
| 418 | + onlyHighlight = manifest.onlyHighlight; | ||
| 419 | + expected = onlyHighlight === 1 ? (manifest.highlights || []) : [this.buildFullFile(manifest)]; | ||
| 420 | + } else { | ||
| 421 | + onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight); | ||
| 422 | + if (onlyHighlight === 1 || !requestedPeriod) { | ||
| 423 | + return { code: 1, message: '文件未生成', fileExists: false, files: [] }; | ||
| 424 | + } | ||
| 425 | + expected = [this.buildFullFile({ siteId, classId, classDate: requestedPeriod.classDate })]; | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + if (expected.length === 0) { | ||
| 429 | + return { code: 1, message: '文件未生成', fileExists: false, files: [] }; | ||
| 430 | + } | ||
| 431 | + const statuses = []; | ||
| 432 | + for (let index = 0; index < expected.length; index += 20) { | ||
| 433 | + const batch = expected.slice(index, index + 20); | ||
| 434 | + statuses.push(...await Promise.all(batch.map(async file => ({ | ||
| 435 | + file, | ||
| 436 | + exists: await this.inspectObject(file.ossKey) | ||
| 437 | + })))); | ||
| 438 | + } | ||
| 439 | + const fileExists = statuses.every(item => item.exists); | ||
| 440 | + const files = fileExists ? statuses.map(item => { | ||
| 441 | + const result = { type: item.file.type, url: item.file.url }; | ||
| 442 | + if (item.file.highlightId != null) result.highlightId = item.file.highlightId; | ||
| 443 | + return result; | ||
| 444 | + }) : []; | ||
| 445 | + const response = { | ||
| 446 | + code: fileExists ? 0 : 1, | ||
| 447 | + message: fileExists ? '文件已生成' : '文件未生成', | ||
| 448 | + fileExists, | ||
| 449 | + onlyHighlight, | ||
| 450 | + files | ||
| 451 | + }; | ||
| 452 | + if (fileExists && onlyHighlight === 0) response.classUrl = files[0].url; | ||
| 453 | + return response; | ||
| 454 | + } | ||
| 455 | +} | ||
| 456 | + | ||
| 457 | +const recordingTaskService = new RecordingTaskService(); | ||
| 458 | + | ||
| 459 | +module.exports = { | ||
| 460 | + RecordingTaskService, | ||
| 461 | + RecordingTaskValidationError, | ||
| 462 | + buildManifestKey, | ||
| 463 | + normalizeClassDate, | ||
| 464 | + normalizeOnlyHighlight, | ||
| 465 | + normalizeRecordingTask, | ||
| 466 | + normalizeTaskPeriod, | ||
| 467 | + parseTaskTime, | ||
| 468 | + recordingTaskService, | ||
| 469 | + HighlightUpstreamError, | ||
| 470 | + HighlightValidationError | ||
| 471 | +}; |
| @@ -142,7 +142,6 @@ async function run() { | @@ -142,7 +142,6 @@ async function run() { | ||
| 142 | scheduledSiteService.readConfig = () => ({ | 142 | scheduledSiteService.readConfig = () => ({ |
| 143 | highlight: { | 143 | highlight: { |
| 144 | enabled: true, | 144 | enabled: true, |
| 145 | - sourceMode: 'site', | ||
| 146 | siteIds: ['xdyui2'], | 145 | siteIds: ['xdyui2'], |
| 147 | maxConcurrent: 1, | 146 | maxConcurrent: 1, |
| 148 | taskRetentionMs: 86400000 | 147 | taskRetentionMs: 86400000 |
| @@ -159,34 +158,6 @@ async function run() { | @@ -159,34 +158,6 @@ async function run() { | ||
| 159 | assert.strictEqual(siteQuery.siteId, 'xdyui2'); | 158 | assert.strictEqual(siteQuery.siteId, 'xdyui2'); |
| 160 | assert.strictEqual(scheduledSite.received, 0); | 159 | assert.strictEqual(scheduledSite.received, 0); |
| 161 | 160 | ||
| 162 | - const scheduledTaskService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' }); | ||
| 163 | - scheduledTaskService.readConfig = () => ({ | ||
| 164 | - highlight: { | ||
| 165 | - enabled: true, | ||
| 166 | - sourceMode: 'task', | ||
| 167 | - siteIds: ['xdyui2'], | ||
| 168 | - maxConcurrent: 1, | ||
| 169 | - taskRetentionMs: 86400000 | ||
| 170 | - } | ||
| 171 | - }); | ||
| 172 | - scheduledTaskService.fetchRecordingTasks = async () => [ | ||
| 173 | - { meetingNumber: '1001', siteId: 'xdyui2', status: 0, onlyHighlight: 1 }, | ||
| 174 | - { meetingNumber: '1002', siteId: 'xdyui2', status: 1, onlyHighlight: 1 }, | ||
| 175 | - { meetingNumber: '1003', siteId: 'xdyui2', status: 0, onlyHighlight: 0 }, | ||
| 176 | - { meetingNumber: '1004', siteId: 'other', status: 0, onlyHighlight: 1 } | ||
| 177 | - ]; | ||
| 178 | - const fetchedClasses = []; | ||
| 179 | - scheduledTaskService.fetchByClass = async classId => { | ||
| 180 | - fetchedClasses.push(classId); | ||
| 181 | - return [Object.assign({}, item, { meetingNumber: classId, siteId: 'xdyui2' })]; | ||
| 182 | - }; | ||
| 183 | - scheduledTaskService.enqueue = async records => records.map(record => ({ status: 'queued', record })); | ||
| 184 | - const scheduledTask = await scheduledTaskService.runScheduledRecording(); | ||
| 185 | - assert.deepStrictEqual(fetchedClasses, ['1001']); | ||
| 186 | - assert.strictEqual(scheduledTask.sourceTasks, 4); | ||
| 187 | - assert.strictEqual(scheduledTask.pendingTasks, 1); | ||
| 188 | - assert.strictEqual(scheduledTask.queued, 1); | ||
| 189 | - | ||
| 190 | const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-highlight-')); | 161 | const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-highlight-')); |
| 191 | try { | 162 | try { |
| 192 | const captureService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' }); | 163 | const captureService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' }); |
| 1 | const assert = require('assert'); | 1 | const assert = require('assert'); |
| 2 | const http = require('http'); | 2 | const http = require('http'); |
| 3 | -const { highlightRecordingService } = require('../services/highlightRecordingService'); | 3 | +const { |
| 4 | + HighlightValidationError, | ||
| 5 | + highlightRecordingService | ||
| 6 | +} = require('../services/highlightRecordingService'); | ||
| 4 | const app = require('../app'); | 7 | const app = require('../app'); |
| 5 | 8 | ||
| 6 | function request(server, method, route, body) { | 9 | function request(server, method, route, body) { |
| @@ -42,12 +45,20 @@ async function run() { | @@ -42,12 +45,20 @@ async function run() { | ||
| 42 | assert.strictEqual(invalidFiles.status, 400); | 45 | assert.strictEqual(invalidFiles.status, 400); |
| 43 | assert.strictEqual(invalidFiles.body.code, 10); | 46 | assert.strictEqual(invalidFiles.body.code, 10); |
| 44 | 47 | ||
| 48 | + const originalGetFileStatusesForDisabled = highlightRecordingService.getFileStatuses; | ||
| 49 | + try { | ||
| 50 | + highlightRecordingService.getFileStatuses = async () => { | ||
| 51 | + throw new HighlightValidationError('高光录制功能未启用'); | ||
| 52 | + }; | ||
| 45 | const disabledFiles = await request(server, 'POST', '/highlight/files', { | 53 | const disabledFiles = await request(server, 'POST', '/highlight/files', { |
| 46 | items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }] | 54 | items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }] |
| 47 | }); | 55 | }); |
| 48 | assert.strictEqual(disabledFiles.status, 400); | 56 | assert.strictEqual(disabledFiles.status, 400); |
| 49 | assert.strictEqual(disabledFiles.body.code, 10); | 57 | assert.strictEqual(disabledFiles.body.code, 10); |
| 50 | assert.strictEqual(disabledFiles.body.message, '高光录制功能未启用'); | 58 | assert.strictEqual(disabledFiles.body.message, '高光录制功能未启用'); |
| 59 | + } finally { | ||
| 60 | + highlightRecordingService.getFileStatuses = originalGetFileStatusesForDisabled; | ||
| 61 | + } | ||
| 51 | 62 | ||
| 52 | const invalidPreview = await request(server, 'POST', '/highlight/preview/by-class', {}); | 63 | const invalidPreview = await request(server, 'POST', '/highlight/preview/by-class', {}); |
| 53 | assert.strictEqual(invalidPreview.status, 400); | 64 | assert.strictEqual(invalidPreview.status, 400); |
| @@ -57,13 +68,16 @@ async function run() { | @@ -57,13 +68,16 @@ async function run() { | ||
| 57 | assert.strictEqual(invalidClassFiles.status, 400); | 68 | assert.strictEqual(invalidClassFiles.status, 400); |
| 58 | assert.strictEqual(invalidClassFiles.body.code, 10); | 69 | assert.strictEqual(invalidClassFiles.body.code, 10); |
| 59 | 70 | ||
| 71 | + const originalRunScheduledRecording = highlightRecordingService.runScheduledRecording; | ||
| 72 | + try { | ||
| 73 | + highlightRecordingService.runScheduledRecording = async () => { | ||
| 74 | + throw new HighlightValidationError('高光录制功能未启用'); | ||
| 75 | + }; | ||
| 60 | const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled'); | 76 | const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled'); |
| 61 | assert.strictEqual(disabledScheduled.status, 400); | 77 | assert.strictEqual(disabledScheduled.status, 400); |
| 62 | assert.strictEqual(disabledScheduled.body.code, 10); | 78 | assert.strictEqual(disabledScheduled.body.code, 10); |
| 63 | assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用'); | 79 | assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用'); |
| 64 | 80 | ||
| 65 | - const originalRunScheduledRecording = highlightRecordingService.runScheduledRecording; | ||
| 66 | - try { | ||
| 67 | highlightRecordingService.runScheduledRecording = async () => ({ | 81 | highlightRecordingService.runScheduledRecording = async () => ({ |
| 68 | mode: 'site', | 82 | mode: 'site', |
| 69 | beginTime: 1785945600000, | 83 | beginTime: 1785945600000, |
test/recordingRoutes.test.js
0 → 100644
| 1 | +const assert = require('assert'); | ||
| 2 | +const http = require('http'); | ||
| 3 | +const { recordingTaskService } = require('../services/recordingTaskService'); | ||
| 4 | +const app = require('../app'); | ||
| 5 | + | ||
| 6 | +function request(server, route, body) { | ||
| 7 | + return new Promise((resolve, reject) => { | ||
| 8 | + const address = server.address(); | ||
| 9 | + const payload = JSON.stringify(body || {}); | ||
| 10 | + const req = http.request({ | ||
| 11 | + host: '127.0.0.1', | ||
| 12 | + port: address.port, | ||
| 13 | + path: route, | ||
| 14 | + method: 'POST', | ||
| 15 | + headers: { | ||
| 16 | + 'Content-Type': 'application/json', | ||
| 17 | + 'Content-Length': Buffer.byteLength(payload) | ||
| 18 | + } | ||
| 19 | + }, response => { | ||
| 20 | + let data = ''; | ||
| 21 | + response.setEncoding('utf8'); | ||
| 22 | + response.on('data', chunk => { data += chunk; }); | ||
| 23 | + response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(data) })); | ||
| 24 | + }); | ||
| 25 | + req.on('error', reject); | ||
| 26 | + req.write(payload); | ||
| 27 | + req.end(); | ||
| 28 | + }); | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +async function run() { | ||
| 32 | + const server = http.createServer(app); | ||
| 33 | + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); | ||
| 34 | + const originalAcceptTasks = recordingTaskService.acceptTasks; | ||
| 35 | + const originalGetFileResult = recordingTaskService.getFileResult; | ||
| 36 | + let acceptedCalls = 0; | ||
| 37 | + let fileCalls = 0; | ||
| 38 | + const acceptedBodies = []; | ||
| 39 | + try { | ||
| 40 | + recordingTaskService.acceptTasks = async (body, handlers) => { | ||
| 41 | + acceptedCalls += 1; | ||
| 42 | + acceptedBodies.push(body); | ||
| 43 | + assert.strictEqual(typeof handlers.recordFullClass, 'function'); | ||
| 44 | + return { accepted: 1, duplicates: 0, noMedia: 0 }; | ||
| 45 | + }; | ||
| 46 | + recordingTaskService.getFileResult = async body => { | ||
| 47 | + fileCalls += 1; | ||
| 48 | + assert.strictEqual(body.classId, '1001'); | ||
| 49 | + return { | ||
| 50 | + code: 0, | ||
| 51 | + message: '文件已生成', | ||
| 52 | + fileExists: true, | ||
| 53 | + onlyHighlight: 1, | ||
| 54 | + files: [{ type: 'highlight', highlightId: 5, url: 'https://example/highlight.mp4' }] | ||
| 55 | + }; | ||
| 56 | + }; | ||
| 57 | + | ||
| 58 | + const oldTask = await request(server, '/recordingTask', {}); | ||
| 59 | + assert.strictEqual(oldTask.status, 200); | ||
| 60 | + assert.strictEqual(oldTask.body.code, '1'); | ||
| 61 | + assert.strictEqual(acceptedCalls, 0, '旧任务接口不得进入 V2 服务'); | ||
| 62 | + | ||
| 63 | + const highTask = await request(server, '/recordingTaskV2', { | ||
| 64 | + list: [{ | ||
| 65 | + id: 'task-high-1', | ||
| 66 | + siteId: 'doctest', | ||
| 67 | + meetingNumber: '1001', | ||
| 68 | + beginTime: '2026-08-05 10:00:00', | ||
| 69 | + endTime: '2026-08-05 11:00:00', | ||
| 70 | + onlyHighlight: 1 | ||
| 71 | + }] | ||
| 72 | + }); | ||
| 73 | + assert.strictEqual(highTask.status, 200); | ||
| 74 | + assert.deepStrictEqual(highTask.body, { | ||
| 75 | + code: '0', | ||
| 76 | + message: 'success', | ||
| 77 | + accepted: 1, | ||
| 78 | + duplicates: 0, | ||
| 79 | + noMedia: 0, | ||
| 80 | + v: 'v1.2.0.20251208' | ||
| 81 | + }); | ||
| 82 | + assert.strictEqual(acceptedBodies[0].list[0].onlyHighlight, 1); | ||
| 83 | + | ||
| 84 | + const compatibleFullTask = await request(server, '/recordingTaskV2', { | ||
| 85 | + list: [{ siteId: 'doctest', classId: '1001', yymmdd: '20260805' }] | ||
| 86 | + }); | ||
| 87 | + assert.strictEqual(compatibleFullTask.status, 200); | ||
| 88 | + assert.strictEqual(compatibleFullTask.body.code, '0'); | ||
| 89 | + assert.strictEqual(acceptedCalls, 2); | ||
| 90 | + assert.strictEqual(acceptedBodies[1].list[0].onlyHighlight, undefined); | ||
| 91 | + | ||
| 92 | + const oldFile = await request(server, '/fileExists', { | ||
| 93 | + siteId: 'doctest', classId: '1001' | ||
| 94 | + }); | ||
| 95 | + assert.strictEqual(oldFile.status, 200); | ||
| 96 | + assert.strictEqual(oldFile.body.code, 1); | ||
| 97 | + assert.match(oldFile.body.classUrl, /classId=1001/); | ||
| 98 | + assert.strictEqual(fileCalls, 0, '旧查询接口不得进入 V2 服务'); | ||
| 99 | + | ||
| 100 | + const newFile = await request(server, '/fileExistsV2', { | ||
| 101 | + siteId: 'doctest', classId: '1001' | ||
| 102 | + }); | ||
| 103 | + assert.strictEqual(newFile.status, 200); | ||
| 104 | + assert.strictEqual(newFile.body.fileExists, true); | ||
| 105 | + assert.strictEqual(newFile.body.onlyHighlight, 1); | ||
| 106 | + assert.strictEqual(newFile.body.files.length, 1); | ||
| 107 | + assert.strictEqual(fileCalls, 1); | ||
| 108 | + } finally { | ||
| 109 | + recordingTaskService.acceptTasks = originalAcceptTasks; | ||
| 110 | + recordingTaskService.getFileResult = originalGetFileResult; | ||
| 111 | + await new Promise(resolve => server.close(resolve)); | ||
| 112 | + } | ||
| 113 | + console.log('recording V2 routes tests passed'); | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +run().catch(error => { | ||
| 117 | + console.error(error); | ||
| 118 | + process.exitCode = 1; | ||
| 119 | +}); |
test/recordingTaskService.test.js
0 → 100644
| 1 | +const assert = require('assert'); | ||
| 2 | +const fs = require('fs'); | ||
| 3 | +const os = require('os'); | ||
| 4 | +const path = require('path'); | ||
| 5 | +const { | ||
| 6 | + RecordingTaskService, | ||
| 7 | + buildManifestKey, | ||
| 8 | + normalizeOnlyHighlight, | ||
| 9 | + normalizeRecordingTask, | ||
| 10 | + parseTaskTime | ||
| 11 | +} = require('../services/recordingTaskService'); | ||
| 12 | + | ||
| 13 | +async function waitForBackground(service) { | ||
| 14 | + for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 15 | + if (service.inFlight.size === 0) return; | ||
| 16 | + await new Promise(resolve => setTimeout(resolve, 10)); | ||
| 17 | + } | ||
| 18 | + throw new Error('后台高光任务未在测试时间内结束'); | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +async function run() { | ||
| 22 | + assert.strictEqual(normalizeOnlyHighlight(undefined), 0); | ||
| 23 | + assert.strictEqual(normalizeOnlyHighlight(0), 0); | ||
| 24 | + assert.strictEqual(normalizeOnlyHighlight('1'), 1); | ||
| 25 | + assert.strictEqual(normalizeOnlyHighlight(2), 0); | ||
| 26 | + | ||
| 27 | + assert.deepStrictEqual(normalizeRecordingTask({ | ||
| 28 | + siteId: 'doctest', | ||
| 29 | + classId: '1001', | ||
| 30 | + yymmdd: '20260805' | ||
| 31 | + }), { | ||
| 32 | + taskId: '', | ||
| 33 | + siteId: 'doctest', | ||
| 34 | + classId: '1001', | ||
| 35 | + onlyHighlight: 0, | ||
| 36 | + beginTime: null, | ||
| 37 | + endTime: null, | ||
| 38 | + classDate: '20260805', | ||
| 39 | + classStartTime: '20260805' | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + const saasTask = normalizeRecordingTask({ | ||
| 43 | + id: 'task-high-1', | ||
| 44 | + siteId: 'doctest', | ||
| 45 | + meetingNumber: '1002', | ||
| 46 | + beginTime: '2026-08-05 10:00:00', | ||
| 47 | + endTime: '2026-08-05 11:00:00', | ||
| 48 | + onlyHighlight: 1 | ||
| 49 | + }); | ||
| 50 | + assert.strictEqual(saasTask.classId, '1002'); | ||
| 51 | + assert.strictEqual(saasTask.onlyHighlight, 1); | ||
| 52 | + assert.strictEqual(saasTask.classDate, '20260805'); | ||
| 53 | + assert.strictEqual(saasTask.beginTime, 1785895200000); | ||
| 54 | + assert.strictEqual(parseTaskTime(1785895200000, 'beginTime'), 1785895200000); | ||
| 55 | + assert.throws(() => normalizeRecordingTask({ | ||
| 56 | + siteId: 'doctest', classId: '1002', beginTime: 1785895200000 | ||
| 57 | + }), /必须同时传入/); | ||
| 58 | + assert.throws(() => normalizeRecordingTask({ | ||
| 59 | + siteId: 'doctest', classId: '1002', yymmdd: 1785895200000 | ||
| 60 | + }), /yyyyMMdd/); | ||
| 61 | + | ||
| 62 | + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-v2-task-')); | ||
| 63 | + const configPath = path.join(tempRoot, 'config.json'); | ||
| 64 | + fs.writeFileSync(configPath, JSON.stringify({ | ||
| 65 | + PROJECTCATALOG: tempRoot, | ||
| 66 | + HIGHLIGHTCONFIG: { | ||
| 67 | + enabled: true, | ||
| 68 | + siteIds: ['doctest'], | ||
| 69 | + maxDurationMs: 3600000, | ||
| 70 | + outputBaseUrl: 'https://xdymp4.xuedianyun.com', | ||
| 71 | + outputNamespace: '' | ||
| 72 | + } | ||
| 73 | + })); | ||
| 74 | + | ||
| 75 | + const records = [{ | ||
| 76 | + id: 5, | ||
| 77 | + meetingNumber: '1002', | ||
| 78 | + siteId: 'doctest', | ||
| 79 | + beginTime: 1785895298000, | ||
| 80 | + endTime: 1785895343000 | ||
| 81 | + }, { | ||
| 82 | + id: 6, | ||
| 83 | + meetingNumber: '1002', | ||
| 84 | + siteId: 'doctest', | ||
| 85 | + beginTime: 1785895498000, | ||
| 86 | + endTime: 1785895543000 | ||
| 87 | + }, { | ||
| 88 | + id: 7, | ||
| 89 | + meetingNumber: '1002', | ||
| 90 | + siteId: 'doctest', | ||
| 91 | + beginTime: 1785899498000, | ||
| 92 | + endTime: 1785899543000 | ||
| 93 | + }]; | ||
| 94 | + const calls = { fetch: 0, enqueue: 0, wait: 0 }; | ||
| 95 | + const highlightService = { | ||
| 96 | + fetchByClass: async classId => { | ||
| 97 | + calls.fetch += 1; | ||
| 98 | + return classId === '1002' ? records : []; | ||
| 99 | + }, | ||
| 100 | + enqueue: async items => { | ||
| 101 | + calls.enqueue += 1; | ||
| 102 | + return items.map(item => ({ status: 'queued', highlightId: item.highlightId })); | ||
| 103 | + }, | ||
| 104 | + waitForTaskKeys: async keys => { | ||
| 105 | + calls.wait += 1; | ||
| 106 | + return keys.map(key => ({ key, status: 'uploading' })); | ||
| 107 | + } | ||
| 108 | + }; | ||
| 109 | + const objects = new Set(); | ||
| 110 | + const statusUpdates = []; | ||
| 111 | + const service = new RecordingTaskService({ | ||
| 112 | + configPath, | ||
| 113 | + highlightService, | ||
| 114 | + inspectObject: async key => objects.has(key), | ||
| 115 | + updateTaskStatus: async (task, status) => { statusUpdates.push(`${task.classId}:${status}`); } | ||
| 116 | + }); | ||
| 117 | + | ||
| 118 | + const fullCalls = []; | ||
| 119 | + const fullResult = await service.acceptTasks({ | ||
| 120 | + list: [{ | ||
| 121 | + id: 'task-full-1', | ||
| 122 | + siteId: 'doctest', | ||
| 123 | + classId: '1001', | ||
| 124 | + yymmdd: '20260805' | ||
| 125 | + }] | ||
| 126 | + }, { | ||
| 127 | + recordFullClass: task => { fullCalls.push(task); } | ||
| 128 | + }); | ||
| 129 | + assert.deepStrictEqual(fullResult, { accepted: 1, duplicates: 0, noMedia: 0 }); | ||
| 130 | + assert.strictEqual(fullCalls.length, 1); | ||
| 131 | + assert.strictEqual(fullCalls[0].onlyHighlight, 0); | ||
| 132 | + assert.strictEqual(calls.fetch, 0, '整课任务不得查询高光接口'); | ||
| 133 | + | ||
| 134 | + let fullFile = await service.getFileResult({ | ||
| 135 | + siteId: 'doctest', classId: '1001', classStartTime: '20260805' | ||
| 136 | + }); | ||
| 137 | + assert.strictEqual(fullFile.fileExists, false); | ||
| 138 | + objects.add('oss/doctest/20260805/1001.mp4'); | ||
| 139 | + fullFile = await service.getFileResult({ siteId: 'doctest', classId: '1001' }); | ||
| 140 | + assert.strictEqual(fullFile.fileExists, true); | ||
| 141 | + assert.strictEqual(fullFile.onlyHighlight, 0); | ||
| 142 | + assert.strictEqual(fullFile.files.length, 1); | ||
| 143 | + assert.strictEqual(fullFile.classUrl, fullFile.files[0].url); | ||
| 144 | + | ||
| 145 | + const highResult = await service.acceptTasks({ list: [{ | ||
| 146 | + id: 'task-high-1', | ||
| 147 | + siteId: 'doctest', | ||
| 148 | + meetingNumber: '1002', | ||
| 149 | + beginTime: '2026-08-05 10:00:00', | ||
| 150 | + endTime: '2026-08-05 11:00:00', | ||
| 151 | + onlyHighlight: 1 | ||
| 152 | + }] }); | ||
| 153 | + assert.deepStrictEqual(highResult, { accepted: 1, duplicates: 0, noMedia: 0 }); | ||
| 154 | + await waitForBackground(service); | ||
| 155 | + assert.strictEqual(calls.fetch, 1); | ||
| 156 | + assert.strictEqual(calls.enqueue, 1); | ||
| 157 | + assert.strictEqual(calls.wait, 1); | ||
| 158 | + assert.deepStrictEqual(statusUpdates, ['1002:2']); | ||
| 159 | + | ||
| 160 | + const highManifest = service.loadManifest(buildManifestKey({ siteId: 'doctest', classId: '1002' })); | ||
| 161 | + assert.strictEqual(highManifest.onlyHighlight, 1); | ||
| 162 | + assert.strictEqual(highManifest.status, 'completed'); | ||
| 163 | + assert.deepStrictEqual(highManifest.highlights.map(item => item.highlightId), [5, 6]); | ||
| 164 | + | ||
| 165 | + const duplicate = await service.acceptTasks({ list: [{ | ||
| 166 | + id: 'task-high-1', | ||
| 167 | + siteId: 'doctest', | ||
| 168 | + meetingNumber: '1002', | ||
| 169 | + beginTime: '2026-08-05 10:00:00', | ||
| 170 | + endTime: '2026-08-05 11:00:00', | ||
| 171 | + onlyHighlight: 1 | ||
| 172 | + }] }); | ||
| 173 | + assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 }); | ||
| 174 | + assert.strictEqual(calls.fetch, 1); | ||
| 175 | + | ||
| 176 | + let highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' }); | ||
| 177 | + assert.strictEqual(highFiles.fileExists, false); | ||
| 178 | + objects.add('oss/doctest/20260805/1002_highlight_5.mp4'); | ||
| 179 | + objects.add('oss/doctest/20260805/1002_highlight_6.mp4'); | ||
| 180 | + highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' }); | ||
| 181 | + assert.strictEqual(highFiles.fileExists, true); | ||
| 182 | + assert.strictEqual(highFiles.onlyHighlight, 1); | ||
| 183 | + assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6]); | ||
| 184 | + | ||
| 185 | + const noMedia = await service.acceptTasks({ list: [{ | ||
| 186 | + id: 'task-high-empty', | ||
| 187 | + siteId: 'doctest', | ||
| 188 | + classId: '1003', | ||
| 189 | + yymmdd: '20260805', | ||
| 190 | + onlyHighlight: 1 | ||
| 191 | + }] }); | ||
| 192 | + assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 }); | ||
| 193 | + assert.deepStrictEqual(statusUpdates, ['1002:2', '1003:3']); | ||
| 194 | + const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' }); | ||
| 195 | + assert.strictEqual(emptyResult.fileExists, false); | ||
| 196 | + | ||
| 197 | + const mappedNoMedia = await service.acceptTasks({ list: [{ | ||
| 198 | + siteId: 'doctest', | ||
| 199 | + classId: '1006', | ||
| 200 | + yymmdd: '20260805', | ||
| 201 | + onlyHighlight: 1 | ||
| 202 | + }] }); | ||
| 203 | + assert.deepStrictEqual(mappedNoMedia, { accepted: 1, duplicates: 0, noMedia: 1 }); | ||
| 204 | + assert.strictEqual(statusUpdates[statusUpdates.length - 1], '1006:3', | ||
| 205 | + '没有 SaaS 任务 id 也必须回写状态'); | ||
| 206 | + | ||
| 207 | + const retryTask = normalizeRecordingTask({ | ||
| 208 | + id: 'task-high-retry', | ||
| 209 | + siteId: 'doctest', | ||
| 210 | + classId: '1004', | ||
| 211 | + yymmdd: '20260805', | ||
| 212 | + onlyHighlight: 1 | ||
| 213 | + }); | ||
| 214 | + service.saveManifest(service.buildManifest(retryTask, [], 'failed')); | ||
| 215 | + const retryResult = await service.acceptTasks({ list: [{ | ||
| 216 | + id: 'task-high-retry', | ||
| 217 | + siteId: 'doctest', | ||
| 218 | + classId: '1004', | ||
| 219 | + yymmdd: '20260805', | ||
| 220 | + onlyHighlight: 1 | ||
| 221 | + }] }); | ||
| 222 | + assert.deepStrictEqual(retryResult, { accepted: 1, duplicates: 0, noMedia: 1 }, | ||
| 223 | + '失败的高光任务必须允许使用相同任务 ID 重试'); | ||
| 224 | + | ||
| 225 | + const fallback = await service.getFileResult({ | ||
| 226 | + siteId: 'doctest', classId: 'legacy', classStartTime: '20260805' | ||
| 227 | + }); | ||
| 228 | + assert.strictEqual(fallback.fileExists, false); | ||
| 229 | + objects.add('oss/doctest/20260805/legacy.mp4'); | ||
| 230 | + const generatedFallback = await service.getFileResult({ | ||
| 231 | + siteId: 'doctest', classId: 'legacy', classStartTime: '20260805' | ||
| 232 | + }); | ||
| 233 | + assert.strictEqual(generatedFallback.fileExists, true); | ||
| 234 | + | ||
| 235 | + let releaseConcurrentFetch; | ||
| 236 | + let concurrentFetches = 0; | ||
| 237 | + const concurrentService = new RecordingTaskService({ | ||
| 238 | + configPath, | ||
| 239 | + highlightService: { | ||
| 240 | + fetchByClass: async () => { | ||
| 241 | + concurrentFetches += 1; | ||
| 242 | + return new Promise(resolve => { releaseConcurrentFetch = resolve; }); | ||
| 243 | + } | ||
| 244 | + }, | ||
| 245 | + updateTaskStatus: async () => {} | ||
| 246 | + }); | ||
| 247 | + const concurrentTask = { | ||
| 248 | + id: 'task-high-concurrent', | ||
| 249 | + siteId: 'doctest', | ||
| 250 | + classId: '1005', | ||
| 251 | + yymmdd: '20260805', | ||
| 252 | + onlyHighlight: 1 | ||
| 253 | + }; | ||
| 254 | + const firstConcurrent = concurrentService.acceptTasks({ list: [concurrentTask] }); | ||
| 255 | + await new Promise(resolve => setImmediate(resolve)); | ||
| 256 | + const duplicateConcurrent = await concurrentService.acceptTasks({ list: [concurrentTask] }); | ||
| 257 | + assert.deepStrictEqual(duplicateConcurrent, { accepted: 0, duplicates: 1, noMedia: 0 }); | ||
| 258 | + assert.strictEqual(concurrentFetches, 1, '并发重投不得重复查询 SaaS 高光接口'); | ||
| 259 | + releaseConcurrentFetch([]); | ||
| 260 | + assert.deepStrictEqual(await firstConcurrent, { accepted: 1, duplicates: 0, noMedia: 1 }); | ||
| 261 | + | ||
| 262 | + fs.rmSync(tempRoot, { recursive: true, force: true }); | ||
| 263 | + console.log('recording task V2 service tests passed'); | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +run().catch(error => { | ||
| 267 | + console.error(error); | ||
| 268 | + process.exitCode = 1; | ||
| 269 | +}); |
-
请 注册 或 登录 后发表评论