huz1xuan

完善高光时刻V2接口

@@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
4 4
5 查询指定课堂的录制文件是否已经生成,并在文件全部可用时返回访问地址。 5 查询指定课堂的录制文件是否已经生成,并在文件全部可用时返回访问地址。
6 6
7 -接口自动识别整课录制和仅高光录制,调用方不需要指定录制类型。查询操作不会创建、重新执行或修改录制任务。 7 +接口通过 `onlyHighlight` 区分整课录制和仅高光录制。查询操作不会创建、重新执行或修改录制任务。
8 8
9 | 项目 | 内容 | 9 | 项目 | 内容 |
10 |---|---| 10 |---|---|
@@ -23,6 +23,7 @@ @@ -23,6 +23,7 @@
23 | `siteId` | String | 是 | 站点 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 | 23 | `siteId` | String | 是 | 站点 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
24 | `classId` | String | 是 | 课堂 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 | 24 | `classId` | String | 是 | 课堂 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
25 | `classStartTime` | String | 否 | 兼容历史整课文件查询时使用,格式为 `yyyyMMdd`;V2 录制任务通常不需要传入 | 25 | `classStartTime` | String | 否 | 兼容历史整课文件查询时使用,格式为 `yyyyMMdd`;V2 录制任务通常不需要传入 |
  26 +| `onlyHighlight` | Integer | 否 | `0` 或不传表示整课录制,`1` 表示仅高光录制 |
26 27
27 ### 请求示例 28 ### 请求示例
28 29
@@ -47,6 +48,18 @@ Content-Type: application/json; charset=utf-8 @@ -47,6 +48,18 @@ Content-Type: application/json; charset=utf-8
47 } 48 }
48 ``` 49 ```
49 50
  51 +高光文件查询示例:
  52 +
  53 +```json
  54 +{
  55 + "siteId": "doctest",
  56 + "classId": "487012832",
  57 + "onlyHighlight": 1
  58 +}
  59 +```
  60 +
  61 +高光查询会调用 SaaS `getByClassPrivate.do` 获取该课堂的高光列表,再检查对应 MP4 是否已生成,不依赖 WebScreen 本地任务快照。
  62 +
50 ## 3. 响应参数 63 ## 3. 响应参数
51 64
52 | 参数 | 类型 | 必定返回 | 说明 | 65 | 参数 | 类型 | 必定返回 | 说明 |
@@ -185,3 +198,4 @@ HTTP 200 && code == 0 && fileExists == true @@ -185,3 +198,4 @@ HTTP 200 && code == 0 && fileExists == true
185 | 版本 | 日期 | 说明 | 198 | 版本 | 日期 | 说明 |
186 |---|---|---| 199 |---|---|---|
187 | V2 | 2026-08-10 | 支持统一查询整课录制和仅高光录制结果 | 200 | V2 | 2026-08-10 | 支持统一查询整课录制和仅高光录制结果 |
  201 +| V2 | 2026-08-12 | 高光查询改为以 `onlyHighlight=1` 和 `getByClassPrivate.do` 为准,不再依赖本地任务快照 |
@@ -32,7 +32,7 @@ @@ -32,7 +32,7 @@
32 32
33 ## 3. 查询 33 ## 3. 查询
34 34
35 -- `/fileExistsV2` 不触发录制或 SaaS 查询。 35 +- `/fileExistsV2` 不触发录制;整课查询不访问 SaaS,高光查询通过 `getByClassPrivate.do` 获取高光列表。
36 - 整课任务兼容返回 `classUrl`。 36 - 整课任务兼容返回 `classUrl`。
37 - 高光任务返回全部高光 URL。 37 - 高光任务返回全部高光 URL。
38 - 任一目标文件缺失时 `fileExists=false`。 38 - 任一目标文件缺失时 `fileExists=false`。
1 -# 高光 MP4 内部约定  
2 -  
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 客户查询只读取任务创建时保存的高光清单。  
12 -  
13 -文件位置:  
14 -  
15 -```text  
16 -本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4  
17 -OSS:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4  
18 -```  
@@ -198,7 +198,8 @@ Content-Type: application/json @@ -198,7 +198,8 @@ Content-Type: application/json
198 } 198 }
199 ``` 199 ```
200 200
201 -V2 根据 `/recordingTaskV2` 保存的任务快照判断文件类型,客户不需要再次传 `onlyHighlight`。 201 +V2 根据 `onlyHighlight` 判断文件类型:`0` 或不传查询整课文件,`1` 通过 SaaS
  202 +`getByClassPrivate.do` 获取高光列表并检查对应文件。本地任务快照不作为高光文件查询的前置条件。
