huz1xuan

feat: 支持高光时刻分段录制

node_modules
.idea
log
\ No newline at end of file
log
.DS_Store
... ...
# WebScreen 高光录制部署文档
## 1. 部署范围
当前仅部署到新验证服务器,只启用 `xdyui2`。
不要直接替换现有正式录制服务器。CrazyTalk 等 xdyui2 验证通过后再添加。
## 2. 环境要求
- Node.js 与现有 WebScreen 生产版本一致。
- `web_capture_c` 可执行文件及依赖完整。
- PCLive 回放地址能从服务器访问。
- 服务器时钟已通过 NTP 同步。
- OSS AccessKey 环境变量已配置,供文件状态查询使用。
- 原本地文件搬运至 OSS 的定时任务已部署。
WebScreen 只监听端口 `3001`。
## 3. 安装
```bash
cd /root/webScreen
npm install
npm test
```
确认录制程序可执行:
```bash
test -x /root/web_capture_release/linux-x64/web_capture_c
```
## 4. 配置
保留服务器原 `GETCLASSURLPARAMETER`、`PROJECTWINCATALOG`、`PROJECTCATALOG` 和 `BACKMEDIACONFIG`,增加:
```json
"HIGHLIGHTCONFIG": {
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
```
验证 JSON:
```bash
node -e "JSON.parse(require('fs').readFileSync('config/config.json')); console.log('config ok')"
```
## 5. 启动
```bash
npm run pm2
pm2 show webScreen
curl http://127.0.0.1:3001/highlight/status
```
预期:
```json
{"code":0,"message":"success","data":{"queued":0,"recording":0,"knownTasks":0}}
```
## 6. 首次手工验证
先查询某节已知课堂,不启动录制:
```bash
curl -X POST http://127.0.0.1:3001/highlight/preview/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"课堂号"}'
```
检查响应中的:
- `siteId` 必须是 `xdyui2`。
- `playbackUrl` 包含 `recBeginTime` 和 `recEndTime`。
- 文件名包含 `_highlight_高光ID.mp4`。
- `duration` 等于 `endTime-beginTime`。
再手工触发单课堂:
```bash
curl -X POST http://127.0.0.1:3001/highlight/recording/by-class \
-H 'Content-Type: application/json' \
-d '{"classId":"课堂号"}'
```
观察:
```bash
tail -f log/$(date +%Y%m%d).txt
find /root/web_capture_release/media/xdyui2 -type f
```
一个课堂有 N 条高光时,应出现 N 个不同文件。
## 7. 验证多文件查询
```bash
curl -X POST http://127.0.0.1:3001/highlight/fileExists \
-H 'Content-Type: application/json' \
-d '{"siteId":"xdyui2","classId":"课堂号"}'
```
本地文件等待搬运时,状态应为 `uploading`;OSS 可见后应为 `generated` 并返回 URL。
## 8. 验证 cron 全量模式
先手工执行原 cron 入口:
```bash
wget -qO- http://127.0.0.1:3001/recording
```
响应 `data.mode` 应为 `site`,时间窗应为 Asia/Shanghai 前一天。
确认无误后配置:
```cron
57 7 * * * wget -qO- http://127.0.0.1:3001/recording >/dev/null 2>&1
```
## 9. 切换任务模式
xdyui2 全量验证完成后,可改为:
```json
"sourceMode": "task"
```
任务模式只处理:
```text
status = 0
onlyHighlight = 1
siteId = xdyui2
```
当前没有任务状态回写接口。重复 cron 依靠队列、本地文件和 OSS 文件跳过,代码中保留 TODO。后端接口确定后再补领取和结果回写。
## 10. OSS 搬运验证
高光录制完成后,本地目录应包含:
```text
{classId}_highlight_{highlightId}.mp4
download.json
```
等待现有搬运任务执行,再检查:
```text
https://xdymp4.xuedianyun.com/oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
```
确认搬运程序不会只匹配旧 `{classId}.mp4` 文件名。
## 11. 回滚
最快业务回滚:
```json
"HIGHLIGHTCONFIG": {
"enabled": false
}
```
然后:
```bash
pm2 restart webScreen
```
关闭后,`GET /recording` 继续运行原整课录制逻辑。高光使用独立文件名,不覆盖旧整课 MP4。
## 12. 上线 CrazyTalk 前检查
1. 获取 CrazyTalk 准确、区分大小写的 `siteId`。
2. xdyui2 连续验证多天,无重复、缺段和错误路径。
3. 确认 OSS 搬运支持高光文件名。
4. 确认任务状态回写方案。
5. 将 CrazyTalk 加入 `HIGHLIGHTCONFIG.siteIds`,不要改旧 `GETCLASSURLPARAMETER.siteId`。
... ...
# WebScreen 高光时刻 MP4 录制 Spec
## 1. 目标
WebScreen 根据 SaaS 高光记录,把每个 `beginTime/endTime` 时间段录制成独立 MP4。
已确认:
- 一个课堂可以有多条高光。
- 一个高光时间段生成一个 MP4;N 条高光生成 N 个文件。
- 当前只在 `xdyui2` 测试,验证后再增加 CrazyTalk。
- WebScreen 只生成本地文件;服务器已有任务定时移动到 OSS。
- 原定时任务每天 `07:57` 调用 `GET /recording`。
- 任务模式暂时只处理 `status=0`;后端状态回写规则待确认。
## 2. 兼容原则
高光开关缺失或关闭时,`GET /recording` 完整执行原整课录制逻辑。
```json
{
"HIGHLIGHTCONFIG": {
"enabled": false
}
}
```
只有显式配置 `enabled: true` 才进入高光录制。因此旧服务器、旧站点和旧客户不受影响。
以下接口保持原行为:
- `POST /recording`
- `POST /recordingTask`
- `POST /fileExists`
- `POST /mp4record/recording/:id`
高光文件查询使用独立接口 `POST /highlight/fileExists`,不改变原 `/fileExists` 单文件响应。
## 3. 定时触发
WebScreen 本身不创建 cron。服务器已有 cron:
```cron
57 7 * * * wget http://127.0.0.1:3001/recording &
```
建议部署时改成不落 wget 临时文件的等价写法:
```cron
57 7 * * * wget -qO- http://127.0.0.1:3001/recording >/dev/null 2>&1
```
`GET /recording` 读取 `HIGHLIGHTCONFIG.sourceMode`:
- `site`:全量高光模式。
- `task`:指定课堂任务模式。
这里“全量”表示录制指定站点前一天的全部高光,不表示录制整堂课堂。
## 4. 配置
当前 xdyui2 测试配置:
```json
{
"HIGHLIGHTCONFIG": {
"enabled": true,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
}
}
```
`BACKMEDIACONFIG.url` 继续由服务器配置决定,代码不硬编码 `dev`、`devback` 或 `release`。
## 5. 全量高光模式
配置:
```json
"sourceMode": "site"
```
流程:
1. cron 调用 `GET /recording`。
2. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。
3. 遍历 `HIGHLIGHTCONFIG.siteIds`。
4. 分页调用 `getBySitePrivate.do`。
5. 每条有效高光加入录制队列。
6. 一个时间段生成一个本地 MP4。
接口:
```http
POST /3m/api/highlight/getBySitePrivate.do
Content-Type: application/x-www-form-urlencoded
```
签名:
```text
authId = MD5(siteId + timestamp)
```
## 6. 指定课堂任务模式
配置:
```json
"sourceMode": "task"
```
流程:
1. 分页调用 `getRecordingTasksPrivate.do`。
2. 只保留 `status=0 && onlyHighlight=1`。
3. 只保留 `siteIds` 白名单内的任务。
4. `taskList.meetingNumber` 作为 `classId`。
5. 调用 `getByClassPrivate.do` 获取该课堂全部高光。
6. 每条高光加入录制队列。
任务接口签名:
```text
authId = MD5(pageNo + pageSize + timestamp)
```
课堂高光接口签名:
```text
authId = MD5(classId + timestamp)
```
代码必须保留:
```js
// TODO: 等后端明确录制任务状态流转及完成回写接口。
```
任务状态回写接口确定前,使用内存队列、本地文件和 OSS 文件共同避免重复录制。
## 7. 字段映射
SaaS 高光记录:
```json
{
"id": 4,
"meetingNumber": "1486758620",
"siteId": "xdyui2",
"beginTime": 1779159793000,
"endTime": 1779159893000
}
```
WebScreen 内部统一为:
```text
highlightId = id
classId = meetingNumber
```
校验:
- `id` 为正整数。
- `meetingNumber`、`siteId` 只包含安全字符。
- 时间为 13 位毫秒时间戳。
- `endTime > beginTime`。
- 时长不超过 `maxDurationMs`。
- 站点必须属于 `HIGHLIGHTCONFIG.siteIds`。
唯一键:
```text
siteId:highlightId
```
## 8. 回放录制地址
使用 `URLSearchParams` 在现有 `BACKMEDIACONFIG.url` 上增加:
```text
classId={meetingNumber}
recordMp4=true
playRecord=1
recBeginTime={beginTime}
recEndTime={endTime}
```
`recBeginTime` 和 `recEndTime` 都是绝对毫秒时间戳,不换算为相对秒数。
调用 `web_capture_c` 时使用参数数组和 `spawn(..., { shell: false })`,不把接口字段拼接进 shell 命令。
## 9. 文件与上传
文件名:
```text
{classId}_highlight_{highlightId}.mp4
```
路径:
```text
本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
OSS: oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
URL: https://xdymp4.xuedianyun.com/oss/{siteId}/{yyyyMMdd}/{fileName}
```
日期按高光 `beginTime` 的 Asia/Shanghai 日期计算。
录制过程先写入 `PROJECTCATALOG/.highlight_tmp`,完成后原子移动到 `media`,避免 OSS 搬运程序读取半成品。队列完成后在涉及的日期目录写入 `download.json`,兼容原搬运机制。
WebScreen 不主动上传 OSS。
## 10. 队列和去重
- 全局并发由 `maxConcurrent` 控制。
- 同一课堂高光串行,避免同时加载同一课堂回放。
- 不同课堂可以并行。
- 队列中已有相同 `siteId:highlightId` 时不重复加入。
- 本地最终文件存在时不重复录制。
- OSS 文件存在时不重复录制。
- OSS 查询异常时停止本轮处理,不能把查询失败当作文件不存在。
## 11. 高光多文件查询
```http
POST /highlight/fileExists
Content-Type: application/json
```
请求:
```json
{
"siteId": "xdyui2",
"classId": "1486758620"
}
```
WebScreen 调用 `getByClassPrivate.do` 获取该课堂全部高光,再逐条检查本地任务状态和 OSS。
响应:
```json
{
"code": 0,
"message": "文件已生成",
"onlyHighlight": 1,
"classUrlList": [
{
"highlightId": 4,
"classId": "1486758620",
"siteId": "xdyui2",
"beginTime": 1779159793000,
"generated": true,
"status": "generated",
"url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260519/1486758620_highlight_4.mp4"
}
]
}
```
全部生成时 `code=0`;无文件或存在未生成文件时 `code=1`,但仍返回每条记录状态。
## 12. 验收标准
1. `xdyui2` 一个课堂有 N 条高光时生成 N 个不同 MP4。
2. 每个录制 URL 包含正确的 `recBeginTime/recEndTime`。
3. 重复执行 cron 不重复录制本地或 OSS 已存在文件。
4. `sourceMode=site` 只查询前一天 xdyui2 高光。
5. `sourceMode=task` 只处理 `status=0 && onlyHighlight=1`。
6. `/highlight/fileExists` 返回课堂全部高光文件状态和地址。
7. 原 `/fileExists` 响应不变。
8. 删除或关闭 `HIGHLIGHTCONFIG` 后,`GET /recording` 恢复原整课录制。
## 13. 已知待办
- 后端确认任务领取、成功、失败状态及回写接口。
- xdyui2 验证通过后再增加 CrazyTalk 的准确 `siteId`。
- 正式部署前确认服务器 OSS 搬运任务会处理新文件名和 `download.json`。
... ...
... ... @@ -6,6 +6,7 @@ var logger = require('morgan');
var indexRouter = require('./routes/index');
var bbgRecordingRouter = require('./routes/bbgRecording');
var highlightRecordingRouter = require('./routes/highlightRecording');
var app = express();
... ... @@ -31,6 +32,7 @@ app.all("*",function(req,res,next){
app.use('/', indexRouter);
app.use('/mp4record',bbgRecordingRouter);
app.use('/highlight', highlightRecordingRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
... ...
... ... @@ -27,5 +27,24 @@
"w":1280,
"h":720
},
"HIGHLIGHTCONFIG": {
"enabled": false,
"sourceMode": "site",
"siteIds": ["xdyui2"],
"apiBaseUrl": "https://saas.xuedianyun.com",
"pageSize": 100,
"taskPageSize": 100,
"maxPages": 1000,
"maxConcurrent": 2,
"maxDurationMs": 21600000,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500,
"loadGraceMs": 60000,
"endGraceMs": 10000,
"taskRetentionMs": 86400000,
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
},
"classLastNumber":["0","1","2","3","4","5","6","7","8","9"]
}
... ...
# 获取课堂内精彩时刻
内部接口,获取课堂内精彩时刻。
**`POST`**
```/3m/api/highlight/getByClassPrivate.do```
## 接口参数
|字段|类型|必选|描述|
|-------|-------|--------|--------|
|classId|String|是|课堂ID|
|timestamp|String|是|时间戳|
|authId|String|是|MD5(classId+timestamp)|
## 请求示例
```http
POST /3m/api/highlight/getByClassPrivate.do HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB
Content-Length: 82
classId=1486758620&authId=a66749831542e5e090e41d72f455918d&timestamp=1784628401782
```
## 返回结果
Success 200
|字段|类型|描述|
|----|----|----|
|code|int|0.正常 <br>1.classId不存在 <br>4.authId错误 <br>10.报文格式错误|
## 返回示例
```json
{
"code": 0,
"data": [
{
"meetingNumber": "1486758620",
"more": "ext",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 4,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "xuid"
},
{
"meetingNumber": "1486758620",
"more": "test",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 5,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "sss"
}
]
}
```
... ...
# 获取站点内精彩时刻
内部接口,获取站点内精彩时刻。
**`POST`**
```/3m/api/highlight/getBySitePrivate.do```
## 接口参数
|字段|类型|必选|描述|
|-------|-------|--------|--------|
|siteId|String|是|站点ID|
|pageNo|Integer|是|页码|
|pageSize|Integer|是|每页记录数|
|beginTime|Timestamp|否|开始时间:13为时间戳|
|beginTime|Timestamp|否|结束时间:13为时间戳|
|pageSize|Integer|是|每页记录数|
|timestamp|String|是|时间戳|
|authId|String|是|MD5(siteId+timestamp)|
## 请求示例
```http
POST /3m/api/highlight/getBySitePrivate.do HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=F812FB7C7F40E63955561EBE4A4AA1AB
Content-Length: 145
siteId=doctest&pageNo=1&pageSize=10&beginTime=1779159593000&endTime=1779159993000&authId=9ead85fffcaab547ba68edf0ea2b0170&timestamp=1784628642310
```
## 返回结果
Success 200
|字段|类型|描述|
|----|----|----|
|code|int|0.正常 <br>1.站点错误或已过期 <br>2.分页参数错误 <br>4.authId错误 <br>10.报文格式错误|
## 返回示例
```json
{
"code": 0,
"data": [
{
"meetingNumber": "1486758620",
"more": "test",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 5,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "sss"
},
{
"meetingNumber": "1486758620",
"more": "ext",
"siteId": "doctest",
"beginTime": 1779159793000,
"endTime": 1779159893000,
"id": 4,
"type": 0,
"userName": "xu",
"userRole": 8,
"userId": "xuid"
}
],
"pageNo": 1,
"count": 2,
"pageSize": 10
}
```
... ...
... ... @@ -4,7 +4,8 @@
"private": true,
"scripts": {
"start": "node ./bin/www",
"pm2": "pm2 start ./bin/www --name webScreen"
"pm2": "pm2 start ./bin/www --name webScreen",
"test": "node ./test/highlightRecordingService.test.js && node ./test/highlightRoutes.test.js"
},
"dependencies": {
"ali-oss": "^6.22.0",
... ...
const express = require('express');
const {
HighlightUpstreamError,
HighlightValidationError,
highlightRecordingService: service
} = require('../services/highlightRecordingService');
const router = express.Router();
function sendError(res, error) {
if (error instanceof HighlightValidationError) {
return res.status(400).send({ code: 10, message: error.message });
}
if (error instanceof HighlightUpstreamError) {
return res.status(502).send({
code: error.upstreamCode,
message: error.message
});
}
console.error('高光录制接口异常:', error);
return res.status(500).send({ code: -1, message: '服务器内部错误' });
}
router.post('/preview/by-class', async function (req, res) {
try {
const records = await service.fetchByClass(req.body && req.body.classId);
const data = await service.preview(records);
return res.send({ code: 0, message: records.length ? 'success' : '无高光数据', data });
} catch (error) {
return sendError(res, error);
}
});
router.post('/preview/by-site', async function (req, res) {
try {
const records = await service.fetchBySite(req.body || {});
const data = await service.preview(records);
return res.send({ code: 0, message: records.length ? 'success' : '无高光数据', data });
} catch (error) {
return sendError(res, error);
}
});
router.post('/recording/by-class', async function (req, res) {
try {
const records = await service.fetchByClass(req.body && req.body.classId);
if (records.length === 0) {
return res.send({ code: 0, message: '无高光数据', data: [] });
}
const data = await service.enqueue(records);
return res.send({ code: 0, message: 'success', data });
} catch (error) {
return sendError(res, error);
}
});
router.post('/recording/by-site', async function (req, res) {
try {
const records = await service.fetchBySite(req.body || {});
if (records.length === 0) {
return res.send({
code: 0,
message: '无高光数据',
data: { received: 0, queued: 0, recording: 0, uploading: 0, generated: 0, invalid: 0, failed: 0 }
});
}
const tasks = await service.enqueue(records);
const summary = service.summarizeTasks(tasks);
return res.send({ code: 0, message: 'success', data: summary });
} catch (error) {
return sendError(res, error);
}
});
router.post('/fileExists', async function (req, res) {
try {
const data = await service.getClassFileStatuses(req.body || {});
const allGenerated = data.length > 0 && data.every(item => item.generated);
return res.send({
code: allGenerated ? 0 : 1,
message: allGenerated ? '文件已生成' : (data.length ? '部分文件未生成' : '文件未生成'),
onlyHighlight: 1,
classUrlList: data
});
} catch (error) {
return sendError(res, error);
}
});
router.post('/files', async function (req, res) {
try {
const items = req.body && req.body.items;
if (!Array.isArray(items) || items.length === 0) {
throw new HighlightValidationError('items 必须是非空数组');
}
if (items.length > 1000) {
throw new HighlightValidationError('items 不能超过1000条');
}
const data = await service.getFileStatuses(items);
return res.send({ code: 0, message: 'success', data });
} catch (error) {
return sendError(res, error);
}
});
router.get('/status', function (req, res) {
res.send({ code: 0, message: 'success', data: service.getQueueInfo() });
});
module.exports = router;
... ...
... ... @@ -8,6 +8,11 @@ require('dotenv').config(); // 加载环境变量
const method = require("../config/method")
const config = require("../config/config")
const {
highlightRecordingService,
HighlightValidationError,
HighlightUpstreamError
} = require('../services/highlightRecordingService');
const version ='v1.2.0.20251208';
// const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config
const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method
... ... @@ -213,7 +218,25 @@ router.get('/recording', async function (req, res, next) {
let fileConfig = new MediaCreat().getConfigFileJson()
if (!fileConfig) return false
const { GETCLASSURLPARAMETER } = JSON.parse(fileConfig)
const parsedConfig = JSON.parse(fileConfig)
if (parsedConfig.HIGHLIGHTCONFIG && parsedConfig.HIGHLIGHTCONFIG.enabled === true) {
try {
const data = await highlightRecordingService.runScheduledRecording()
res.send({ code: "0", message: data.received ? "success" : "无高光数据", data })
} catch (error) {
new MediaCreat().wrieLog("高光录制任务读取失败:------>" + (error.message || error))
if (error instanceof HighlightValidationError) {
res.status(400).send({ code: "1", message: error.message })
} else if (error instanceof HighlightUpstreamError) {
res.status(502).send({ code: String(error.upstreamCode), message: error.message })
} else {
res.status(500).send({ code: "-1", message: "高光录制任务读取失败" })
}
}
return
}
const { GETCLASSURLPARAMETER } = parsedConfig
siteIds = GETCLASSURLPARAMETER.siteId
if (siteIds.length == 0) {
new MediaCreat().wrieLog("脚本录制,未配置siteId:------>" + siteIds)
... ...
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const querystring = require('querystring');
const OSS = require('ali-oss');
const { spawn } = require('child_process');
require('dotenv').config();
const DEFAULT_CONFIG = {
enabled: false,
sourceMode: 'site',
siteIds: [],
apiBaseUrl: 'https://saas.xuedianyun.com',
pageSize: 100,
taskPageSize: 100,
maxPages: 1000,
maxConcurrent: 2,
maxDurationMs: 6 * 60 * 60 * 1000,
apiTimeoutMs: 10000,
apiRetryCount: 2,
apiRetryBaseDelayMs: 500,
loadGraceMs: 60000,
endGraceMs: 10000,
taskRetentionMs: 24 * 60 * 60 * 1000,
outputNamespace: '',
outputBaseUrl: 'https://xdymp4.xuedianyun.com'
};
class HighlightValidationError extends Error {
constructor(message) {
super(message);
this.name = 'HighlightValidationError';
}
}
class HighlightUpstreamError extends Error {
constructor(code, message) {
super(message);
this.name = 'HighlightUpstreamError';
this.upstreamCode = code;
}
}
function md5(value) {
return crypto.createHash('md5').update(String(value), 'utf8').digest('hex');
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function formatShanghaiDate(timestamp) {
const date = new Date(Number(timestamp) + 8 * 60 * 60 * 1000);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
function getPreviousShanghaiDayRange(now) {
const shanghaiNow = new Date(Number(now == null ? Date.now() : now) + 8 * 60 * 60 * 1000);
const todayStartUtc = Date.UTC(
shanghaiNow.getUTCFullYear(),
shanghaiNow.getUTCMonth(),
shanghaiNow.getUTCDate()
) - 8 * 60 * 60 * 1000;
const beginTime = todayStartUtc - 24 * 60 * 60 * 1000;
return { beginTime, endTime: todayStartUtc - 1 };
}
function isSafeIdentifier(value) {
return /^[A-Za-z0-9_-]{1,128}$/.test(String(value || ''));
}
function isEpochMilliseconds(value) {
return Number.isSafeInteger(value) && String(value).length === 13;
}
function normalizeHighlight(raw, maxDurationMs) {
const item = raw || {};
const highlightId = Number(item.highlightId != null ? item.highlightId : item.id);
const classId = String(item.classId != null ? item.classId : item.meetingNumber || '');
const siteId = String(item.siteId || '');
const beginTime = Number(item.beginTime);
const endTime = Number(item.endTime);
if (!Number.isSafeInteger(highlightId) || highlightId <= 0) {
throw new HighlightValidationError('highlightId 无效');
}
if (!isSafeIdentifier(classId)) {
throw new HighlightValidationError('classId 无效');
}
if (!isSafeIdentifier(siteId)) {
throw new HighlightValidationError('siteId 无效');
}
if (!isEpochMilliseconds(beginTime) || !isEpochMilliseconds(endTime)) {
throw new HighlightValidationError('beginTime/endTime 必须是13位毫秒时间戳');
}
if (endTime <= beginTime) {
throw new HighlightValidationError('endTime 必须大于 beginTime');
}
if (endTime - beginTime > maxDurationMs) {
throw new HighlightValidationError('高光时长超过限制');
}
return {
highlightId,
classId,
siteId,
beginTime,
endTime,
type: item.type,
more: item.more == null ? '' : item.more,
userId: item.userId == null ? '' : item.userId,
userName: item.userName == null ? '' : item.userName,
userRole: item.userRole
};
}
function normalizeFileLookup(raw) {
const item = raw || {};
const highlightId = Number(item.highlightId != null ? item.highlightId : item.id);
const classId = String(item.classId != null ? item.classId : item.meetingNumber || '');
const siteId = String(item.siteId || '');
const beginTime = Number(item.beginTime);
if (!Number.isSafeInteger(highlightId) || highlightId <= 0) {
throw new HighlightValidationError('highlightId 无效');
}
if (!isSafeIdentifier(classId)) {
throw new HighlightValidationError('classId 无效');
}
if (!isSafeIdentifier(siteId)) {
throw new HighlightValidationError('siteId 无效');
}
if (!isEpochMilliseconds(beginTime)) {
throw new HighlightValidationError('beginTime 必须是13位毫秒时间戳');
}
return { highlightId, classId, siteId, beginTime };
}
function buildTaskKey(item) {
return `${item.siteId}:${item.highlightId}`;
}
function normalizeOutputNamespace(value) {
const namespace = String(value || '').trim();
if (namespace && !/^[A-Za-z0-9_-]{1,128}$/.test(namespace)) {
throw new HighlightValidationError('outputNamespace 只能包含字母、数字、下划线和短横线');
}
return namespace;
}
function buildFileInfo(item, projectCatalog, outputBaseUrl, outputNamespace) {
const date = formatShanghaiDate(item.beginTime);
const fileName = `${item.classId}_highlight_${item.highlightId}.mp4`;
const namespace = normalizeOutputNamespace(outputNamespace);
const localParts = [projectCatalog, 'media'];
const ossParts = ['oss'];
if (namespace) {
localParts.push(namespace);
ossParts.push(namespace);
}
localParts.push(item.siteId, date);
ossParts.push(item.siteId, date, fileName);
const localDir = path.join(...localParts);
const localPath = path.join(localDir, fileName);
const tempDir = path.join(projectCatalog, '.highlight_tmp', ...localParts.slice(2));
const tempPath = path.join(tempDir, fileName);
const ossKey = ossParts.join('/');
return {
namespace,
date,
fileName,
localDir,
localPath,
tempDir,
tempPath,
ossKey,
url: `${String(outputBaseUrl).replace(/\/$/, '')}/${ossKey}`
};
}
class HighlightRecordingService {
constructor(options) {
const opts = options || {};
this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json');
this.queue = [];
this.tasks = new Map();
this.activeClassIds = new Set();
this.activeCount = 0;
this.scheduledRunActive = false;
this.completedDirs = new Set();
this.ossClient = null;
this.ossClientInitialized = false;
}
readConfig() {
const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
const highlightConfig = Object.assign({}, DEFAULT_CONFIG, raw.HIGHLIGHTCONFIG || {});
return {
projectCatalog: raw.PROJECTCATALOG,
captureCatalog: raw.PROJECTWINCATALOG,
playback: raw.BACKMEDIACONFIG || {},
highlight: highlightConfig
};
}
writeLog(message) {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const logDir = path.join(process.cwd(), 'log');
fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(path.join(logDir, `${ymd}.txt`), `${now.toLocaleString()} 高光录制 ${message}\r\n`);
}
cleanupTasks() {
const retentionMs = Number(this.readConfig().highlight.taskRetentionMs);
if (!Number.isFinite(retentionMs) || retentionMs <= 0) return;
const cutoff = Date.now() - retentionMs;
for (const [key, task] of this.tasks.entries()) {
if (!['queued', 'recording'].includes(task.status) && task.updatedAt < cutoff) {
this.tasks.delete(key);
}
}
}
ensureFeatureEnabled() {
if (!this.readConfig().highlight.enabled) {
throw new HighlightValidationError('高光录制功能未启用');
}
}
getAllowedSiteIds() {
const siteIds = this.readConfig().highlight.siteIds;
if (!Array.isArray(siteIds)) {
throw new HighlightValidationError('HIGHLIGHTCONFIG.siteIds 必须是数组');
}
const normalized = siteIds.map(siteId => String(siteId || '')).filter(Boolean);
for (const siteId of normalized) {
if (!isSafeIdentifier(siteId)) {
throw new HighlightValidationError(`高光站点ID无效: ${siteId}`);
}
}
return normalized;
}
isSiteAllowed(siteId) {
return this.getAllowedSiteIds().includes(String(siteId || ''));
}
async requestHighlights(endpoint, authValue, fields) {
const config = this.readConfig().highlight;
const url = new URL(endpoint, `${String(config.apiBaseUrl).replace(/\/$/, '')}/`).toString();
let response;
let lastError;
for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) {
const timestamp = String(Date.now());
const body = Object.assign({}, fields, {
timestamp,
authId: md5(String(authValue) + timestamp)
});
try {
response = await axios.post(url, querystring.stringify(body), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: config.apiTimeoutMs
});
break;
} catch (error) {
lastError = error;
const status = error && error.response && error.response.status;
const retryable = !status || status >= 500;
if (!retryable || attempt >= config.apiRetryCount) {
const upstreamError = new HighlightUpstreamError(-1, `高光接口请求失败${status ? ` HTTP ${status}` : ''}`);
upstreamError.cause = error;
throw upstreamError;
}
await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt));
}
}
if (!response) {
const upstreamError = new HighlightUpstreamError(-1, '高光接口请求失败');
upstreamError.cause = lastError;
throw upstreamError;
}
const result = response.data || {};
const code = Number(result.code);
if (code !== 0) {
throw new HighlightUpstreamError(code, `高光接口返回错误 code=${result.code}`);
}
if (!Array.isArray(result.data)) {
throw new HighlightUpstreamError(10, '高光接口 data 不是数组');
}
return result;
}
async fetchByClass(classId) {
this.ensureFeatureEnabled();
const normalizedClassId = String(classId || '');
if (!isSafeIdentifier(normalizedClassId)) {
throw new HighlightValidationError('classId 无效');
}
const result = await this.requestHighlights(
'/3m/api/highlight/getByClassPrivate.do',
normalizedClassId,
{ classId: normalizedClassId }
);
return result.data;
}
async fetchRecordingTasks() {
this.ensureFeatureEnabled();
const config = this.readConfig().highlight;
const pageSize = Number(config.taskPageSize || config.pageSize);
if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 1000) {
throw new HighlightValidationError('taskPageSize 无效');
}
const records = [];
let pageNo = 1;
while (pageNo <= config.maxPages) {
const url = new URL(
'/3m/api/recording/getRecordingTasksPrivate.do',
`${String(config.apiBaseUrl).replace(/\/$/, '')}/`
).toString();
let response;
for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) {
const timestamp = String(Date.now());
const body = {
pageNo,
pageSize,
timestamp,
authId: md5(`${pageNo}${pageSize}${timestamp}`)
};
try {
response = await axios.post(url, querystring.stringify(body), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: config.apiTimeoutMs
});
break;
} catch (error) {
const status = error && error.response && error.response.status;
const retryable = !status || status >= 500;
if (!retryable || attempt >= config.apiRetryCount) {
throw new HighlightUpstreamError(-1, `录制任务接口请求失败${status ? ` HTTP ${status}` : ''}`);
}
await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt));
}
}
const result = response.data || {};
if (Number(result.code) !== 0) {
throw new HighlightUpstreamError(Number(result.code), `录制任务接口返回错误 code=${result.code}`);
}
if (!Array.isArray(result.taskList)) {
throw new HighlightUpstreamError(10, '录制任务接口 taskList 不是数组');
}
records.push(...result.taskList);
const taskCount = Number(result.taskCount);
if (result.taskList.length < pageSize || (Number.isFinite(taskCount) && pageNo * pageSize >= taskCount)) {
break;
}
pageNo += 1;
}
if (pageNo > config.maxPages) {
throw new HighlightUpstreamError(2, '录制任务接口分页超过安全上限');
}
return records;
}
async fetchBySite(params) {
this.ensureFeatureEnabled();
const input = params || {};
const siteId = String(input.siteId || '');
if (!isSafeIdentifier(siteId)) {
throw new HighlightValidationError('siteId 无效');
}
const config = this.readConfig().highlight;
const pageSize = Number(input.pageSize || config.pageSize);
if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 1000) {
throw new HighlightValidationError('pageSize 无效');
}
const beginTime = input.beginTime == null || input.beginTime === '' ? null : Number(input.beginTime);
const endTime = input.endTime == null || input.endTime === '' ? null : Number(input.endTime);
if (beginTime != null && !isEpochMilliseconds(beginTime)) {
throw new HighlightValidationError('beginTime 必须是13位毫秒时间戳');
}
if (endTime != null && !isEpochMilliseconds(endTime)) {
throw new HighlightValidationError('endTime 必须是13位毫秒时间戳');
}
if (beginTime != null && endTime != null && endTime <= beginTime) {
throw new HighlightValidationError('endTime 必须大于 beginTime');
}
const records = [];
const seen = new Set();
let pageNo = 1;
while (pageNo <= config.maxPages) {
const fields = { siteId, pageNo, pageSize };
if (beginTime != null) fields.beginTime = beginTime;
if (endTime != null) fields.endTime = endTime;
const result = await this.requestHighlights(
'/3m/api/highlight/getBySitePrivate.do',
siteId,
fields
);
for (const item of result.data) {
const dedupeKey = `${item.siteId || siteId}:${item.id}:${item.meetingNumber}:${item.beginTime}:${item.endTime}`;
if (!seen.has(dedupeKey)) {
seen.add(dedupeKey);
records.push(item);
}
}
const count = Number(result.count);
const responsePageSize = Number(result.pageSize) || pageSize;
const responsePageNo = Number(result.pageNo) || pageNo;
if (result.data.length === 0 || !Number.isFinite(count) || responsePageNo * responsePageSize >= count) {
break;
}
pageNo += 1;
}
if (pageNo > config.maxPages) {
throw new HighlightUpstreamError(2, '高光接口分页超过安全上限');
}
return records;
}
getOssClient() {
if (this.ossClientInitialized) return this.ossClient;
this.ossClientInitialized = true;
if (!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID || !process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET) {
return null;
}
this.ossClient = new OSS({
region: 'oss-cn-beijing',
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
authorizationV4: true,
bucket: 'xdymp4'
});
return this.ossClient;
}
localFileExists(filePath) {
try {
return fs.statSync(filePath).size > 0;
} catch (error) {
return false;
}
}
async inspectFile(fileInfo) {
const local = this.localFileExists(fileInfo.localPath);
const client = this.getOssClient();
if (!client) {
return { local, oss: null, generated: false };
}
try {
await client.head(fileInfo.ossKey);
return { local, oss: true, generated: true };
} catch (error) {
const status = error && (error.status || (error.res && error.res.status));
if (status !== 404 && error && error.code !== 'NoSuchKey') {
this.writeLog(`OSS查询失败 key=${fileInfo.ossKey} error=${error.message || error}`);
throw new HighlightUpstreamError(-1, 'OSS文件状态查询失败');
}
return { local, oss: false, generated: false };
}
}
snapshot(task) {
const result = {
highlightId: task.item.highlightId,
classId: task.item.classId,
siteId: task.item.siteId,
beginTime: task.item.beginTime,
endTime: task.item.endTime,
status: task.status
};
if (task.status === 'generated') result.url = task.fileInfo.url;
if (task.error) result.error = task.error;
return result;
}
async preview(records) {
const config = this.readConfig();
const allowedSiteIds = new Set(this.getAllowedSiteIds());
const output = [];
for (const raw of records || []) {
let item;
try {
item = normalizeHighlight(raw, config.highlight.maxDurationMs);
if (!allowedSiteIds.has(item.siteId)) {
throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
}
const fileInfo = buildFileInfo(
item,
config.projectCatalog,
config.highlight.outputBaseUrl,
config.highlight.outputNamespace
);
const fileState = await this.inspectFile(fileInfo);
output.push({
highlightId: item.highlightId,
classId: item.classId,
siteId: item.siteId,
beginTime: item.beginTime,
endTime: item.endTime,
duration: item.endTime - item.beginTime,
status: fileState.generated ? 'generated' : (fileState.local ? 'uploading' : 'not_generated'),
fileName: fileInfo.fileName,
localPath: fileInfo.localPath,
ossKey: fileInfo.ossKey,
url: fileState.generated ? fileInfo.url : null,
playbackUrl: this.buildPlaybackUrl(item, config.playback)
});
} catch (error) {
output.push({
highlightId: raw && (raw.id || raw.highlightId),
classId: raw && (raw.meetingNumber || raw.classId),
status: 'invalid',
error: error.message
});
}
}
return output;
}
async enqueue(records) {
this.cleanupTasks();
const config = this.readConfig();
const allowedSiteIds = new Set(this.getAllowedSiteIds());
const output = [];
for (const raw of records || []) {
let item;
try {
item = normalizeHighlight(raw, config.highlight.maxDurationMs);
if (!allowedSiteIds.has(item.siteId)) {
throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
}
} catch (error) {
output.push({
highlightId: raw && (raw.id || raw.highlightId),
classId: raw && (raw.meetingNumber || raw.classId),
status: 'invalid',
error: error.message
});
continue;
}
const key = buildTaskKey(item);
const existing = this.tasks.get(key);
if (existing && ['queued', 'recording', 'uploading', 'generated'].includes(existing.status)) {
if (existing.item.classId !== item.classId || existing.item.beginTime !== item.beginTime || existing.item.endTime !== item.endTime) {
output.push({
highlightId: item.highlightId,
classId: item.classId,
status: 'invalid',
error: '相同 siteId/highlightId 对应的课堂或时间段发生变化'
});
continue;
}
output.push(this.snapshot(existing));
continue;
}
const fileInfo = buildFileInfo(
item,
config.projectCatalog,
config.highlight.outputBaseUrl,
config.highlight.outputNamespace
);
const fileState = await this.inspectFile(fileInfo);
if (fileState.generated || fileState.local) {
const status = fileState.generated ? 'generated' : 'uploading';
const task = { key, item, fileInfo, status, error: '', updatedAt: Date.now() };
this.tasks.set(key, task);
if (fileState.local && !fileState.generated) this.completedDirs.add(fileInfo.localDir);
output.push(this.snapshot(task));
continue;
}
const task = { key, item, fileInfo, status: 'queued', error: '', updatedAt: Date.now() };
this.tasks.set(key, task);
this.queue.push(task);
output.push(this.snapshot(task));
}
this.schedule();
this.writeCompletionMarkersIfIdle();
return output;
}
schedule() {
const config = this.readConfig().highlight;
while (this.activeCount < config.maxConcurrent && this.queue.length > 0) {
const index = this.queue.findIndex(task => !this.activeClassIds.has(`${task.item.siteId}:${task.item.classId}`));
if (index < 0) return;
const task = this.queue.splice(index, 1)[0];
const activeClassKey = `${task.item.siteId}:${task.item.classId}`;
this.activeCount += 1;
this.activeClassIds.add(activeClassKey);
this.processTask(task).catch(error => {
task.status = 'failed';
task.error = error.message || String(error);
task.updatedAt = Date.now();
this.writeLog(`失败 key=${task.key} error=${task.error}`);
}).finally(() => {
this.activeCount -= 1;
this.activeClassIds.delete(activeClassKey);
this.writeCompletionMarkersIfIdle();
this.schedule();
});
}
}
buildPlaybackUrl(item, playback) {
const url = new URL(playback.url);
const params = url.searchParams;
params.set('classId', item.classId);
params.set('recordMp4', 'true');
params.set('userId', String(playback.userId == null ? 0 : playback.userId));
params.set('userName', String(playback.userName || ''));
params.set('userRole', String(playback.userRole || 'normal'));
params.set('portalIP', String(playback.portalIP || 'saas.xuedianyun.com'));
params.set('portalPort', String(playback.portalPort || 80));
params.set('channels', String(playback.channels == null ? 2 : playback.channels));
params.set('playRecord', '1');
params.set('language', 'zh-cn');
params.set('recBeginTime', String(item.beginTime));
params.set('recEndTime', String(item.endTime));
return url.toString();
}
runCapture(task, config) {
return new Promise((resolve, reject) => {
const duration = task.item.endTime - task.item.beginTime;
const playbackUrl = this.buildPlaybackUrl(task.item, config.playback);
const binary = path.join(config.captureCatalog, 'web_capture_c');
const args = [
`-o=${task.fileInfo.tempPath}`,
`-u=${playbackUrl}`,
`-d=${duration}`,
`-s=${config.playback.s}`,
`-fa=${config.playback.fa}`,
`-k=${config.playback.k}`,
`-w=${config.playback.w}`,
`-h=${config.playback.h}`,
'-ar=44100'
];
const child = spawn(binary, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] });
const watchdogMs = duration + config.highlight.loadGraceMs + config.highlight.endGraceMs;
let stderr = '';
let settled = false;
let timedOut = false;
let killTimer = null;
const watchdog = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
}, watchdogMs);
child.stderr.on('data', chunk => {
stderr = `${stderr}${chunk}`.slice(-8192);
});
child.on('error', error => {
if (settled) return;
settled = true;
clearTimeout(watchdog);
if (killTimer) clearTimeout(killTimer);
reject(error);
});
child.on('close', code => {
if (settled) return;
settled = true;
clearTimeout(watchdog);
if (killTimer) clearTimeout(killTimer);
if (timedOut) {
reject(new Error(`录制超时 ${watchdogMs}ms`));
} else if (code !== 0) {
reject(new Error(`web_capture_c 退出码 ${code}: ${stderr.trim()}`));
} else {
resolve();
}
});
});
}
async processTask(task) {
const config = this.readConfig();
task.status = 'recording';
task.error = '';
task.updatedAt = Date.now();
fs.mkdirSync(task.fileInfo.localDir, { recursive: true });
fs.mkdirSync(task.fileInfo.tempDir, { recursive: true });
if (fs.existsSync(task.fileInfo.tempPath)) fs.unlinkSync(task.fileInfo.tempPath);
this.writeLog(`开始 key=${task.key} classId=${task.item.classId} beginTime=${task.item.beginTime} endTime=${task.item.endTime}`);
await this.runCapture(task, config);
if (!this.localFileExists(task.fileInfo.tempPath)) {
throw new Error('录制进程结束但未生成有效文件');
}
if (fs.existsSync(task.fileInfo.localPath)) {
fs.unlinkSync(task.fileInfo.tempPath);
} else {
fs.renameSync(task.fileInfo.tempPath, task.fileInfo.localPath);
}
const state = await this.inspectFile(task.fileInfo);
task.status = state.generated ? 'generated' : 'uploading';
task.updatedAt = Date.now();
this.completedDirs.add(task.fileInfo.localDir);
this.writeLog(`完成 key=${task.key} status=${task.status} file=${task.fileInfo.localPath}`);
}
writeCompletionMarkersIfIdle() {
if (this.activeCount !== 0 || this.queue.length !== 0 || this.completedDirs.size === 0) return;
for (const localDir of this.completedDirs) {
try {
fs.writeFileSync(path.join(localDir, 'download.json'), JSON.stringify({ code: '0', success: 'ok' }));
} catch (error) {
this.writeLog(`写入完成标记失败 dir=${localDir} error=${error.message || error}`);
}
}
this.completedDirs.clear();
}
summarizeTasks(tasks) {
const summary = {
received: tasks.length,
queued: 0,
recording: 0,
uploading: 0,
generated: 0,
invalid: 0,
failed: 0
};
for (const task of tasks) {
if (Object.prototype.hasOwnProperty.call(summary, task.status)) {
summary[task.status] += 1;
}
}
return summary;
}
async runScheduledRecording(now) {
this.ensureFeatureEnabled();
if (this.scheduledRunActive) {
throw new HighlightValidationError('高光任务正在读取中');
}
this.scheduledRunActive = true;
try {
const config = this.readConfig().highlight;
const mode = String(config.sourceMode || 'site');
const siteIds = this.getAllowedSiteIds();
if (siteIds.length === 0) {
throw new HighlightValidationError('未配置高光站点');
}
if (mode === 'site') {
const range = getPreviousShanghaiDayRange(now);
const records = [];
for (const siteId of siteIds) {
const siteRecords = await this.fetchBySite({
siteId,
beginTime: range.beginTime,
endTime: range.endTime
});
records.push(...siteRecords);
}
const tasks = await this.enqueue(records);
return Object.assign({ mode, beginTime: range.beginTime, endTime: range.endTime }, this.summarizeTasks(tasks));
}
if (mode === 'task') {
const rawTasks = await this.fetchRecordingTasks();
// TODO: 等后端明确录制任务状态流转及完成回写接口。
const pendingTasks = rawTasks.filter(task =>
Number(task && task.status) === 0 &&
Number(task && task.onlyHighlight) === 1 &&
siteIds.includes(String(task && task.siteId || ''))
);
const classKeys = new Set();
const records = [];
for (const task of pendingTasks) {
const classId = String(task.meetingNumber || task.classId || '');
const classKey = `${task.siteId}:${classId}`;
if (!isSafeIdentifier(classId) || classKeys.has(classKey)) continue;
classKeys.add(classKey);
const classRecords = await this.fetchByClass(classId);
records.push(...classRecords.filter(record => String(record.siteId || '') === String(task.siteId)));
}
const tasks = await this.enqueue(records);
return Object.assign({ mode, sourceTasks: rawTasks.length, pendingTasks: pendingTasks.length }, this.summarizeTasks(tasks));
}
throw new HighlightValidationError('HIGHLIGHTCONFIG.sourceMode 只能是 site 或 task');
} finally {
this.scheduledRunActive = false;
}
}
async getClassFileStatuses(params) {
this.ensureFeatureEnabled();
const input = params || {};
const siteId = String(input.siteId || '');
const classId = String(input.classId || input.meetingNumber || '');
if (!isSafeIdentifier(siteId)) throw new HighlightValidationError('siteId 无效');
if (!isSafeIdentifier(classId)) throw new HighlightValidationError('classId 无效');
if (!this.isSiteAllowed(siteId)) {
throw new HighlightValidationError(`站点未启用高光录制: ${siteId}`);
}
const records = await this.fetchByClass(classId);
const matched = records.filter(record => String(record.siteId || '') === siteId);
return this.getFileStatuses(matched);
}
async getFileStatuses(items) {
this.cleanupTasks();
const config = this.readConfig();
const allowedSiteIds = new Set(this.getAllowedSiteIds());
const resolveStatus = async raw => {
let item;
try {
item = normalizeFileLookup(raw);
if (!allowedSiteIds.has(item.siteId)) {
throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
}
} catch (error) {
return {
highlightId: raw && (raw.highlightId || raw.id),
classId: raw && (raw.classId || raw.meetingNumber),
generated: false,
status: 'invalid',
url: null,
error: error.message
};
}
const fileInfo = buildFileInfo(
item,
config.projectCatalog,
config.highlight.outputBaseUrl,
config.highlight.outputNamespace
);
const fileState = await this.inspectFile(fileInfo);
const key = buildTaskKey(item);
const task = this.tasks.get(key);
let status = 'not_generated';
if (fileState.generated) {
status = 'generated';
if (task) {
task.status = 'generated';
task.updatedAt = Date.now();
}
} else if (fileState.local) {
status = 'uploading';
} else if (task) {
status = task.status;
}
return {
highlightId: item.highlightId,
classId: item.classId,
siteId: item.siteId,
beginTime: item.beginTime,
generated: status === 'generated',
status,
url: status === 'generated' ? fileInfo.url : null,
error: task && task.error ? task.error : undefined
};
};
const output = [];
const source = items || [];
const concurrency = 20;
for (let index = 0; index < source.length; index += concurrency) {
const batch = source.slice(index, index + concurrency);
const results = await Promise.all(batch.map(resolveStatus));
output.push(...results);
}
return output;
}
getQueueInfo() {
this.cleanupTasks();
return {
queued: this.queue.length,
recording: this.activeCount,
knownTasks: this.tasks.size
};
}
}
const highlightRecordingService = new HighlightRecordingService();
module.exports = {
HighlightRecordingService,
HighlightUpstreamError,
HighlightValidationError,
buildFileInfo,
buildTaskKey,
formatShanghaiDate,
md5,
normalizeFileLookup,
normalizeHighlight,
normalizeOutputNamespace,
getPreviousShanghaiDayRange,
highlightRecordingService
};
... ...
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
HighlightRecordingService,
buildFileInfo,
buildTaskKey,
formatShanghaiDate,
getPreviousShanghaiDayRange,
md5,
normalizeFileLookup,
normalizeHighlight,
normalizeOutputNamespace
} = require('../services/highlightRecordingService');
async function run() {
assert.strictEqual(md5('14867586201784628401782'), 'a66749831542e5e090e41d72f455918d');
const shanghaiMidnight = Date.UTC(2026, 0, 1, 16, 0, 0, 0);
assert.strictEqual(formatShanghaiDate(shanghaiMidnight), '20260102');
assert.deepStrictEqual(getPreviousShanghaiDayRange(Date.UTC(2026, 7, 6, 0, 0, 0)), {
beginTime: Date.UTC(2026, 7, 4, 16, 0, 0),
endTime: Date.UTC(2026, 7, 5, 15, 59, 59, 999)
});
const item = normalizeHighlight({
id: 4,
meetingNumber: '1486758620',
siteId: 'doctest',
beginTime: 1779159793000,
endTime: 1779159893000,
type: 0,
more: 'ext'
}, 3600000);
assert.strictEqual(item.highlightId, 4);
assert.strictEqual(item.classId, '1486758620');
assert.strictEqual(buildTaskKey(item), 'doctest:4');
assert.throws(() => normalizeHighlight({
id: 4,
meetingNumber: '1486758620',
siteId: '../bad',
beginTime: 1779159793000,
endTime: 1779159893000
}, 3600000), /siteId 无效/);
assert.throws(() => normalizeHighlight({
id: 4,
meetingNumber: '1486758620',
siteId: 'doctest',
beginTime: 1779159893000,
endTime: 1779159793000
}, 3600000), /endTime 必须大于 beginTime/);
assert.deepStrictEqual(normalizeFileLookup({
highlightId: 4,
classId: '1486758620',
siteId: 'doctest',
beginTime: 1779159793000
}), {
highlightId: 4,
classId: '1486758620',
siteId: 'doctest',
beginTime: 1779159793000
});
const fileInfo = buildFileInfo(item, '/capture', 'https://xdymp4.xuedianyun.com/');
assert.strictEqual(fileInfo.fileName, '1486758620_highlight_4.mp4');
assert.strictEqual(fileInfo.localPath, path.join('/capture', 'media', 'doctest', '20260519', fileInfo.fileName));
assert.strictEqual(fileInfo.ossKey, 'oss/doctest/20260519/1486758620_highlight_4.mp4');
assert.strictEqual(fileInfo.url, `https://xdymp4.xuedianyun.com/${fileInfo.ossKey}`);
const validationFileInfo = buildFileInfo(item, '/capture', 'https://xdymp4.xuedianyun.com/', 'validation-20260805');
assert.strictEqual(
validationFileInfo.localPath,
path.join('/capture', 'media', 'validation-20260805', 'doctest', '20260519', validationFileInfo.fileName)
);
assert.strictEqual(
validationFileInfo.ossKey,
'oss/validation-20260805/doctest/20260519/1486758620_highlight_4.mp4'
);
assert.strictEqual(normalizeOutputNamespace('validation_1'), 'validation_1');
assert.throws(() => normalizeOutputNamespace('../validation'), /outputNamespace/);
const service = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
const playbackUrl = new URL(service.buildPlaybackUrl(item, {
url: 'https://pclive.xuedianyun.com/pcBase/pclive2/dev/index.html',
portalIP: 'saas.xuedianyun.com',
portalPort: 80,
channels: 2,
userRole: 'normal'
}));
assert.strictEqual(playbackUrl.searchParams.get('classId'), '1486758620');
assert.strictEqual(playbackUrl.searchParams.get('playRecord'), '1');
assert.strictEqual(playbackUrl.searchParams.get('recBeginTime'), '1779159793000');
assert.strictEqual(playbackUrl.searchParams.get('recEndTime'), '1779159893000');
const queueService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
queueService.readConfig = () => ({
projectCatalog: '/capture',
highlight: {
siteIds: ['doctest'],
maxDurationMs: 3600000,
outputBaseUrl: 'https://xdymp4.xuedianyun.com',
taskRetentionMs: 86400000,
maxConcurrent: 1
}
});
queueService.inspectFile = async () => ({ local: false, oss: true, generated: true });
const firstResult = await queueService.enqueue([item]);
assert.strictEqual(firstResult[0].status, 'generated');
const changedResult = await queueService.enqueue([Object.assign({}, item, { beginTime: item.beginTime + 1000 })]);
assert.strictEqual(changedResult[0].status, 'invalid');
assert.match(changedResult[0].error, /时间段发生变化/);
const previewService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
previewService.readConfig = () => ({
projectCatalog: '/capture',
playback: {
url: 'https://pclive.xuedianyun.com/pcBase/pclive2/dev/index.html',
portalIP: 'saas.xuedianyun.com',
portalPort: 80,
channels: 2
},
highlight: {
siteIds: ['doctest'],
maxDurationMs: 3600000,
outputBaseUrl: 'https://xdymp4.xuedianyun.com',
outputNamespace: 'validation-20260805'
}
});
previewService.inspectFile = async () => ({ local: false, oss: false, generated: false });
const preview = await previewService.preview([item]);
assert.strictEqual(preview[0].status, 'not_generated');
assert.strictEqual(preview[0].duration, 100000);
assert.match(preview[0].ossKey, /^oss\/validation-20260805\//);
assert.strictEqual(new URL(preview[0].playbackUrl).searchParams.get('recBeginTime'), '1779159793000');
assert.strictEqual(previewService.getQueueInfo().queued, 0);
const scheduledSiteService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
scheduledSiteService.readConfig = () => ({
highlight: {
enabled: true,
sourceMode: 'site',
siteIds: ['xdyui2'],
maxConcurrent: 1,
taskRetentionMs: 86400000
}
});
let siteQuery;
scheduledSiteService.fetchBySite = async params => {
siteQuery = params;
return [];
};
scheduledSiteService.enqueue = async records => records;
const scheduledSite = await scheduledSiteService.runScheduledRecording(Date.UTC(2026, 7, 6, 0, 0, 0));
assert.strictEqual(scheduledSite.mode, 'site');
assert.strictEqual(siteQuery.siteId, 'xdyui2');
assert.strictEqual(scheduledSite.received, 0);
const scheduledTaskService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
scheduledTaskService.readConfig = () => ({
highlight: {
enabled: true,
sourceMode: 'task',
siteIds: ['xdyui2'],
maxConcurrent: 1,
taskRetentionMs: 86400000
}
});
scheduledTaskService.fetchRecordingTasks = async () => [
{ meetingNumber: '1001', siteId: 'xdyui2', status: 0, onlyHighlight: 1 },
{ meetingNumber: '1002', siteId: 'xdyui2', status: 1, onlyHighlight: 1 },
{ meetingNumber: '1003', siteId: 'xdyui2', status: 0, onlyHighlight: 0 },
{ meetingNumber: '1004', siteId: 'other', status: 0, onlyHighlight: 1 }
];
const fetchedClasses = [];
scheduledTaskService.fetchByClass = async classId => {
fetchedClasses.push(classId);
return [Object.assign({}, item, { meetingNumber: classId, siteId: 'xdyui2' })];
};
scheduledTaskService.enqueue = async records => records.map(record => ({ status: 'queued', record }));
const scheduledTask = await scheduledTaskService.runScheduledRecording();
assert.deepStrictEqual(fetchedClasses, ['1001']);
assert.strictEqual(scheduledTask.sourceTasks, 4);
assert.strictEqual(scheduledTask.pendingTasks, 1);
assert.strictEqual(scheduledTask.queued, 1);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webscreen-highlight-'));
try {
const captureService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
captureService.readConfig = () => ({
projectCatalog: tempRoot,
captureCatalog: '/capture',
playback: { url: 'https://example.com/index.html' },
highlight: {
enabled: true,
siteIds: ['doctest'],
maxDurationMs: 3600000,
outputBaseUrl: 'https://xdymp4.xuedianyun.com',
outputNamespace: '',
taskRetentionMs: 86400000,
maxConcurrent: 1,
loadGraceMs: 100,
endGraceMs: 100
}
});
captureService.inspectFile = async fileInfo => ({
local: fs.existsSync(fileInfo.localPath),
oss: false,
generated: false
});
captureService.runCapture = async task => {
fs.writeFileSync(task.fileInfo.tempPath, 'test-mp4');
};
await captureService.enqueue([item]);
for (let attempt = 0; attempt < 100 && captureService.getQueueInfo().recording > 0; attempt += 1) {
await new Promise(resolve => setTimeout(resolve, 10));
}
const capturedFile = buildFileInfo(item, tempRoot, 'https://xdymp4.xuedianyun.com');
assert.strictEqual(fs.readFileSync(capturedFile.localPath, 'utf8'), 'test-mp4');
assert.ok(fs.existsSync(path.join(capturedFile.localDir, 'download.json')));
assert.strictEqual(fs.existsSync(capturedFile.tempPath), false);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
console.log('highlightRecordingService tests passed');
}
run().catch(error => {
console.error(error);
process.exitCode = 1;
});
... ...
const assert = require('assert');
const http = require('http');
const { highlightRecordingService } = require('../services/highlightRecordingService');
const app = require('../app');
function request(server, method, route, body) {
return new Promise((resolve, reject) => {
const address = server.address();
const payload = body == null ? '' : JSON.stringify(body);
const req = http.request({
host: '127.0.0.1',
port: address.port,
path: route,
method,
headers: payload ? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
} : {}
}, response => {
let data = '';
response.setEncoding('utf8');
response.on('data', chunk => { data += chunk; });
response.on('end', () => {
resolve({ status: response.statusCode, body: JSON.parse(data) });
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function run() {
const server = http.createServer(app);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const status = await request(server, 'GET', '/highlight/status');
assert.strictEqual(status.status, 200);
assert.deepStrictEqual(status.body.data, { queued: 0, recording: 0, knownTasks: 0 });
const invalidFiles = await request(server, 'POST', '/highlight/files', { items: [] });
assert.strictEqual(invalidFiles.status, 400);
assert.strictEqual(invalidFiles.body.code, 10);
const invalidPreview = await request(server, 'POST', '/highlight/preview/by-class', {});
assert.strictEqual(invalidPreview.status, 400);
assert.strictEqual(invalidPreview.body.code, 10);
const invalidClassFiles = await request(server, 'POST', '/highlight/fileExists', {});
assert.strictEqual(invalidClassFiles.status, 400);
assert.strictEqual(invalidClassFiles.body.code, 10);
const originalGetClassFileStatuses = highlightRecordingService.getClassFileStatuses;
highlightRecordingService.getClassFileStatuses = async () => [
{ highlightId: 4, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/4.mp4' },
{ highlightId: 5, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/5.mp4' }
];
const generatedFiles = await request(server, 'POST', '/highlight/fileExists', { siteId: 'xdyui2', classId: '1001' });
assert.strictEqual(generatedFiles.body.code, 0);
assert.strictEqual(generatedFiles.body.onlyHighlight, 1);
assert.strictEqual(generatedFiles.body.classUrlList.length, 2);
highlightRecordingService.getClassFileStatuses = async () => [
{ highlightId: 4, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/4.mp4' },
{ highlightId: 5, classId: '1001', siteId: 'xdyui2', generated: false, status: 'uploading', url: null }
];
const partialFiles = await request(server, 'POST', '/highlight/fileExists', { siteId: 'xdyui2', classId: '1001' });
assert.strictEqual(partialFiles.body.code, 1);
assert.strictEqual(partialFiles.body.message, '部分文件未生成');
highlightRecordingService.getClassFileStatuses = originalGetClassFileStatuses;
} finally {
await new Promise(resolve => server.close(resolve));
}
console.log('highlight routes tests passed');
}
run().catch(error => {
console.error(error);
process.exitCode = 1;
});
... ...