huz1xuan

完善高光时刻V2接口

... ... @@ -4,7 +4,7 @@
查询指定课堂的录制文件是否已经生成,并在文件全部可用时返回访问地址。
接口自动识别整课录制和仅高光录制,调用方不需要指定录制类型。查询操作不会创建、重新执行或修改录制任务。
接口通过 `onlyHighlight` 区分整课录制和仅高光录制。查询操作不会创建、重新执行或修改录制任务。
| 项目 | 内容 |
|---|---|
... ... @@ -23,6 +23,7 @@
| `siteId` | String | 是 | 站点 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
| `classId` | String | 是 | 课堂 ID;仅支持字母、数字、下划线和短横线,最长 128 个字符 |
| `classStartTime` | String | 否 | 兼容历史整课文件查询时使用,格式为 `yyyyMMdd`;V2 录制任务通常不需要传入 |
| `onlyHighlight` | Integer | 否 | `0` 或不传表示整课录制,`1` 表示仅高光录制 |
### 请求示例
... ... @@ -47,6 +48,18 @@ Content-Type: application/json; charset=utf-8
}
```
高光文件查询示例:
```json
{
"siteId": "doctest",
"classId": "487012832",
"onlyHighlight": 1
}
```
高光查询会调用 SaaS `getByClassPrivate.do` 获取该课堂的高光列表,再检查对应 MP4 是否已生成,不依赖 WebScreen 本地任务快照。
## 3. 响应参数
| 参数 | 类型 | 必定返回 | 说明 |
... ... @@ -185,3 +198,4 @@ HTTP 200 && code == 0 && fileExists == true
| 版本 | 日期 | 说明 |
|---|---|---|
| V2 | 2026-08-10 | 支持统一查询整课录制和仅高光录制结果 |
| V2 | 2026-08-12 | 高光查询改为以 `onlyHighlight=1` 和 `getByClassPrivate.do` 为准,不再依赖本地任务快照 |
... ...
... ... @@ -32,7 +32,7 @@
## 3. 查询
- `/fileExistsV2` 不触发录制或 SaaS 查询。
- `/fileExistsV2` 不触发录制;整课查询不访问 SaaS,高光查询通过 `getByClassPrivate.do` 获取高光列表。
- 整课任务兼容返回 `classUrl`。
- 高光任务返回全部高光 URL。
- 任一目标文件缺失时 `fileExists=false`。
... ...
# 高光 MP4 内部约定
- 一个 SaaS 高光记录生成一个独立 MP4。
- 文件名为 `{classId}_highlight_{highlightId}.mp4`。
- 回放地址携带绝对毫秒时间戳 `recBeginTime/recEndTime`。
- 录制进程使用参数数组和 `spawn(..., {shell:false})`,不拼接未校验的 shell 输入。
- 临时文件完成并校验非空后,原子移动到正式 `media` 目录。
- 相同 `siteId + highlightId` 不重复进入队列。
- 本地文件或 OSS 对象已存在时不重复录制。
- SaaS 高光接口失败不能当作“没有高光”。
- V2 客户查询只读取任务创建时保存的高光清单。
文件位置:
```text
本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
OSS:oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
```
... ... @@ -198,7 +198,8 @@ Content-Type: application/json
}
```
V2 根据 `/recordingTaskV2` 保存的任务快照判断文件类型,客户不需要再次传 `onlyHighlight`。
V2 根据 `onlyHighlight` 判断文件类型:`0` 或不传查询整课文件,`1` 通过 SaaS
`getByClassPrivate.do` 获取高光列表并检查对应文件。本地任务快照不作为高光文件查询的前置条件。
整课成功响应:
... ...
# 高光录制调用说明
服务地址:`http://47.93.239.64:3001`
## 1. 录制单个课堂的全部高光时刻
调用:
```http
POST /recordingTaskV2
Content-Type: application/json
```
请求示例(`xdyui2` 站点、课堂号 `1010945096`、2026-08-11):
```bash
curl -X POST 'http://47.93.239.64:3001/recordingTaskV2' \
-H 'Content-Type: application/json' \
--data '{
"list": [
{
"siteId": "xdyui2",
"classId": "1010945096",
"beginTime": "2026-08-11 00:00:00",
"endTime": "2026-08-11 23:59:59",
"onlyHighlight": 1
}
]
}'
```
要点:
- `onlyHighlight` 必须为 `1`;否则会按整课堂录制。
- `classId` 是课堂号,也可以改传兼容字段 `meetingNumber`。
- `beginTime`、`endTime` 建议传入课堂所在日期的北京时间范围;也支持 13 位毫秒时间戳。
- 返回 `code="0"` 且 `accepted=1` 表示任务已接收,录制和上传仍在后台异步执行。
查询最终文件:
```bash
curl -X POST 'http://47.93.239.64:3001/fileExistsV2' \
-H 'Content-Type: application/json' \
--data '{
"siteId": "xdyui2",
"classId": "1010945096",
"onlyHighlight": 1
}'
```
当返回 `fileExists=true` 时,`files` 数组中的 `url` 就是该课堂各段高光 MP4 的下载地址。返回 `fileExists=false` 表示仍在录制或上传,可稍后重试查询。
## 2. 录制前一天某个站点的全部高光时刻课堂
### 指定一个站点立即执行
调用:
```http
POST /highlight/recording/by-site
Content-Type: application/json
```
请求中的 `beginTime`、`endTime` 必须是“北京时间前一天 00:00:00.000 至 23:59:59.999”对应的 13 位毫秒时间戳。
例如在 2026-08-13 执行,录制 `xdyui2` 站点 2026-08-12 全天的高光:
```bash
curl -X POST 'http://47.93.239.64:3001/highlight/recording/by-site' \
-H 'Content-Type: application/json' \
--data '{
"siteId": "xdyui2",
"beginTime": 1786464000000,
"endTime": 1786550399999
}'
```
成功响应中的字段含义:
- `received`:前一天该站点命中的高光总数。
- `queued`、`recording`:等待或正在录制的数量。
- `uploading`:本地 MP4 已生成,等待上传。
- `generated`:最终文件已经生成。
- `invalid`、`failed`:无效或失败的数量。
### 每天自动执行
先把目标站点放入服务器 `HIGHLIGHTCONFIG.siteIds`,例如只处理 `xdyui2`:
```json
{
"HIGHLIGHTCONFIG": {
"siteIds": ["xdyui2"]
}
}
```
然后每天调用:
```bash
curl -X POST 'http://47.93.239.64:3001/highlight/recording/scheduled' \
-H 'Content-Type: application/json' \
--data '{}'
```
该接口会自动按北京时间计算前一天的完整时间范围,并录制 `siteIds` 中所有站点的全部高光;请求中不用再传日期。
... ...
... ... @@ -379,10 +379,12 @@ router.post('/fileExistsV2', async (req, res) => {
const result = await recordingTaskService.getFileResult(req.body || {});
return res.send(result);
} catch (err) {
if (err instanceof RecordingTaskValidationError) {
if (err instanceof RecordingTaskValidationError || err instanceof HighlightValidationError) {
return res.status(400).send({
code: err.apiCode,
message: err.message,
code: err instanceof RecordingTaskValidationError ? err.apiCode : 2,
message: err instanceof RecordingTaskValidationError
? err.message
: 'siteId或classId不正确',
fileExists: false,
files: []
});
... ...
... ... @@ -623,20 +623,36 @@ class RecordingTaskService {
const key = buildManifestKey({ siteId, classId });
const manifest = this.loadManifest(key);
const requestedPeriod = normalizeTaskPeriod(input, null, false);
const onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight);
if (onlyHighlight === 1) {
const statuses = await this.highlightService.getClassFileStatuses({ siteId, classId });
const fileExists = statuses.length > 0 &&
statuses.every(item => item.generated && item.url);
return {
code: fileExists ? 0 : 1,
message: fileExists ? '文件已生成' : '文件未生成',
fileExists,
onlyHighlight: 1,
files: fileExists ? statuses.map(item => ({
type: 'highlight',
highlightId: item.highlightId,
url: item.url
})) : []
};
}
if (manifest && requestedPeriod && requestedPeriod.beginTime != null && manifest.beginTime != null &&
(requestedPeriod.beginTime !== manifest.beginTime || requestedPeriod.endTime !== manifest.endTime)) {
return { code: 1, message: '文件未生成', fileExists: false, files: [] };
return { code: 1, message: '文件未生成', fileExists: false, onlyHighlight: 0, files: [] };
}
let expected = [];
let onlyHighlight;
if (manifest) {
onlyHighlight = manifest.onlyHighlight;
expected = onlyHighlight === 1 ? (manifest.highlights || []) : [this.buildFullFile(manifest)];
expected = [this.buildFullFile(manifest)];
} else {
onlyHighlight = normalizeOnlyHighlight(input.onlyHighlight);
if (onlyHighlight === 1 || !requestedPeriod) {
return { code: 1, message: '文件未生成', fileExists: false, files: [] };
if (!requestedPeriod) {
return { code: 1, message: '文件未生成', fileExists: false, onlyHighlight: 0, files: [] };
}
expected = [this.buildFullFile({ siteId, classId, classDate: requestedPeriod.classDate })];
}
... ...
const assert = require('assert');
const http = require('http');
const { recordingTaskService } = require('../services/recordingTaskService');
const {
HighlightValidationError,
recordingTaskService
} = require('../services/recordingTaskService');
const app = require('../app');
function request(server, route, body) {
... ... @@ -119,13 +122,27 @@ async function run() {
assert.strictEqual(fileCalls, 0, '旧查询接口不得进入 V2 服务');
const newFile = await request(server, '/fileExistsV2', {
siteId: 'doctest', classId: '1001'
siteId: 'doctest', classId: '1001', onlyHighlight: 1
});
assert.strictEqual(newFile.status, 200);
assert.strictEqual(newFile.body.fileExists, true);
assert.strictEqual(newFile.body.onlyHighlight, 1);
assert.strictEqual(newFile.body.files.length, 1);
assert.strictEqual(fileCalls, 1);
recordingTaskService.getFileResult = async () => {
throw new HighlightValidationError('高光录制功能未启用');
};
const disabledHighlight = await request(server, '/fileExistsV2', {
siteId: 'kiwikids', classId: '258178512', onlyHighlight: 1
});
assert.strictEqual(disabledHighlight.status, 400);
assert.deepStrictEqual(disabledHighlight.body, {
code: 2,
message: 'siteId或classId不正确',
fileExists: false,
files: []
});
} finally {
recordingTaskService.acceptTasks = originalAcceptTasks;
recordingTaskService.getFileResult = originalGetFileResult;
... ...
... ... @@ -99,8 +99,15 @@ async function run() {
siteId: 'doctest',
beginTime: 1785899498000,
endTime: 1785899543000
}, {
id: 8,
meetingNumber: '1007',
siteId: 'doctest',
beginTime: 1785895298000,
endTime: 1785895343000
}];
const calls = { fetch: 0, enqueue: 0, wait: 0 };
const calls = { fetch: 0, enqueue: 0, wait: 0, fileStatus: 0 };
const objects = new Set();
const highlightService = {
fetchByClass: async classId => {
calls.fetch += 1;
... ... @@ -113,9 +120,24 @@ async function run() {
waitForTaskKeys: async keys => {
calls.wait += 1;
return keys.map(key => ({ key, status: 'uploading' }));
},
getClassFileStatuses: async ({ siteId, classId }) => {
calls.fileStatus += 1;
return records.filter(item => item.siteId === siteId && item.meetingNumber === classId)
.map(item => {
const ossKey = `oss/${siteId}/20260805/${classId}_highlight_${item.id}.mp4`;
const generated = objects.has(ossKey);
return {
highlightId: item.id,
classId,
siteId,
generated,
status: generated ? 'generated' : 'not_generated',
url: generated ? `https://xdymp4.xuedianyun.com/${ossKey}` : null
};
});
}
};
const objects = new Set();
const statusUpdates = [];
const service = new RecordingTaskService({
configPath,
... ... @@ -182,14 +204,29 @@ async function run() {
assert.deepStrictEqual(duplicate, { accepted: 0, duplicates: 1, noMedia: 0 });
assert.strictEqual(calls.fetch, 1);
let highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
let highFiles = await service.getFileResult({
siteId: 'doctest', classId: '1002', onlyHighlight: 1
});
assert.strictEqual(highFiles.fileExists, false);
objects.add('oss/doctest/20260805/1002_highlight_5.mp4');
objects.add('oss/doctest/20260805/1002_highlight_6.mp4');
highFiles = await service.getFileResult({ siteId: 'doctest', classId: '1002' });
objects.add('oss/doctest/20260805/1002_highlight_7.mp4');
highFiles = await service.getFileResult({
siteId: 'doctest', classId: '1002', onlyHighlight: 1
});
assert.strictEqual(highFiles.fileExists, true);
assert.strictEqual(highFiles.onlyHighlight, 1);
assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6]);
assert.deepStrictEqual(highFiles.files.map(item => item.highlightId), [5, 6, 7]);
objects.add('oss/doctest/20260805/1007_highlight_8.mp4');
const manifestlessHighlight = await service.getFileResult({
siteId: 'doctest', classId: '1007', classStartTime: '20260805', onlyHighlight: 1
});
assert.strictEqual(manifestlessHighlight.fileExists, true,
'高光查询不得依赖本地 manifest');
assert.strictEqual(manifestlessHighlight.onlyHighlight, 1);
assert.deepStrictEqual(manifestlessHighlight.files.map(item => item.highlightId), [8]);
assert.strictEqual(calls.fileStatus, 3, '每次高光文件查询都必须读取 getByClass 文件状态');
const noMedia = await service.acceptTasks({ list: [{
id: 'task-high-empty',
... ... @@ -200,7 +237,9 @@ async function run() {
}] });
assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2', '1003:3']);
const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' });
const emptyResult = await service.getFileResult({
siteId: 'doctest', classId: '1003', onlyHighlight: 1
});
assert.strictEqual(emptyResult.fileExists, false);
const mappedNoMedia = await service.acceptTasks({ list: [{
... ...