202 203
203 整课成功响应: 204 整课成功响应:
204 205
  1 +# 高光录制调用说明
  2 +
  3 +服务地址:`http://47.93.239.64:3001`
  4 +
  5 +## 1. 录制单个课堂的全部高光时刻
  6 +
  7 +调用:
  8 +
  9 +```http
  10 +POST /recordingTaskV2
  11 +Content-Type: application/json
  12 +```
  13 +
  14 +请求示例(`xdyui2` 站点、课堂号 `1010945096`、2026-08-11):
  15 +
  16 +```bash
  17 +curl -X POST 'http://47.93.239.64:3001/recordingTaskV2' \
  18 + -H 'Content-Type: application/json' \
  19 + --data '{
  20 + "list": [
  21 + {
  22 + "siteId": "xdyui2",
  23 + "classId": "1010945096",
  24 + "beginTime": "2026-08-11 00:00:00",
  25 + "endTime": "2026-08-11 23:59:59",
  26 + "onlyHighlight": 1
  27 + }
  28 + ]
  29 + }'
  30 +```
  31 +
  32 +要点:
  33 +
  34 +- `onlyHighlight` 必须为 `1`;否则会按整课堂录制。
  35 +- `classId` 是课堂号,也可以改传兼容字段 `meetingNumber`。
  36 +- `beginTime`、`endTime` 建议传入课堂所在日期的北京时间范围;也支持 13 位毫秒时间戳。
  37 +- 返回 `code="0"` 且 `accepted=1` 表示任务已接收,录制和上传仍在后台异步执行。
  38 +
  39 +查询最终文件:
  40 +
  41 +```bash
  42 +curl -X POST 'http://47.93.239.64:3001/fileExistsV2' \
  43 + -H 'Content-Type: application/json' \
  44 + --data '{
  45 + "siteId": "xdyui2",
  46 + "classId": "1010945096",
  47 + "onlyHighlight": 1
  48 + }'
  49 +```
  50 +
  51 +当返回 `fileExists=true` 时,`files` 数组中的 `url` 就是该课堂各段高光 MP4 的下载地址。返回 `fileExists=false` 表示仍在录制或上传,可稍后重试查询。
  52 +
  53 +## 2. 录制前一天某个站点的全部高光时刻课堂
  54 +
  55 +### 指定一个站点立即执行
  56 +
  57 +调用:
  58 +
  59 +```http
  60 +POST /highlight/recording/by-site
  61 +Content-Type: application/json
  62 +```
  63 +
  64 +请求中的 `beginTime`、`endTime` 必须是“北京时间前一天 00:00:00.000 至 23:59:59.999”对应的 13 位毫秒时间戳。
  65 +
  66 +例如在 2026-08-13 执行,录制 `xdyui2` 站点 2026-08-12 全天的高光:
  67 +
  68 +```bash
  69 +curl -X POST 'http://47.93.239.64:3001/highlight/recording/by-site' \
  70 + -H 'Content-Type: application/json' \
  71 + --data '{
  72 + "siteId": "xdyui2",
  73 + "beginTime": 1786464000000,
  74 + "endTime": 1786550399999
  75 + }'
  76 +```
  77 +
  78 +成功响应中的字段含义:
  79 +
  80 +- `received`:前一天该站点命中的高光总数。
  81 +- `queued`、`recording`:等待或正在录制的数量。
  82 +- `uploading`:本地 MP4 已生成,等待上传。
  83 +- `generated`:最终文件已经生成。
  84 +- `invalid`、`failed`:无效或失败的数量。
  85 +
  86 +### 每天自动执行
  87 +
  88 +先把目标站点放入服务器 `HIGHLIGHTCONFIG.siteIds`,例如只处理 `xdyui2`:
  89 +
  90 +```json
  91 +{
  92 + "HIGHLIGHTCONFIG": {
  93 + "siteIds": ["xdyui2"]
  94 + }
  95 +}
  96 +```
  97 +
  98 +然后每天调用:
  99 +
  100 +```bash
  101 +curl -X POST 'http://47.93.239.64:3001/highlight/recording/scheduled' \
  102 + -H 'Content-Type: application/json' \
  103 + --data '{}'
  104 +```
  105 +
  106 +该接口会自动按北京时间计算前一天的完整时间范围,并录制 `siteIds` 中所有站点的全部高光;请求中不用再传日期。
  107 +
