huz1xuan

高光时刻接口V2

1 -node调用录制文件  
2 -2025-0306 新的dev分支 1 +node 调用录制文件。
3 2
4 -项目文档统一放在 [docs](./docs/README.md) 目录,高光录制接口见 [docs/HIGHLIGHT_API.md](./docs/HIGHLIGHT_API.md)。 3 +项目文档统一放在 [docs](./docs/README.md) 目录。V2 录制任务与文件查询接口见 [docs/RECORDING_API.md](./docs/RECORDING_API.md)。
@@ -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,
  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 提供的对接文档为准。
  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 日期计算。
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&timestamp=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 -```  
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&timestamp=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,51 +718,26 @@ class HighlightRecordingService { @@ -755,51 +718,26 @@ 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);  
767 - const records = [];  
768 - for (const siteId of siteIds) {  
769 - const siteRecords = await this.fetchBySite({  
770 - siteId,  
771 - beginTime: range.beginTime,  
772 - endTime: range.endTime  
773 - });  
774 - records.push(...siteRecords);  
775 - }  
776 - 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)); 725 + const range = getPreviousShanghaiDayRange(now);
  726 + const records = [];
  727 + for (const siteId of siteIds) {
  728 + const siteRecords = await this.fetchBySite({
  729 + siteId,
  730 + beginTime: range.beginTime,
  731 + endTime: range.endTime
  732 + });
  733 + records.push(...siteRecords);
800 } 734 }
801 -  
802 - throw new HighlightValidationError('HIGHLIGHTCONFIG.sourceMode 只能是 site 或 task'); 735 + const tasks = await this.enqueue(records);
  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 }
  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
45 - const disabledFiles = await request(server, 'POST', '/highlight/files', {  
46 - items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }]  
47 - });  
48 - assert.strictEqual(disabledFiles.status, 400);  
49 - assert.strictEqual(disabledFiles.body.code, 10);  
50 - assert.strictEqual(disabledFiles.body.message, '高光录制功能未启用'); 48 + const originalGetFileStatusesForDisabled = highlightRecordingService.getFileStatuses;
  49 + try {
  50 + highlightRecordingService.getFileStatuses = async () => {
  51 + throw new HighlightValidationError('高光录制功能未启用');
  52 + };
  53 + const disabledFiles = await request(server, 'POST', '/highlight/files', {
  54 + items: [{ highlightId: 5, classId: '1001', siteId: 'xdyui2', beginTime: 1785895298000 }]
  55 + });
  56 + assert.strictEqual(disabledFiles.status, 400);
  57 + assert.strictEqual(disabledFiles.body.code, 10);
  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
60 - const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled');  
61 - assert.strictEqual(disabledScheduled.status, 400);  
62 - assert.strictEqual(disabledScheduled.body.code, 10);  
63 - assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用');  
64 -  
65 const originalRunScheduledRecording = highlightRecordingService.runScheduledRecording; 71 const originalRunScheduledRecording = highlightRecordingService.runScheduledRecording;
66 try { 72 try {
  73 + highlightRecordingService.runScheduledRecording = async () => {
  74 + throw new HighlightValidationError('高光录制功能未启用');
  75 + };
  76 + const disabledScheduled = await request(server, 'POST', '/highlight/recording/scheduled');
  77 + assert.strictEqual(disabledScheduled.status, 400);
  78 + assert.strictEqual(disabledScheduled.body.code, 10);
  79 + assert.strictEqual(disabledScheduled.body.message, '高光录制功能未启用');
  80 +
67 highlightRecordingService.runScheduledRecording = async () => ({ 81 highlightRecordingService.runScheduledRecording = async () => ({
68 mode: 'site', 82 mode: 'site',
69 beginTime: 1785945600000, 83 beginTime: 1785945600000,
  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 +});
  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 +});