@@ -379,10 +379,12 @@ router.post('/fileExistsV2', async (req, res) => { @@ -379,10 +379,12 @@ router.post('/fileExistsV2', async (req, res) => {
379 const result = await recordingTaskService.getFileResult(req.body || {}); 379 const result = await recordingTaskService.getFileResult(req.body || {});
380 return res.send(result); 380 return res.send(result);
381 } catch (err) { 381 } catch (err) {
382 - if (err instanceof RecordingTaskValidationError) { 382 + if (err instanceof RecordingTaskValidationError || err instanceof HighlightValidationError) {
383 return res.status(400).send({ 383 return res.status(400).send({
384 - code: err.apiCode,  
385 - message: err.message, 384 + code: err instanceof RecordingTaskValidationError ? err.apiCode : 2,
  385 + message: err instanceof RecordingTaskValidationError
  386 + ? err.message
  387 + : 'siteId或classId不正确',
386 fileExists: false, 388 fileExists: false,
387 files: [] 389 files: []
388 }); 390 });
@@ -623,20 +623,36 @@ class RecordingTaskService { @@ -623,20 +623,36 @@ class RecordingTaskService {
623 const key = buildManifestKey({ siteId, classId }); 623 const key = buildManifestKey({ siteId, classId });
624 const manifest = this.loadManifest(key); 624 const manifest = this.loadManifest(key);
625 const requestedPeriod = normalizeTaskPeriod(input, null, false); 625 const requestedPeriod = normalizeTaskPeriod(input, null, false);
  626 + const onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight);
  627 +
  628 + if (onlyHighlight === 1) {
  629 + const statuses = await this.highlightService.getClassFileStatuses({ siteId, classId });
  630 + const fileExists = statuses.length > 0 &&
  631 + statuses.every(item => item.generated && item.url);
  632 + return {
  633 + code: fileExists ? 0 : 1,
  634 + message: fileExists ? '文件已生成' : '文件未生成',
  635 + fileExists,
  636 + onlyHighlight: 1,
  637 + files: fileExists ? statuses.map(item => ({
  638 + type: 'highlight',
  639 + highlightId: item.highlightId,
  640 + url: item.url
  641 + })) : []
  642 + };
  643 + }
  644 +
626 if (manifest && requestedPeriod && requestedPeriod.beginTime != null && manifest.beginTime != null && 645 if (manifest && requestedPeriod && requestedPeriod.beginTime != null && manifest.beginTime != null &&
627 (requestedPeriod.beginTime !== manifest.beginTime || requestedPeriod.endTime !== manifest.endTime)) { 646 (requestedPeriod.beginTime !== manifest.beginTime || requestedPeriod.endTime !== manifest.endTime)) {
628 - return { code: 1, message: '文件未生成', fileExists: false, files: [] }; 647 + return { code: 1, message: '文件未生成', fileExists: false, onlyHighlight: 0, files: [] };
629 } 648 }
630 649
631 let expected = []; 650 let expected = [];
632 - let onlyHighlight;  
633 if (manifest) { 651 if (manifest) {
634 - onlyHighlight = manifest.onlyHighlight;  
635 - expected = onlyHighlight === 1 ? (manifest.highlights || []) : [this.buildFullFile(manifest)]; 652 + expected = [this.buildFullFile(manifest)];
636 } else { 653 } else {
637 - onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight);  
638 - if (onlyHighlight === 1 || !requestedPeriod) {  
639 - return { code: 1, message: '文件未生成', fileExists: false, files: [] }; 654 + if (!requestedPeriod) {
  655 + return { code: 1, message: '文件未生成', fileExists: false, onlyHighlight: 0, files: [] };
640 } 656 }
641 expected = [this.buildFullFile({ siteId, classId, classDate: requestedPeriod.classDate })]; 657 expected = [this.buildFullFile({ siteId, classId, classDate: requestedPeriod.classDate })];
642 } 658 }
1 const assert = require('assert'); 1 const assert = require('assert');
2 const http = require('http'); 2 const http = require('http');
3 -const { recordingTaskService } = require('../services/recordingTaskService'); 3 +const {
  4 + HighlightValidationError,
  5 + recordingTaskService
  6 +} = require('../services/recordingTaskService');
4 const app = require('../app'); 7 const app = require('../app');
5 8
6 function request(server, route, body) { 9 function request(server, route, body) {
@@ -119,13 +122,27 @@ async function run() { @@ -119,13 +122,27 @@ async function run() {
119 assert.strictEqual(fileCalls, 0, '旧查询接口不得进入 V2 服务'); 122 assert.strictEqual(fileCalls, 0, '旧查询接口不得进入 V2 服务');
120 123
121 const newFile = await request(server, '/fileExistsV2', { 124 const newFile = await request(server, '/fileExistsV2', {
122 - siteId: 'doctest', classId: '1001' 125 + siteId: 'doctest', classId: '1001', onlyHighlight: 1
123 }); 126 });
124 assert.strictEqual(newFile.status, 200); 127 assert.strictEqual(newFile.status, 200);
125 assert.strictEqual(newFile.body.fileExists, true); 128 assert.strictEqual(newFile.body.fileExists, true);
126 assert.strictEqual(newFile.body.onlyHighlight, 1); 129 assert.strictEqual(newFile.body.onlyHighlight, 1);
127 assert.strictEqual(newFile.body.files.length, 1); 130 assert.strictEqual(newFile.body.files.length, 1);
128 assert.strictEqual(fileCalls, 1); 131 assert.strictEqual(fileCalls, 1);
  132 +
  133 + recordingTaskService.getFileResult = async () => {
  134 + throw new HighlightValidationError('高光录制功能未启用');
  135 + };
  136 + const disabledHighlight = await request(server, '/fileExistsV2', {
  137 + siteId: 'kiwikids', classId: '258178512', onlyHighlight: 1
  138 + });
  139 + assert.strictEqual(disabledHighlight.status, 400);
  140 + assert.deepStrictEqual(disabledHighlight.body, {
  141 + code: 2,
  142 + message: 'siteId或classId不正确',
  143 + fileExists: false,
  144 + files: []
  145 + });
129 } finally { 146 } finally {
130 recordingTaskService.acceptTasks = originalAcceptTasks; 147 recordingTaskService.acceptTasks = originalAcceptTasks;
131 recordingTaskService.getFileResult = originalGetFileResult; 148 recordingTaskService.getFileResult = originalGetFileResult;
@@ -99,8 +99,15 @@ async function run() { @@ -99,8 +99,15 @@ async function run() {
99 siteId: 'doctest', 99 siteId: 'doctest',
100 beginTime: 1785899498000, 100 beginTime: 1785899498000,
101 endTime: 1785899543000 101 endTime: 1785899543000
  102 + }, {
  103 + id: 8,
  104 + meetingNumber: '1007',
  105 + siteId: 'doctest',
  106 + beginTime: 1785895298000,
  107 + endTime: 1785895343000
102 }]; 108 }];
103 - const calls = { fetch: 0, enqueue: 0, wait: 0 }; 109 + const calls = { fetch: 0, enqueue: 0, wait: 0, fileStatus: 0 };
  110 + const objects = new Set();
104 const highlightService = { 111 const highlightService = {
105 fetchByClass: async classId => { 112 fetchByClass: async classId => {
106 calls.fetch += 1; 113 calls.fetch += 1;
@@ -113,9 +120,24 @@ async function run() { @@ -113,9 +120,24 @@ async function run() {
113 waitForTaskKeys: async keys => { 120 waitForTaskKeys: async keys => {
114 calls.wait += 1; 121 calls.wait += 1;
115 return keys.map(key => ({ key, status: 'uploading' })); 122 return keys.map(key => ({ key, status: 'uploading' }));
  123 + },
  124 + getClassFileStatuses: async ({ siteId, classId }) => {
  125 + calls.fileStatus += 1;
  126 + return records.filter(item => item.siteId === siteId && item.meetingNumber === classId)
  127 + .map(item => {
  128 + const ossKey = `oss/${siteId}/20260805/${classId}_highlight_${item.id}.mp4`;
  129 + const generated = objects.has(ossKey);
  130 + return {
  131 + highlightId: item.id,
  132 + classId,
  133 + siteId,
  134 + generated,
  135 + status: generated ? 'generated' : 'not_generated',
  136 + url: generated ? `https://xdymp4.xuedianyun.com/${ossKey}` : null
  137 + };
  138 + });
116 } 139 }
117 }; 140 };
118 - const objects = new Set();  
119 const statusUpdates = []; 141 const statusUpdates = [];
120 const service = new RecordingTaskService({ 142 const service = new RecordingTaskService({
121 configPath, 143 configPath,
@@ -182,14 +204,29 @@ async function run() { @@ -182,14 +204,29 @@ async function run() {
182 assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 }); 204 assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 });
183 assert.strictEqual(calls.fetch, 1); 205 assert.strictEqual(calls.fetch, 1);
184 206
185 - let highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' }); 207 + let highFiles = await service.getFileResult({
  208 + siteId: 'doctest', classId: '1002', onlyHighlight: 1
  209 + });
186 assert.strictEqual(highFiles.fileExists, false); 210 assert.strictEqual(highFiles.fileExists, false);
187 objects.add('oss/doctest/20260805/1002_highlight_5.mp4'); 211 objects.add('oss/doctest/20260805/1002_highlight_5.mp4');
188 objects.add('oss/doctest/20260805/1002_highlight_6.mp4'); 212 objects.add('oss/doctest/20260805/1002_highlight_6.mp4');
189 - highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' }); 213 + objects.add('oss/doctest/20260805/1002_highlight_7.mp4');
  214 + highFiles = await service.getFileResult({
  215 + siteId: 'doctest', classId: '1002', onlyHighlight: 1
  216 + });
190 assert.strictEqual(highFiles.fileExists, true); 217 assert.strictEqual(highFiles.fileExists, true);
191 assert.strictEqual(highFiles.onlyHighlight, 1); 218 assert.strictEqual(highFiles.onlyHighlight, 1);
192 - assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6]); 219 + assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6, 7]);
  220 +
  221 + objects.add('oss/doctest/20260805/1007_highlight_8.mp4');
  222 + const manifestlessHighlight = await service.getFileResult({
  223 + siteId: 'doctest', classId: '1007', classStartTime: '20260805', onlyHighlight: 1
  224 + });
  225 + assert.strictEqual(manifestlessHighlight.fileExists, true,
  226 + '高光查询不得依赖本地 manifest');
  227 + assert.strictEqual(manifestlessHighlight.onlyHighlight, 1);
  228 + assert.deepStrictEqual(manifestlessHighlight.files.map(item => item.highlightId), [8]);
  229 + assert.strictEqual(calls.fileStatus, 3, '每次高光文件查询都必须读取 getByClass 文件状态');
193 230
194 const noMedia = await service.acceptTasks({ list: [{ 231 const noMedia = await service.acceptTasks({ list: [{
195 id: 'task-high-empty', 232 id: 'task-high-empty',
@@ -200,7 +237,9 @@ async function run() { @@ -200,7 +237,9 @@ async function run() {
200 }] }); 237 }] });
201 assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 }); 238 assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
202 assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2', '1003:3']); 239 assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2', '1003:3']);
203 - const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' }); 240 + const emptyResult = await service.getFileResult({
  241 + siteId: 'doctest', classId: '1003', onlyHighlight: 1
  242 + });
204 assert.strictEqual(emptyResult.fileExists, false); 243 assert.strictEqual(emptyResult.fileExists, false);
205 244
206 const mappedNoMedia = await service.acceptTasks({ list: [{ 245 const mappedNoMedia = await service.acceptTasks({ list: [{