huz1xuan

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

1 node_modules 1 node_modules
2 .idea 2 .idea
3 log 3 log
  4 +.DS_Store
  1 +# WebScreen 高光录制部署文档
  2 +
  3 +## 1. 部署范围
  4 +
  5 +当前仅部署到新验证服务器,只启用 `xdyui2`。
  6 +
  7 +不要直接替换现有正式录制服务器。CrazyTalk 等 xdyui2 验证通过后再添加。
  8 +
  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`,增加:
  37 +
  38 +```json
  39 +"HIGHLIGHTCONFIG": {
  40 + "enabled": true,
  41 + "sourceMode": "site",
  42 + "siteIds": ["xdyui2"],
  43 + "apiBaseUrl": "https://saas.xuedianyun.com",
  44 + "pageSize": 100,
  45 + "taskPageSize": 100,
  46 + "maxPages": 1000,
  47 + "maxConcurrent": 2,
  48 + "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"
  57 +}
  58 +```
  59 +
  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 +```
  73 +
  74 +预期:
  75 +
  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 +```
  111 +
  112 +一个课堂有 N 条高光时,应出现 N 个不同文件。
  113 +
  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 +wget -qO- http://127.0.0.1:3001/recording
  130 +```
  131 +
  132 +响应 `data.mode` 应为 `site`,时间窗应为 Asia/Shanghai 前一天。
  133 +
  134 +确认无误后配置:
  135 +
  136 +```cron
  137 +57 7 * * * wget -qO- http://127.0.0.1:3001/recording >/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 +高光录制完成后,本地目录应包含:
  161 +
  162 +```text
  163 +{classId}_highlight_{highlightId}.mp4
  164 +download.json
  165 +```
  166 +
  167 +等待现有搬运任务执行,再检查:
  168 +
  169 +```text
  170 +https://xdymp4.xuedianyun.com/oss/xdyui2/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
  171 +```
  172 +
  173 +确认搬运程序不会只匹配旧 `{classId}.mp4` 文件名。
  174 +
  175 +## 11. 回滚
  176 +
  177 +最快业务回滚:
  178 +
  179 +```json
  180 +"HIGHLIGHTCONFIG": {
  181 + "enabled": false
  182 +}
  183 +```
  184 +
  185 +然后:
  186 +
  187 +```bash
  188 +pm2 restart webScreen
  189 +```
  190 +
  191 +关闭后,`GET /recording` 继续运行原整课录制逻辑。高光使用独立文件名,不覆盖旧整课 MP4。
  192 +
  193 +## 12. 上线 CrazyTalk 前检查
  194 +
  195 +1. 获取 CrazyTalk 准确、区分大小写的 `siteId`。
  196 +2. xdyui2 连续验证多天,无重复、缺段和错误路径。
  197 +3. 确认 OSS 搬运支持高光文件名。
  198 +4. 确认任务状态回写方案。
  199 +5. 将 CrazyTalk 加入 `HIGHLIGHTCONFIG.siteIds`,不要改旧 `GETCLASSURLPARAMETER.siteId`。
  1 +# WebScreen 高光时刻 MP4 录制 Spec
  2 +
  3 +## 1. 目标
  4 +
  5 +WebScreen 根据 SaaS 高光记录,把每个 `beginTime/endTime` 时间段录制成独立 MP4。
  6 +
  7 +已确认:
  8 +
  9 +- 一个课堂可以有多条高光。
  10 +- 一个高光时间段生成一个 MP4;N 条高光生成 N 个文件。
  11 +- 当前只在 `xdyui2` 测试,验证后再增加 CrazyTalk。
  12 +- WebScreen 只生成本地文件;服务器已有任务定时移动到 OSS。
  13 +- 原定时任务每天 `07:57` 调用 `GET /recording`。
  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` 才进入高光录制。因此旧服务器、旧站点和旧客户不受影响。
  29 +
  30 +以下接口保持原行为:
  31 +
  32 +- `POST /recording`
  33 +- `POST /recordingTask`
  34 +- `POST /fileExists`
  35 +- `POST /mp4record/recording/:id`
  36 +
  37 +高光文件查询使用独立接口 `POST /highlight/fileExists`,不改变原 `/fileExists` 单文件响应。
  38 +
  39 +## 3. 定时触发
  40 +
  41 +WebScreen 本身不创建 cron。服务器已有 cron:
  42 +
  43 +```cron
  44 +57 7 * * * wget http://127.0.0.1:3001/recording &
  45 +```
  46 +
  47 +建议部署时改成不落 wget 临时文件的等价写法:
  48 +
  49 +```cron
  50 +57 7 * * * wget -qO- http://127.0.0.1:3001/recording >/dev/null 2>&1
  51 +```
  52 +
  53 +`GET /recording` 读取 `HIGHLIGHTCONFIG.sourceMode`:
  54 +
  55 +- `site`:全量高光模式。
  56 +- `task`:指定课堂任务模式。
  57 +
  58 +这里“全量”表示录制指定站点前一天的全部高光,不表示录制整堂课堂。
  59 +
  60 +## 4. 配置
  61 +
  62 +当前 xdyui2 测试配置:
  63 +
  64 +```json
  65 +{
  66 + "HIGHLIGHTCONFIG": {
  67 + "enabled": true,
  68 + "sourceMode": "site",
  69 + "siteIds": ["xdyui2"],
  70 + "apiBaseUrl": "https://saas.xuedianyun.com",
  71 + "pageSize": 100,
  72 + "taskPageSize": 100,
  73 + "maxPages": 1000,
  74 + "maxConcurrent": 2,
  75 + "maxDurationMs": 21600000,
  76 + "apiTimeoutMs": 10000,
  77 + "apiRetryCount": 2,
  78 + "apiRetryBaseDelayMs": 500,
  79 + "loadGraceMs": 60000,
  80 + "endGraceMs": 10000,
  81 + "taskRetentionMs": 86400000,
  82 + "outputNamespace": "",
  83 + "outputBaseUrl": "https://xdymp4.xuedianyun.com"
  84 + }
  85 +}
  86 +```
  87 +
  88 +`BACKMEDIACONFIG.url` 继续由服务器配置决定,代码不硬编码 `dev`、`devback` 或 `release`。
  89 +
  90 +## 5. 全量高光模式
  91 +
  92 +配置:
  93 +
  94 +```json
  95 +"sourceMode": "site"
  96 +```
  97 +
  98 +流程:
  99 +
  100 +1. cron 调用 `GET /recording`。
  101 +2. 按 Asia/Shanghai 计算前一天 `00:00:00.000` 至 `23:59:59.999`。
  102 +3. 遍历 `HIGHLIGHTCONFIG.siteIds`。
  103 +4. 分页调用 `getBySitePrivate.do`。
  104 +5. 每条有效高光加入录制队列。
  105 +6. 一个时间段生成一个本地 MP4。
  106 +
  107 +接口:
  108 +
  109 +```http
  110 +POST /3m/api/highlight/getBySitePrivate.do
  111 +Content-Type: application/x-www-form-urlencoded
  112 +```
  113 +
  114 +签名:
  115 +
  116 +```text
  117 +authId = MD5(siteId + timestamp)
  118 +```
  119 +
  120 +## 6. 指定课堂任务模式
  121 +
  122 +配置:
  123 +
  124 +```json
  125 +"sourceMode": "task"
  126 +```
  127 +
  128 +流程:
  129 +
  130 +1. 分页调用 `getRecordingTasksPrivate.do`。
  131 +2. 只保留 `status=0 && onlyHighlight=1`。
  132 +3. 只保留 `siteIds` 白名单内的任务。
  133 +4. `taskList.meetingNumber` 作为 `classId`。
  134 +5. 调用 `getByClassPrivate.do` 获取该课堂全部高光。
  135 +6. 每条高光加入录制队列。
  136 +
  137 +任务接口签名:
  138 +
  139 +```text
  140 +authId = MD5(pageNo + pageSize + timestamp)
  141 +```
  142 +
  143 +课堂高光接口签名:
  144 +
  145 +```text
  146 +authId = MD5(classId + timestamp)
  147 +```
  148 +
  149 +代码必须保留:
  150 +
  151 +```js
  152 +// TODO: 等后端明确录制任务状态流转及完成回写接口。
  153 +```
  154 +
  155 +任务状态回写接口确定前,使用内存队列、本地文件和 OSS 文件共同避免重复录制。
  156 +
  157 +## 7. 字段映射
  158 +
  159 +SaaS 高光记录:
  160 +
  161 +```json
  162 +{
  163 + "id": 4,
  164 + "meetingNumber": "1486758620",
  165 + "siteId": "xdyui2",
  166 + "beginTime": 1779159793000,
  167 + "endTime": 1779159893000
  168 +}
  169 +```
  170 +
  171 +WebScreen 内部统一为:
  172 +
  173 +```text
  174 +highlightId = id
  175 +classId = meetingNumber
  176 +```
  177 +
  178 +校验:
  179 +
  180 +- `id` 为正整数。
  181 +- `meetingNumber`、`siteId` 只包含安全字符。
  182 +- 时间为 13 位毫秒时间戳。
  183 +- `endTime > beginTime`。
  184 +- 时长不超过 `maxDurationMs`。
  185 +- 站点必须属于 `HIGHLIGHTCONFIG.siteIds`。
  186 +
  187 +唯一键:
  188 +
  189 +```text
  190 +siteId:highlightId
  191 +```
  192 +
  193 +## 8. 回放录制地址
  194 +
  195 +使用 `URLSearchParams` 在现有 `BACKMEDIACONFIG.url` 上增加:
  196 +
  197 +```text
  198 +classId={meetingNumber}
  199 +recordMp4=true
  200 +playRecord=1
  201 +recBeginTime={beginTime}
  202 +recEndTime={endTime}
  203 +```
  204 +
  205 +`recBeginTime` 和 `recEndTime` 都是绝对毫秒时间戳,不换算为相对秒数。
  206 +
  207 +调用 `web_capture_c` 时使用参数数组和 `spawn(..., { shell: false })`,不把接口字段拼接进 shell 命令。
  208 +
  209 +## 9. 文件与上传
  210 +
  211 +文件名:
  212 +
  213 +```text
  214 +{classId}_highlight_{highlightId}.mp4
  215 +```
  216 +
  217 +路径:
  218 +
  219 +```text
  220 +本地:media/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
  221 +OSS: oss/{siteId}/{yyyyMMdd}/{classId}_highlight_{highlightId}.mp4
  222 +URL: https://xdymp4.xuedianyun.com/oss/{siteId}/{yyyyMMdd}/{fileName}
  223 +```
  224 +
  225 +日期按高光 `beginTime` 的 Asia/Shanghai 日期计算。
  226 +
  227 +录制过程先写入 `PROJECTCATALOG/.highlight_tmp`,完成后原子移动到 `media`,避免 OSS 搬运程序读取半成品。队列完成后在涉及的日期目录写入 `download.json`,兼容原搬运机制。
  228 +
  229 +WebScreen 不主动上传 OSS。
  230 +
  231 +## 10. 队列和去重
  232 +
  233 +- 全局并发由 `maxConcurrent` 控制。
  234 +- 同一课堂高光串行,避免同时加载同一课堂回放。
  235 +- 不同课堂可以并行。
  236 +- 队列中已有相同 `siteId:highlightId` 时不重复加入。
  237 +- 本地最终文件存在时不重复录制。
  238 +- OSS 文件存在时不重复录制。
  239 +- OSS 查询异常时停止本轮处理,不能把查询失败当作文件不存在。
  240 +
  241 +## 11. 高光多文件查询
  242 +
  243 +```http
  244 +POST /highlight/fileExists
  245 +Content-Type: application/json
  246 +```
  247 +
  248 +请求:
  249 +
  250 +```json
  251 +{
  252 + "siteId": "xdyui2",
  253 + "classId": "1486758620"
  254 +}
  255 +```
  256 +
  257 +WebScreen 调用 `getByClassPrivate.do` 获取该课堂全部高光,再逐条检查本地任务状态和 OSS。
  258 +
  259 +响应:
  260 +
  261 +```json
  262 +{
  263 + "code": 0,
  264 + "message": "文件已生成",
  265 + "onlyHighlight": 1,
  266 + "classUrlList": [
  267 + {
  268 + "highlightId": 4,
  269 + "classId": "1486758620",
  270 + "siteId": "xdyui2",
  271 + "beginTime": 1779159793000,
  272 + "generated": true,
  273 + "status": "generated",
  274 + "url": "https://xdymp4.xuedianyun.com/oss/xdyui2/20260519/1486758620_highlight_4.mp4"
  275 + }
  276 + ]
  277 +}
  278 +```
  279 +
  280 +全部生成时 `code=0`;无文件或存在未生成文件时 `code=1`,但仍返回每条记录状态。
  281 +
  282 +## 12. 验收标准
  283 +
  284 +1. `xdyui2` 一个课堂有 N 条高光时生成 N 个不同 MP4。
  285 +2. 每个录制 URL 包含正确的 `recBeginTime/recEndTime`。
  286 +3. 重复执行 cron 不重复录制本地或 OSS 已存在文件。
  287 +4. `sourceMode=site` 只查询前一天 xdyui2 高光。
  288 +5. `sourceMode=task` 只处理 `status=0 && onlyHighlight=1`。
  289 +6. `/highlight/fileExists` 返回课堂全部高光文件状态和地址。
  290 +7. 原 `/fileExists` 响应不变。
  291 +8. 删除或关闭 `HIGHLIGHTCONFIG` 后,`GET /recording` 恢复原整课录制。
  292 +
  293 +## 13. 已知待办
  294 +
  295 +- 后端确认任务领取、成功、失败状态及回写接口。
  296 +- xdyui2 验证通过后再增加 CrazyTalk 的准确 `siteId`。
  297 +- 正式部署前确认服务器 OSS 搬运任务会处理新文件名和 `download.json`。
@@ -6,6 +6,7 @@ var logger = require('morgan'); @@ -6,6 +6,7 @@ var logger = require('morgan');
6 6
7 var indexRouter = require('./routes/index'); 7 var indexRouter = require('./routes/index');
8 var bbgRecordingRouter = require('./routes/bbgRecording'); 8 var bbgRecordingRouter = require('./routes/bbgRecording');
  9 +var highlightRecordingRouter = require('./routes/highlightRecording');
9 10
10 var app = express(); 11 var app = express();
11 12
@@ -31,6 +32,7 @@ app.all("*",function(req,res,next){ @@ -31,6 +32,7 @@ app.all("*",function(req,res,next){
31 32
32 app.use('/', indexRouter); 33 app.use('/', indexRouter);
33 app.use('/mp4record',bbgRecordingRouter); 34 app.use('/mp4record',bbgRecordingRouter);
  35 +app.use('/highlight', highlightRecordingRouter);
34 36
35 // catch 404 and forward to error handler 37 // catch 404 and forward to error handler
36 app.use(function(req, res, next) { 38 app.use(function(req, res, next) {
@@ -27,5 +27,24 @@ @@ -27,5 +27,24 @@
27 "w":1280, 27 "w":1280,
28 "h":720 28 "h":720
29 }, 29 },
  30 + "HIGHLIGHTCONFIG": {
  31 + "enabled": false,
  32 + "sourceMode": "site",
  33 + "siteIds": ["xdyui2"],
  34 + "apiBaseUrl": "https://saas.xuedianyun.com",
  35 + "pageSize": 100,
  36 + "taskPageSize": 100,
  37 + "maxPages": 1000,
  38 + "maxConcurrent": 2,
  39 + "maxDurationMs": 21600000,
  40 + "apiTimeoutMs": 10000,
  41 + "apiRetryCount": 2,
  42 + "apiRetryBaseDelayMs": 500,
  43 + "loadGraceMs": 60000,
  44 + "endGraceMs": 10000,
  45 + "taskRetentionMs": 86400000,
  46 + "outputNamespace": "",
  47 + "outputBaseUrl": "https://xdymp4.xuedianyun.com"
  48 + },
30 "classLastNumber":["0","1","2","3","4","5","6","7","8","9"] 49 "classLastNumber":["0","1","2","3","4","5","6","7","8","9"]
31 } 50 }
  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 +```
@@ -4,7 +4,8 @@ @@ -4,7 +4,8 @@
4 "private": true, 4 "private": true,
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 }, 9 },
9 "dependencies": { 10 "dependencies": {
10 "ali-oss": "^6.22.0", 11 "ali-oss": "^6.22.0",
  1 +const express = require('express');
  2 +const {
  3 + HighlightUpstreamError,
  4 + HighlightValidationError,
  5 + highlightRecordingService: service
  6 +} = require('../services/highlightRecordingService');
  7 +
  8 +const router = express.Router();
  9 +
  10 +function sendError(res, error) {
  11 + if (error instanceof HighlightValidationError) {
  12 + return res.status(400).send({ code: 10, message: error.message });
  13 + }
  14 + if (error instanceof HighlightUpstreamError) {
  15 + return res.status(502).send({
  16 + code: error.upstreamCode,
  17 + message: error.message
  18 + });
  19 + }
  20 + console.error('高光录制接口异常:', error);
  21 + return res.status(500).send({ code: -1, message: '服务器内部错误' });
  22 +}
  23 +
  24 +router.post('/preview/by-class', async function (req, res) {
  25 + try {
  26 + const records = await service.fetchByClass(req.body && req.body.classId);
  27 + const data = await service.preview(records);
  28 + return res.send({ code: 0, message: records.length ? 'success' : '无高光数据', data });
  29 + } catch (error) {
  30 + return sendError(res, error);
  31 + }
  32 +});
  33 +
  34 +router.post('/preview/by-site', async function (req, res) {
  35 + try {
  36 + const records = await service.fetchBySite(req.body || {});
  37 + const data = await service.preview(records);
  38 + return res.send({ code: 0, message: records.length ? 'success' : '无高光数据', data });
  39 + } catch (error) {
  40 + return sendError(res, error);
  41 + }
  42 +});
  43 +
  44 +router.post('/recording/by-class', async function (req, res) {
  45 + try {
  46 + const records = await service.fetchByClass(req.body && req.body.classId);
  47 + if (records.length === 0) {
  48 + return res.send({ code: 0, message: '无高光数据', data: [] });
  49 + }
  50 + const data = await service.enqueue(records);
  51 + return res.send({ code: 0, message: 'success', data });
  52 + } catch (error) {
  53 + return sendError(res, error);
  54 + }
  55 +});
  56 +
  57 +router.post('/recording/by-site', async function (req, res) {
  58 + try {
  59 + const records = await service.fetchBySite(req.body || {});
  60 + if (records.length === 0) {
  61 + return res.send({
  62 + code: 0,
  63 + message: '无高光数据',
  64 + data: { received: 0, queued: 0, recording: 0, uploading: 0, generated: 0, invalid: 0, failed: 0 }
  65 + });
  66 + }
  67 + const tasks = await service.enqueue(records);
  68 + const summary = service.summarizeTasks(tasks);
  69 + return res.send({ code: 0, message: 'success', data: summary });
  70 + } catch (error) {
  71 + return sendError(res, error);
  72 + }
  73 +});
  74 +
  75 +router.post('/fileExists', async function (req, res) {
  76 + try {
  77 + const data = await service.getClassFileStatuses(req.body || {});
  78 + const allGenerated = data.length > 0 && data.every(item => item.generated);
  79 + return res.send({
  80 + code: allGenerated ? 0 : 1,
  81 + message: allGenerated ? '文件已生成' : (data.length ? '部分文件未生成' : '文件未生成'),
  82 + onlyHighlight: 1,
  83 + classUrlList: data
  84 + });
  85 + } catch (error) {
  86 + return sendError(res, error);
  87 + }
  88 +});
  89 +
  90 +router.post('/files', async function (req, res) {
  91 + try {
  92 + const items = req.body && req.body.items;
  93 + if (!Array.isArray(items) || items.length === 0) {
  94 + throw new HighlightValidationError('items 必须是非空数组');
  95 + }
  96 + if (items.length > 1000) {
  97 + throw new HighlightValidationError('items 不能超过1000条');
  98 + }
  99 + const data = await service.getFileStatuses(items);
  100 + return res.send({ code: 0, message: 'success', data });
  101 + } catch (error) {
  102 + return sendError(res, error);
  103 + }
  104 +});
  105 +
  106 +router.get('/status', function (req, res) {
  107 + res.send({ code: 0, message: 'success', data: service.getQueueInfo() });
  108 +});
  109 +
  110 +module.exports = router;
@@ -8,6 +8,11 @@ require('dotenv').config(); // 加载环境变量 @@ -8,6 +8,11 @@ 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 + highlightRecordingService,
  13 + HighlightValidationError,
  14 + HighlightUpstreamError
  15 +} = require('../services/highlightRecordingService');
11 const version ='v1.2.0.20251208'; 16 const version ='v1.2.0.20251208';
12 // const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config 17 // const { GETCLASSURL, GETCLASSURLPARAMETER, PROJECTCATALOG, PROJECTWINCATALOG, BACKMEDIACONFIG } = config
13 const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method 18 const { YesterdayTime,getDayTime, getRequestClassIds, dayTimeYMD } = method
@@ -213,7 +218,25 @@ router.get('/recording', async function (req, res, next) { @@ -213,7 +218,25 @@ router.get('/recording', async function (req, res, next) {
213 let fileConfig = new MediaCreat().getConfigFileJson() 218 let fileConfig = new MediaCreat().getConfigFileJson()
214 if (!fileConfig) return false 219 if (!fileConfig) return false
215 220
216 - const { GETCLASSURLPARAMETER } = JSON.parse(fileConfig) 221 + const parsedConfig = JSON.parse(fileConfig)
  222 + if (parsedConfig.HIGHLIGHTCONFIG && parsedConfig.HIGHLIGHTCONFIG.enabled === true) {
  223 + try {
  224 + const data = await highlightRecordingService.runScheduledRecording()
  225 + res.send({ code: "0", message: data.received ? "success" : "无高光数据", data })
  226 + } catch (error) {
  227 + new MediaCreat().wrieLog("高光录制任务读取失败:------>" + (error.message || error))
  228 + if (error instanceof HighlightValidationError) {
  229 + res.status(400).send({ code: "1", message: error.message })
  230 + } else if (error instanceof HighlightUpstreamError) {
  231 + res.status(502).send({ code: String(error.upstreamCode), message: error.message })
  232 + } else {
  233 + res.status(500).send({ code: "-1", message: "高光录制任务读取失败" })
  234 + }
  235 + }
  236 + return
  237 + }
  238 +
  239 + const { GETCLASSURLPARAMETER } = parsedConfig
217 siteIds = GETCLASSURLPARAMETER.siteId 240 siteIds = GETCLASSURLPARAMETER.siteId
218 if (siteIds.length == 0) { 241 if (siteIds.length == 0) {
219 new MediaCreat().wrieLog("脚本录制,未配置siteId:------>" + siteIds) 242 new MediaCreat().wrieLog("脚本录制,未配置siteId:------>" + siteIds)
  1 +const axios = require('axios');
  2 +const crypto = require('crypto');
  3 +const fs = require('fs');
  4 +const path = require('path');
  5 +const querystring = require('querystring');
  6 +const OSS = require('ali-oss');
  7 +const { spawn } = require('child_process');
  8 +require('dotenv').config();
  9 +
  10 +const DEFAULT_CONFIG = {
  11 + enabled: false,
  12 + sourceMode: 'site',
  13 + siteIds: [],
  14 + apiBaseUrl: 'https://saas.xuedianyun.com',
  15 + pageSize: 100,
  16 + taskPageSize: 100,
  17 + maxPages: 1000,
  18 + maxConcurrent: 2,
  19 + maxDurationMs: 6 * 60 * 60 * 1000,
  20 + apiTimeoutMs: 10000,
  21 + apiRetryCount: 2,
  22 + apiRetryBaseDelayMs: 500,
  23 + loadGraceMs: 60000,
  24 + endGraceMs: 10000,
  25 + taskRetentionMs: 24 * 60 * 60 * 1000,
  26 + outputNamespace: '',
  27 + outputBaseUrl: 'https://xdymp4.xuedianyun.com'
  28 +};
  29 +
  30 +class HighlightValidationError extends Error {
  31 + constructor(message) {
  32 + super(message);
  33 + this.name = 'HighlightValidationError';
  34 + }
  35 +}
  36 +
  37 +class HighlightUpstreamError extends Error {
  38 + constructor(code, message) {
  39 + super(message);
  40 + this.name = 'HighlightUpstreamError';
  41 + this.upstreamCode = code;
  42 + }
  43 +}
  44 +
  45 +function md5(value) {
  46 + return crypto.createHash('md5').update(String(value), 'utf8').digest('hex');
  47 +}
  48 +
  49 +function delay(ms) {
  50 + return new Promise(resolve => setTimeout(resolve, ms));
  51 +}
  52 +
  53 +function formatShanghaiDate(timestamp) {
  54 + const date = new Date(Number(timestamp) + 8 * 60 * 60 * 1000);
  55 + const year = date.getUTCFullYear();
  56 + const month = String(date.getUTCMonth() + 1).padStart(2, '0');
  57 + const day = String(date.getUTCDate()).padStart(2, '0');
  58 + return `${year}${month}${day}`;
  59 +}
  60 +
  61 +function getPreviousShanghaiDayRange(now) {
  62 + const shanghaiNow = new Date(Number(now == null ? Date.now() : now) + 8 * 60 * 60 * 1000);
  63 + const todayStartUtc = Date.UTC(
  64 + shanghaiNow.getUTCFullYear(),
  65 + shanghaiNow.getUTCMonth(),
  66 + shanghaiNow.getUTCDate()
  67 + ) - 8 * 60 * 60 * 1000;
  68 + const beginTime = todayStartUtc - 24 * 60 * 60 * 1000;
  69 + return { beginTime, endTime: todayStartUtc - 1 };
  70 +}
  71 +
  72 +function isSafeIdentifier(value) {
  73 + return /^[A-Za-z0-9_-]{1,128}$/.test(String(value || ''));
  74 +}
  75 +
  76 +function isEpochMilliseconds(value) {
  77 + return Number.isSafeInteger(value) && String(value).length === 13;
  78 +}
  79 +
  80 +function normalizeHighlight(raw, maxDurationMs) {
  81 + const item = raw || {};
  82 + const highlightId = Number(item.highlightId != null ? item.highlightId : item.id);
  83 + const classId = String(item.classId != null ? item.classId : item.meetingNumber || '');
  84 + const siteId = String(item.siteId || '');
  85 + const beginTime = Number(item.beginTime);
  86 + const endTime = Number(item.endTime);
  87 +
  88 + if (!Number.isSafeInteger(highlightId) || highlightId <= 0) {
  89 + throw new HighlightValidationError('highlightId 无效');
  90 + }
  91 + if (!isSafeIdentifier(classId)) {
  92 + throw new HighlightValidationError('classId 无效');
  93 + }
  94 + if (!isSafeIdentifier(siteId)) {
  95 + throw new HighlightValidationError('siteId 无效');
  96 + }
  97 + if (!isEpochMilliseconds(beginTime) || !isEpochMilliseconds(endTime)) {
  98 + throw new HighlightValidationError('beginTime/endTime 必须是13位毫秒时间戳');
  99 + }
  100 + if (endTime <= beginTime) {
  101 + throw new HighlightValidationError('endTime 必须大于 beginTime');
  102 + }
  103 + if (endTime - beginTime > maxDurationMs) {
  104 + throw new HighlightValidationError('高光时长超过限制');
  105 + }
  106 +
  107 + return {
  108 + highlightId,
  109 + classId,
  110 + siteId,
  111 + beginTime,
  112 + endTime,
  113 + type: item.type,
  114 + more: item.more == null ? '' : item.more,
  115 + userId: item.userId == null ? '' : item.userId,
  116 + userName: item.userName == null ? '' : item.userName,
  117 + userRole: item.userRole
  118 + };
  119 +}
  120 +
  121 +function normalizeFileLookup(raw) {
  122 + const item = raw || {};
  123 + const highlightId = Number(item.highlightId != null ? item.highlightId : item.id);
  124 + const classId = String(item.classId != null ? item.classId : item.meetingNumber || '');
  125 + const siteId = String(item.siteId || '');
  126 + const beginTime = Number(item.beginTime);
  127 +
  128 + if (!Number.isSafeInteger(highlightId) || highlightId <= 0) {
  129 + throw new HighlightValidationError('highlightId 无效');
  130 + }
  131 + if (!isSafeIdentifier(classId)) {
  132 + throw new HighlightValidationError('classId 无效');
  133 + }
  134 + if (!isSafeIdentifier(siteId)) {
  135 + throw new HighlightValidationError('siteId 无效');
  136 + }
  137 + if (!isEpochMilliseconds(beginTime)) {
  138 + throw new HighlightValidationError('beginTime 必须是13位毫秒时间戳');
  139 + }
  140 +
  141 + return { highlightId, classId, siteId, beginTime };
  142 +}
  143 +
  144 +function buildTaskKey(item) {
  145 + return `${item.siteId}:${item.highlightId}`;
  146 +}
  147 +
  148 +function normalizeOutputNamespace(value) {
  149 + const namespace = String(value || '').trim();
  150 + if (namespace && !/^[A-Za-z0-9_-]{1,128}$/.test(namespace)) {
  151 + throw new HighlightValidationError('outputNamespace 只能包含字母、数字、下划线和短横线');
  152 + }
  153 + return namespace;
  154 +}
  155 +
  156 +function buildFileInfo(item, projectCatalog, outputBaseUrl, outputNamespace) {
  157 + const date = formatShanghaiDate(item.beginTime);
  158 + const fileName = `${item.classId}_highlight_${item.highlightId}.mp4`;
  159 + const namespace = normalizeOutputNamespace(outputNamespace);
  160 + const localParts = [projectCatalog, 'media'];
  161 + const ossParts = ['oss'];
  162 + if (namespace) {
  163 + localParts.push(namespace);
  164 + ossParts.push(namespace);
  165 + }
  166 + localParts.push(item.siteId, date);
  167 + ossParts.push(item.siteId, date, fileName);
  168 + const localDir = path.join(...localParts);
  169 + const localPath = path.join(localDir, fileName);
  170 + const tempDir = path.join(projectCatalog, '.highlight_tmp', ...localParts.slice(2));
  171 + const tempPath = path.join(tempDir, fileName);
  172 + const ossKey = ossParts.join('/');
  173 + return {
  174 + namespace,
  175 + date,
  176 + fileName,
  177 + localDir,
  178 + localPath,
  179 + tempDir,
  180 + tempPath,
  181 + ossKey,
  182 + url: `${String(outputBaseUrl).replace(/\/$/, '')}/${ossKey}`
  183 + };
  184 +}
  185 +
  186 +class HighlightRecordingService {
  187 + constructor(options) {
  188 + const opts = options || {};
  189 + this.configPath = opts.configPath || path.join(process.cwd(), 'config', 'config.json');
  190 + this.queue = [];
  191 + this.tasks = new Map();
  192 + this.activeClassIds = new Set();
  193 + this.activeCount = 0;
  194 + this.scheduledRunActive = false;
  195 + this.completedDirs = new Set();
  196 + this.ossClient = null;
  197 + this.ossClientInitialized = false;
  198 + }
  199 +
  200 + readConfig() {
  201 + const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
  202 + const highlightConfig = Object.assign({}, DEFAULT_CONFIG, raw.HIGHLIGHTCONFIG || {});
  203 + return {
  204 + projectCatalog: raw.PROJECTCATALOG,
  205 + captureCatalog: raw.PROJECTWINCATALOG,
  206 + playback: raw.BACKMEDIACONFIG || {},
  207 + highlight: highlightConfig
  208 + };
  209 + }
  210 +
  211 + writeLog(message) {
  212 + const now = new Date();
  213 + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
  214 + const logDir = path.join(process.cwd(), 'log');
  215 + fs.mkdirSync(logDir, { recursive: true });
  216 + fs.appendFileSync(path.join(logDir, `${ymd}.txt`), `${now.toLocaleString()} 高光录制 ${message}\r\n`);
  217 + }
  218 +
  219 + cleanupTasks() {
  220 + const retentionMs = Number(this.readConfig().highlight.taskRetentionMs);
  221 + if (!Number.isFinite(retentionMs) || retentionMs <= 0) return;
  222 + const cutoff = Date.now() - retentionMs;
  223 + for (const [key, task] of this.tasks.entries()) {
  224 + if (!['queued', 'recording'].includes(task.status) && task.updatedAt < cutoff) {
  225 + this.tasks.delete(key);
  226 + }
  227 + }
  228 + }
  229 +
  230 + ensureFeatureEnabled() {
  231 + if (!this.readConfig().highlight.enabled) {
  232 + throw new HighlightValidationError('高光录制功能未启用');
  233 + }
  234 + }
  235 +
  236 + getAllowedSiteIds() {
  237 + const siteIds = this.readConfig().highlight.siteIds;
  238 + if (!Array.isArray(siteIds)) {
  239 + throw new HighlightValidationError('HIGHLIGHTCONFIG.siteIds 必须是数组');
  240 + }
  241 + const normalized = siteIds.map(siteId => String(siteId || '')).filter(Boolean);
  242 + for (const siteId of normalized) {
  243 + if (!isSafeIdentifier(siteId)) {
  244 + throw new HighlightValidationError(`高光站点ID无效: ${siteId}`);
  245 + }
  246 + }
  247 + return normalized;
  248 + }
  249 +
  250 + isSiteAllowed(siteId) {
  251 + return this.getAllowedSiteIds().includes(String(siteId || ''));
  252 + }
  253 +
  254 + async requestHighlights(endpoint, authValue, fields) {
  255 + const config = this.readConfig().highlight;
  256 + const url = new URL(endpoint, `${String(config.apiBaseUrl).replace(/\/$/, '')}/`).toString();
  257 + let response;
  258 + let lastError;
  259 + for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) {
  260 + const timestamp = String(Date.now());
  261 + const body = Object.assign({}, fields, {
  262 + timestamp,
  263 + authId: md5(String(authValue) + timestamp)
  264 + });
  265 + try {
  266 + response = await axios.post(url, querystring.stringify(body), {
  267 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  268 + timeout: config.apiTimeoutMs
  269 + });
  270 + break;
  271 + } catch (error) {
  272 + lastError = error;
  273 + const status = error && error.response && error.response.status;
  274 + const retryable = !status || status >= 500;
  275 + if (!retryable || attempt >= config.apiRetryCount) {
  276 + const upstreamError = new HighlightUpstreamError(-1, `高光接口请求失败${status ? ` HTTP ${status}` : ''}`);
  277 + upstreamError.cause = error;
  278 + throw upstreamError;
  279 + }
  280 + await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt));
  281 + }
  282 + }
  283 + if (!response) {
  284 + const upstreamError = new HighlightUpstreamError(-1, '高光接口请求失败');
  285 + upstreamError.cause = lastError;
  286 + throw upstreamError;
  287 + }
  288 + const result = response.data || {};
  289 + const code = Number(result.code);
  290 + if (code !== 0) {
  291 + throw new HighlightUpstreamError(code, `高光接口返回错误 code=${result.code}`);
  292 + }
  293 + if (!Array.isArray(result.data)) {
  294 + throw new HighlightUpstreamError(10, '高光接口 data 不是数组');
  295 + }
  296 + return result;
  297 + }
  298 +
  299 + async fetchByClass(classId) {
  300 + this.ensureFeatureEnabled();
  301 + const normalizedClassId = String(classId || '');
  302 + if (!isSafeIdentifier(normalizedClassId)) {
  303 + throw new HighlightValidationError('classId 无效');
  304 + }
  305 + const result = await this.requestHighlights(
  306 + '/3m/api/highlight/getByClassPrivate.do',
  307 + normalizedClassId,
  308 + { classId: normalizedClassId }
  309 + );
  310 + return result.data;
  311 + }
  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) {
  375 + this.ensureFeatureEnabled();
  376 + const input = params || {};
  377 + const siteId = String(input.siteId || '');
  378 + if (!isSafeIdentifier(siteId)) {
  379 + throw new HighlightValidationError('siteId 无效');
  380 + }
  381 +
  382 + const config = this.readConfig().highlight;
  383 + const pageSize = Number(input.pageSize || config.pageSize);
  384 + if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 1000) {
  385 + throw new HighlightValidationError('pageSize 无效');
  386 + }
  387 +
  388 + const beginTime = input.beginTime == null || input.beginTime === '' ? null : Number(input.beginTime);
  389 + const endTime = input.endTime == null || input.endTime === '' ? null : Number(input.endTime);
  390 + if (beginTime != null && !isEpochMilliseconds(beginTime)) {
  391 + throw new HighlightValidationError('beginTime 必须是13位毫秒时间戳');
  392 + }
  393 + if (endTime != null && !isEpochMilliseconds(endTime)) {
  394 + throw new HighlightValidationError('endTime 必须是13位毫秒时间戳');
  395 + }
  396 + if (beginTime != null && endTime != null && endTime <= beginTime) {
  397 + throw new HighlightValidationError('endTime 必须大于 beginTime');
  398 + }
  399 +
  400 + const records = [];
  401 + const seen = new Set();
  402 + let pageNo = 1;
  403 + while (pageNo <= config.maxPages) {
  404 + const fields = { siteId, pageNo, pageSize };
  405 + if (beginTime != null) fields.beginTime = beginTime;
  406 + if (endTime != null) fields.endTime = endTime;
  407 + const result = await this.requestHighlights(
  408 + '/3m/api/highlight/getBySitePrivate.do',
  409 + siteId,
  410 + fields
  411 + );
  412 + for (const item of result.data) {
  413 + const dedupeKey = `${item.siteId || siteId}:${item.id}:${item.meetingNumber}:${item.beginTime}:${item.endTime}`;
  414 + if (!seen.has(dedupeKey)) {
  415 + seen.add(dedupeKey);
  416 + records.push(item);
  417 + }
  418 + }
  419 +
  420 + const count = Number(result.count);
  421 + const responsePageSize = Number(result.pageSize) || pageSize;
  422 + const responsePageNo = Number(result.pageNo) || pageNo;
  423 + if (result.data.length === 0 || !Number.isFinite(count) || responsePageNo * responsePageSize >= count) {
  424 + break;
  425 + }
  426 + pageNo += 1;
  427 + }
  428 +
  429 + if (pageNo > config.maxPages) {
  430 + throw new HighlightUpstreamError(2, '高光接口分页超过安全上限');
  431 + }
  432 + return records;
  433 + }
  434 +
  435 + getOssClient() {
  436 + if (this.ossClientInitialized) return this.ossClient;
  437 + this.ossClientInitialized = true;
  438 + if (!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID || !process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET) {
  439 + return null;
  440 + }
  441 + this.ossClient = new OSS({
  442 + region: 'oss-cn-beijing',
  443 + accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
  444 + accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
  445 + authorizationV4: true,
  446 + bucket: 'xdymp4'
  447 + });
  448 + return this.ossClient;
  449 + }
  450 +
  451 + localFileExists(filePath) {
  452 + try {
  453 + return fs.statSync(filePath).size > 0;
  454 + } catch (error) {
  455 + return false;
  456 + }
  457 + }
  458 +
  459 + async inspectFile(fileInfo) {
  460 + const local = this.localFileExists(fileInfo.localPath);
  461 + const client = this.getOssClient();
  462 + if (!client) {
  463 + return { local, oss: null, generated: false };
  464 + }
  465 + try {
  466 + await client.head(fileInfo.ossKey);
  467 + return { local, oss: true, generated: true };
  468 + } catch (error) {
  469 + const status = error && (error.status || (error.res && error.res.status));
  470 + if (status !== 404 && error && error.code !== 'NoSuchKey') {
  471 + this.writeLog(`OSS查询失败 key=${fileInfo.ossKey} error=${error.message || error}`);
  472 + throw new HighlightUpstreamError(-1, 'OSS文件状态查询失败');
  473 + }
  474 + return { local, oss: false, generated: false };
  475 + }
  476 + }
  477 +
  478 + snapshot(task) {
  479 + const result = {
  480 + highlightId: task.item.highlightId,
  481 + classId: task.item.classId,
  482 + siteId: task.item.siteId,
  483 + beginTime: task.item.beginTime,
  484 + endTime: task.item.endTime,
  485 + status: task.status
  486 + };
  487 + if (task.status === 'generated') result.url = task.fileInfo.url;
  488 + if (task.error) result.error = task.error;
  489 + return result;
  490 + }
  491 +
  492 + async preview(records) {
  493 + const config = this.readConfig();
  494 + const allowedSiteIds = new Set(this.getAllowedSiteIds());
  495 + const output = [];
  496 + for (const raw of records || []) {
  497 + let item;
  498 + try {
  499 + item = normalizeHighlight(raw, config.highlight.maxDurationMs);
  500 + if (!allowedSiteIds.has(item.siteId)) {
  501 + throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
  502 + }
  503 + const fileInfo = buildFileInfo(
  504 + item,
  505 + config.projectCatalog,
  506 + config.highlight.outputBaseUrl,
  507 + config.highlight.outputNamespace
  508 + );
  509 + const fileState = await this.inspectFile(fileInfo);
  510 + output.push({
  511 + highlightId: item.highlightId,
  512 + classId: item.classId,
  513 + siteId: item.siteId,
  514 + beginTime: item.beginTime,
  515 + endTime: item.endTime,
  516 + duration: item.endTime - item.beginTime,
  517 + status: fileState.generated ? 'generated' : (fileState.local ? 'uploading' : 'not_generated'),
  518 + fileName: fileInfo.fileName,
  519 + localPath: fileInfo.localPath,
  520 + ossKey: fileInfo.ossKey,
  521 + url: fileState.generated ? fileInfo.url : null,
  522 + playbackUrl: this.buildPlaybackUrl(item, config.playback)
  523 + });
  524 + } catch (error) {
  525 + output.push({
  526 + highlightId: raw && (raw.id || raw.highlightId),
  527 + classId: raw && (raw.meetingNumber || raw.classId),
  528 + status: 'invalid',
  529 + error: error.message
  530 + });
  531 + }
  532 + }
  533 + return output;
  534 + }
  535 +
  536 + async enqueue(records) {
  537 + this.cleanupTasks();
  538 + const config = this.readConfig();
  539 + const allowedSiteIds = new Set(this.getAllowedSiteIds());
  540 + const output = [];
  541 + for (const raw of records || []) {
  542 + let item;
  543 + try {
  544 + item = normalizeHighlight(raw, config.highlight.maxDurationMs);
  545 + if (!allowedSiteIds.has(item.siteId)) {
  546 + throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
  547 + }
  548 + } catch (error) {
  549 + output.push({
  550 + highlightId: raw && (raw.id || raw.highlightId),
  551 + classId: raw && (raw.meetingNumber || raw.classId),
  552 + status: 'invalid',
  553 + error: error.message
  554 + });
  555 + continue;
  556 + }
  557 +
  558 + const key = buildTaskKey(item);
  559 + const existing = this.tasks.get(key);
  560 + if (existing && ['queued', 'recording', 'uploading', 'generated'].includes(existing.status)) {
  561 + if (existing.item.classId !== item.classId || existing.item.beginTime !== item.beginTime || existing.item.endTime !== item.endTime) {
  562 + output.push({
  563 + highlightId: item.highlightId,
  564 + classId: item.classId,
  565 + status: 'invalid',
  566 + error: '相同 siteId/highlightId 对应的课堂或时间段发生变化'
  567 + });
  568 + continue;
  569 + }
  570 + output.push(this.snapshot(existing));
  571 + continue;
  572 + }
  573 +
  574 + const fileInfo = buildFileInfo(
  575 + item,
  576 + config.projectCatalog,
  577 + config.highlight.outputBaseUrl,
  578 + config.highlight.outputNamespace
  579 + );
  580 + const fileState = await this.inspectFile(fileInfo);
  581 + if (fileState.generated || fileState.local) {
  582 + const status = fileState.generated ? 'generated' : 'uploading';
  583 + const task = { key, item, fileInfo, status, error: '', updatedAt: Date.now() };
  584 + this.tasks.set(key, task);
  585 + if (fileState.local && !fileState.generated) this.completedDirs.add(fileInfo.localDir);
  586 + output.push(this.snapshot(task));
  587 + continue;
  588 + }
  589 +
  590 + const task = { key, item, fileInfo, status: 'queued', error: '', updatedAt: Date.now() };
  591 + this.tasks.set(key, task);
  592 + this.queue.push(task);
  593 + output.push(this.snapshot(task));
  594 + }
  595 + this.schedule();
  596 + this.writeCompletionMarkersIfIdle();
  597 + return output;
  598 + }
  599 +
  600 + schedule() {
  601 + const config = this.readConfig().highlight;
  602 + while (this.activeCount < config.maxConcurrent && this.queue.length > 0) {
  603 + const index = this.queue.findIndex(task => !this.activeClassIds.has(`${task.item.siteId}:${task.item.classId}`));
  604 + if (index < 0) return;
  605 + const task = this.queue.splice(index, 1)[0];
  606 + const activeClassKey = `${task.item.siteId}:${task.item.classId}`;
  607 + this.activeCount += 1;
  608 + this.activeClassIds.add(activeClassKey);
  609 + this.processTask(task).catch(error => {
  610 + task.status = 'failed';
  611 + task.error = error.message || String(error);
  612 + task.updatedAt = Date.now();
  613 + this.writeLog(`失败 key=${task.key} error=${task.error}`);
  614 + }).finally(() => {
  615 + this.activeCount -= 1;
  616 + this.activeClassIds.delete(activeClassKey);
  617 + this.writeCompletionMarkersIfIdle();
  618 + this.schedule();
  619 + });
  620 + }
  621 + }
  622 +
  623 + buildPlaybackUrl(item, playback) {
  624 + const url = new URL(playback.url);
  625 + const params = url.searchParams;
  626 + params.set('classId', item.classId);
  627 + params.set('recordMp4', 'true');
  628 + params.set('userId', String(playback.userId == null ? 0 : playback.userId));
  629 + params.set('userName', String(playback.userName || ''));
  630 + params.set('userRole', String(playback.userRole || 'normal'));
  631 + params.set('portalIP', String(playback.portalIP || 'saas.xuedianyun.com'));
  632 + params.set('portalPort', String(playback.portalPort || 80));
  633 + params.set('channels', String(playback.channels == null ? 2 : playback.channels));
  634 + params.set('playRecord', '1');
  635 + params.set('language', 'zh-cn');
  636 + params.set('recBeginTime', String(item.beginTime));
  637 + params.set('recEndTime', String(item.endTime));
  638 + return url.toString();
  639 + }
  640 +
  641 + runCapture(task, config) {
  642 + return new Promise((resolve, reject) => {
  643 + const duration = task.item.endTime - task.item.beginTime;
  644 + const playbackUrl = this.buildPlaybackUrl(task.item, config.playback);
  645 + const binary = path.join(config.captureCatalog, 'web_capture_c');
  646 + const args = [
  647 + `-o=${task.fileInfo.tempPath}`,
  648 + `-u=${playbackUrl}`,
  649 + `-d=${duration}`,
  650 + `-s=${config.playback.s}`,
  651 + `-fa=${config.playback.fa}`,
  652 + `-k=${config.playback.k}`,
  653 + `-w=${config.playback.w}`,
  654 + `-h=${config.playback.h}`,
  655 + '-ar=44100'
  656 + ];
  657 + const child = spawn(binary, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] });
  658 + const watchdogMs = duration + config.highlight.loadGraceMs + config.highlight.endGraceMs;
  659 + let stderr = '';
  660 + let settled = false;
  661 + let timedOut = false;
  662 + let killTimer = null;
  663 +
  664 + const watchdog = setTimeout(() => {
  665 + timedOut = true;
  666 + child.kill('SIGTERM');
  667 + killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
  668 + }, watchdogMs);
  669 +
  670 + child.stderr.on('data', chunk => {
  671 + stderr = `${stderr}${chunk}`.slice(-8192);
  672 + });
  673 + child.on('error', error => {
  674 + if (settled) return;
  675 + settled = true;
  676 + clearTimeout(watchdog);
  677 + if (killTimer) clearTimeout(killTimer);
  678 + reject(error);
  679 + });
  680 + child.on('close', code => {
  681 + if (settled) return;
  682 + settled = true;
  683 + clearTimeout(watchdog);
  684 + if (killTimer) clearTimeout(killTimer);
  685 + if (timedOut) {
  686 + reject(new Error(`录制超时 ${watchdogMs}ms`));
  687 + } else if (code !== 0) {
  688 + reject(new Error(`web_capture_c 退出码 ${code}: ${stderr.trim()}`));
  689 + } else {
  690 + resolve();
  691 + }
  692 + });
  693 + });
  694 + }
  695 +
  696 + async processTask(task) {
  697 + const config = this.readConfig();
  698 + task.status = 'recording';
  699 + task.error = '';
  700 + task.updatedAt = Date.now();
  701 + fs.mkdirSync(task.fileInfo.localDir, { recursive: true });
  702 + fs.mkdirSync(task.fileInfo.tempDir, { recursive: true });
  703 + if (fs.existsSync(task.fileInfo.tempPath)) fs.unlinkSync(task.fileInfo.tempPath);
  704 + this.writeLog(`开始 key=${task.key} classId=${task.item.classId} beginTime=${task.item.beginTime} endTime=${task.item.endTime}`);
  705 + await this.runCapture(task, config);
  706 + if (!this.localFileExists(task.fileInfo.tempPath)) {
  707 + throw new Error('录制进程结束但未生成有效文件');
  708 + }
  709 + if (fs.existsSync(task.fileInfo.localPath)) {
  710 + fs.unlinkSync(task.fileInfo.tempPath);
  711 + } else {
  712 + fs.renameSync(task.fileInfo.tempPath, task.fileInfo.localPath);
  713 + }
  714 + const state = await this.inspectFile(task.fileInfo);
  715 + task.status = state.generated ? 'generated' : 'uploading';
  716 + task.updatedAt = Date.now();
  717 + this.completedDirs.add(task.fileInfo.localDir);
  718 + this.writeLog(`完成 key=${task.key} status=${task.status} file=${task.fileInfo.localPath}`);
  719 + }
  720 +
  721 + writeCompletionMarkersIfIdle() {
  722 + if (this.activeCount !== 0 || this.queue.length !== 0 || this.completedDirs.size === 0) return;
  723 + for (const localDir of this.completedDirs) {
  724 + try {
  725 + fs.writeFileSync(path.join(localDir, 'download.json'), JSON.stringify({ code: '0', success: 'ok' }));
  726 + } catch (error) {
  727 + this.writeLog(`写入完成标记失败 dir=${localDir} error=${error.message || error}`);
  728 + }
  729 + }
  730 + this.completedDirs.clear();
  731 + }
  732 +
  733 + summarizeTasks(tasks) {
  734 + const summary = {
  735 + received: tasks.length,
  736 + queued: 0,
  737 + recording: 0,
  738 + uploading: 0,
  739 + generated: 0,
  740 + invalid: 0,
  741 + failed: 0
  742 + };
  743 + for (const task of tasks) {
  744 + if (Object.prototype.hasOwnProperty.call(summary, task.status)) {
  745 + summary[task.status] += 1;
  746 + }
  747 + }
  748 + return summary;
  749 + }
  750 +
  751 + async runScheduledRecording(now) {
  752 + this.ensureFeatureEnabled();
  753 + if (this.scheduledRunActive) {
  754 + throw new HighlightValidationError('高光任务正在读取中');
  755 + }
  756 + this.scheduledRunActive = true;
  757 + try {
  758 + const config = this.readConfig().highlight;
  759 + const mode = String(config.sourceMode || 'site');
  760 + const siteIds = this.getAllowedSiteIds();
  761 + if (siteIds.length === 0) {
  762 + throw new HighlightValidationError('未配置高光站点');
  763 + }
  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));
  800 + }
  801 +
  802 + throw new HighlightValidationError('HIGHLIGHTCONFIG.sourceMode 只能是 site 或 task');
  803 + } finally {
  804 + this.scheduledRunActive = false;
  805 + }
  806 + }
  807 +
  808 + async getClassFileStatuses(params) {
  809 + this.ensureFeatureEnabled();
  810 + const input = params || {};
  811 + const siteId = String(input.siteId || '');
  812 + const classId = String(input.classId || input.meetingNumber || '');
  813 + if (!isSafeIdentifier(siteId)) throw new HighlightValidationError('siteId 无效');
  814 + if (!isSafeIdentifier(classId)) throw new HighlightValidationError('classId 无效');
  815 + if (!this.isSiteAllowed(siteId)) {
  816 + throw new HighlightValidationError(`站点未启用高光录制: ${siteId}`);
  817 + }
  818 + const records = await this.fetchByClass(classId);
  819 + const matched = records.filter(record => String(record.siteId || '') === siteId);
  820 + return this.getFileStatuses(matched);
  821 + }
  822 +
  823 + async getFileStatuses(items) {
  824 + this.cleanupTasks();
  825 + const config = this.readConfig();
  826 + const allowedSiteIds = new Set(this.getAllowedSiteIds());
  827 + const resolveStatus = async raw => {
  828 + let item;
  829 + try {
  830 + item = normalizeFileLookup(raw);
  831 + if (!allowedSiteIds.has(item.siteId)) {
  832 + throw new HighlightValidationError(`站点未启用高光录制: ${item.siteId}`);
  833 + }
  834 + } catch (error) {
  835 + return {
  836 + highlightId: raw && (raw.highlightId || raw.id),
  837 + classId: raw && (raw.classId || raw.meetingNumber),
  838 + generated: false,
  839 + status: 'invalid',
  840 + url: null,
  841 + error: error.message
  842 + };
  843 + }
  844 + const fileInfo = buildFileInfo(
  845 + item,
  846 + config.projectCatalog,
  847 + config.highlight.outputBaseUrl,
  848 + config.highlight.outputNamespace
  849 + );
  850 + const fileState = await this.inspectFile(fileInfo);
  851 + const key = buildTaskKey(item);
  852 + const task = this.tasks.get(key);
  853 + let status = 'not_generated';
  854 + if (fileState.generated) {
  855 + status = 'generated';
  856 + if (task) {
  857 + task.status = 'generated';
  858 + task.updatedAt = Date.now();
  859 + }
  860 + } else if (fileState.local) {
  861 + status = 'uploading';
  862 + } else if (task) {
  863 + status = task.status;
  864 + }
  865 + return {
  866 + highlightId: item.highlightId,
  867 + classId: item.classId,
  868 + siteId: item.siteId,
  869 + beginTime: item.beginTime,
  870 + generated: status === 'generated',
  871 + status,
  872 + url: status === 'generated' ? fileInfo.url : null,
  873 + error: task && task.error ? task.error : undefined
  874 + };
  875 + };
  876 +
  877 + const output = [];
  878 + const source = items || [];
  879 + const concurrency = 20;
  880 + for (let index = 0; index < source.length; index += concurrency) {
  881 + const batch = source.slice(index, index + concurrency);
  882 + const results = await Promise.all(batch.map(resolveStatus));
  883 + output.push(...results);
  884 + }
  885 + return output;
  886 + }
  887 +
  888 + getQueueInfo() {
  889 + this.cleanupTasks();
  890 + return {
  891 + queued: this.queue.length,
  892 + recording: this.activeCount,
  893 + knownTasks: this.tasks.size
  894 + };
  895 + }
  896 +}
  897 +
  898 +const highlightRecordingService = new HighlightRecordingService();
  899 +
  900 +module.exports = {
  901 + HighlightRecordingService,
  902 + HighlightUpstreamError,
  903 + HighlightValidationError,
  904 + buildFileInfo,
  905 + buildTaskKey,
  906 + formatShanghaiDate,
  907 + md5,
  908 + normalizeFileLookup,
  909 + normalizeHighlight,
  910 + normalizeOutputNamespace,
  911 + getPreviousShanghaiDayRange,
  912 + highlightRecordingService
  913 +};
  1 +const assert = require('assert');
  2 +const fs = require('fs');
  3 +const os = require('os');
  4 +const path = require('path');
  5 +const {
  6 + HighlightRecordingService,
  7 + buildFileInfo,
  8 + buildTaskKey,
  9 + formatShanghaiDate,
  10 + getPreviousShanghaiDayRange,
  11 + md5,
  12 + normalizeFileLookup,
  13 + normalizeHighlight,
  14 + normalizeOutputNamespace
  15 +} = require('../services/highlightRecordingService');
  16 +
  17 +async function run() {
  18 + assert.strictEqual(md5('14867586201784628401782'), 'a66749831542e5e090e41d72f455918d');
  19 +
  20 + const shanghaiMidnight = Date.UTC(2026, 0, 1, 16, 0, 0, 0);
  21 + assert.strictEqual(formatShanghaiDate(shanghaiMidnight), '20260102');
  22 + assert.deepStrictEqual(getPreviousShanghaiDayRange(Date.UTC(2026, 7, 6, 0, 0, 0)), {
  23 + beginTime: Date.UTC(2026, 7, 4, 16, 0, 0),
  24 + endTime: Date.UTC(2026, 7, 5, 15, 59, 59, 999)
  25 + });
  26 +
  27 + const item = normalizeHighlight({
  28 + id: 4,
  29 + meetingNumber: '1486758620',
  30 + siteId: 'doctest',
  31 + beginTime: 1779159793000,
  32 + endTime: 1779159893000,
  33 + type: 0,
  34 + more: 'ext'
  35 + }, 3600000);
  36 + assert.strictEqual(item.highlightId, 4);
  37 + assert.strictEqual(item.classId, '1486758620');
  38 + assert.strictEqual(buildTaskKey(item), 'doctest:4');
  39 +
  40 + assert.throws(() => normalizeHighlight({
  41 + id: 4,
  42 + meetingNumber: '1486758620',
  43 + siteId: '../bad',
  44 + beginTime: 1779159793000,
  45 + endTime: 1779159893000
  46 + }, 3600000), /siteId 无效/);
  47 +
  48 + assert.throws(() => normalizeHighlight({
  49 + id: 4,
  50 + meetingNumber: '1486758620',
  51 + siteId: 'doctest',
  52 + beginTime: 1779159893000,
  53 + endTime: 1779159793000
  54 + }, 3600000), /endTime 必须大于 beginTime/);
  55 +
  56 + assert.deepStrictEqual(normalizeFileLookup({
  57 + highlightId: 4,
  58 + classId: '1486758620',
  59 + siteId: 'doctest',
  60 + beginTime: 1779159793000
  61 + }), {
  62 + highlightId: 4,
  63 + classId: '1486758620',
  64 + siteId: 'doctest',
  65 + beginTime: 1779159793000
  66 + });
  67 +
  68 + const fileInfo = buildFileInfo(item, '/capture', 'https://xdymp4.xuedianyun.com/');
  69 + assert.strictEqual(fileInfo.fileName, '1486758620_highlight_4.mp4');
  70 + assert.strictEqual(fileInfo.localPath, path.join('/capture', 'media', 'doctest', '20260519', fileInfo.fileName));
  71 + assert.strictEqual(fileInfo.ossKey, 'oss/doctest/20260519/1486758620_highlight_4.mp4');
  72 + assert.strictEqual(fileInfo.url, `https://xdymp4.xuedianyun.com/${fileInfo.ossKey}`);
  73 +
  74 + const validationFileInfo = buildFileInfo(item, '/capture', 'https://xdymp4.xuedianyun.com/', 'validation-20260805');
  75 + assert.strictEqual(
  76 + validationFileInfo.localPath,
  77 + path.join('/capture', 'media', 'validation-20260805', 'doctest', '20260519', validationFileInfo.fileName)
  78 + );
  79 + assert.strictEqual(
  80 + validationFileInfo.ossKey,
  81 + 'oss/validation-20260805/doctest/20260519/1486758620_highlight_4.mp4'
  82 + );
  83 + assert.strictEqual(normalizeOutputNamespace('validation_1'), 'validation_1');
  84 + assert.throws(() => normalizeOutputNamespace('../validation'), /outputNamespace/);
  85 +
  86 + const service = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
  87 + const playbackUrl = new URL(service.buildPlaybackUrl(item, {
  88 + url: 'https://pclive.xuedianyun.com/pcBase/pclive2/dev/index.html',
  89 + portalIP: 'saas.xuedianyun.com',
  90 + portalPort: 80,
  91 + channels: 2,
  92 + userRole: 'normal'
  93 + }));
  94 + assert.strictEqual(playbackUrl.searchParams.get('classId'), '1486758620');
  95 + assert.strictEqual(playbackUrl.searchParams.get('playRecord'), '1');
  96 + assert.strictEqual(playbackUrl.searchParams.get('recBeginTime'), '1779159793000');
  97 + assert.strictEqual(playbackUrl.searchParams.get('recEndTime'), '1779159893000');
  98 +
  99 + const queueService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
  100 + queueService.readConfig = () => ({
  101 + projectCatalog: '/capture',
  102 + highlight: {
  103 + siteIds: ['doctest'],
  104 + maxDurationMs: 3600000,
  105 + outputBaseUrl: 'https://xdymp4.xuedianyun.com',
  106 + taskRetentionMs: 86400000,
  107 + maxConcurrent: 1
  108 + }
  109 + });
  110 + queueService.inspectFile = async () => ({ local: false, oss: true, generated: true });
  111 + const firstResult = await queueService.enqueue([item]);
  112 + assert.strictEqual(firstResult[0].status, 'generated');
  113 + const changedResult = await queueService.enqueue([Object.assign({}, item, { beginTime: item.beginTime + 1000 })]);
  114 + assert.strictEqual(changedResult[0].status, 'invalid');
  115 + assert.match(changedResult[0].error, /时间段发生变化/);
  116 +
  117 + const previewService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
  118 + previewService.readConfig = () => ({
  119 + projectCatalog: '/capture',
  120 + playback: {
  121 + url: 'https://pclive.xuedianyun.com/pcBase/pclive2/dev/index.html',
  122 + portalIP: 'saas.xuedianyun.com',
  123 + portalPort: 80,
  124 + channels: 2
  125 + },
  126 + highlight: {
  127 + siteIds: ['doctest'],
  128 + maxDurationMs: 3600000,
  129 + outputBaseUrl: 'https://xdymp4.xuedianyun.com',
  130 + outputNamespace: 'validation-20260805'
  131 + }
  132 + });
  133 + previewService.inspectFile = async () => ({ local: false, oss: false, generated: false });
  134 + const preview = await previewService.preview([item]);
  135 + assert.strictEqual(preview[0].status, 'not_generated');
  136 + assert.strictEqual(preview[0].duration, 100000);
  137 + assert.match(preview[0].ossKey, /^oss\/validation-20260805\//);
  138 + assert.strictEqual(new URL(preview[0].playbackUrl).searchParams.get('recBeginTime'), '1779159793000');
  139 + assert.strictEqual(previewService.getQueueInfo().queued, 0);
  140 +
  141 + const scheduledSiteService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
  142 + scheduledSiteService.readConfig = () => ({
  143 + highlight: {
  144 + enabled: true,
  145 + sourceMode: 'site',
  146 + siteIds: ['xdyui2'],
  147 + maxConcurrent: 1,
  148 + taskRetentionMs: 86400000
  149 + }
  150 + });
  151 + let siteQuery;
  152 + scheduledSiteService.fetchBySite = async params => {
  153 + siteQuery = params;
  154 + return [];
  155 + };
  156 + scheduledSiteService.enqueue = async records => records;
  157 + const scheduledSite = await scheduledSiteService.runScheduledRecording(Date.UTC(2026, 7, 6, 0, 0, 0));
  158 + assert.strictEqual(scheduledSite.mode, 'site');
  159 + assert.strictEqual(siteQuery.siteId, 'xdyui2');
  160 + assert.strictEqual(scheduledSite.received, 0);
  161 +
  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-'));
  191 + try {
  192 + const captureService = new HighlightRecordingService({ configPath: '/not-used-in-this-test' });
  193 + captureService.readConfig = () => ({
  194 + projectCatalog: tempRoot,
  195 + captureCatalog: '/capture',
  196 + playback: { url: 'https://example.com/index.html' },
  197 + highlight: {
  198 + enabled: true,
  199 + siteIds: ['doctest'],
  200 + maxDurationMs: 3600000,
  201 + outputBaseUrl: 'https://xdymp4.xuedianyun.com',
  202 + outputNamespace: '',
  203 + taskRetentionMs: 86400000,
  204 + maxConcurrent: 1,
  205 + loadGraceMs: 100,
  206 + endGraceMs: 100
  207 + }
  208 + });
  209 + captureService.inspectFile = async fileInfo => ({
  210 + local: fs.existsSync(fileInfo.localPath),
  211 + oss: false,
  212 + generated: false
  213 + });
  214 + captureService.runCapture = async task => {
  215 + fs.writeFileSync(task.fileInfo.tempPath, 'test-mp4');
  216 + };
  217 + await captureService.enqueue([item]);
  218 + for (let attempt = 0; attempt < 100 && captureService.getQueueInfo().recording > 0; attempt += 1) {
  219 + await new Promise(resolve => setTimeout(resolve, 10));
  220 + }
  221 + const capturedFile = buildFileInfo(item, tempRoot, 'https://xdymp4.xuedianyun.com');
  222 + assert.strictEqual(fs.readFileSync(capturedFile.localPath, 'utf8'), 'test-mp4');
  223 + assert.ok(fs.existsSync(path.join(capturedFile.localDir, 'download.json')));
  224 + assert.strictEqual(fs.existsSync(capturedFile.tempPath), false);
  225 + } finally {
  226 + fs.rmSync(tempRoot, { recursive: true, force: true });
  227 + }
  228 +
  229 + console.log('highlightRecordingService tests passed');
  230 +}
  231 +
  232 +run().catch(error => {
  233 + console.error(error);
  234 + process.exitCode = 1;
  235 +});
  1 +const assert = require('assert');
  2 +const http = require('http');
  3 +const { highlightRecordingService } = require('../services/highlightRecordingService');
  4 +const app = require('../app');
  5 +
  6 +function request(server, method, route, body) {
  7 + return new Promise((resolve, reject) => {
  8 + const address = server.address();
  9 + const payload = body == null ? '' : JSON.stringify(body);
  10 + const req = http.request({
  11 + host: '127.0.0.1',
  12 + port: address.port,
  13 + path: route,
  14 + method,
  15 + headers: payload ? {
  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', () => {
  24 + resolve({ status: response.statusCode, body: JSON.parse(data) });
  25 + });
  26 + });
  27 + req.on('error', reject);
  28 + if (payload) req.write(payload);
  29 + req.end();
  30 + });
  31 +}
  32 +
  33 +async function run() {
  34 + const server = http.createServer(app);
  35 + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  36 + try {
  37 + const status = await request(server, 'GET', '/highlight/status');
  38 + assert.strictEqual(status.status, 200);
  39 + assert.deepStrictEqual(status.body.data, { queued: 0, recording: 0, knownTasks: 0 });
  40 +
  41 + const invalidFiles = await request(server, 'POST', '/highlight/files', { items: [] });
  42 + assert.strictEqual(invalidFiles.status, 400);
  43 + assert.strictEqual(invalidFiles.body.code, 10);
  44 +
  45 + const invalidPreview = await request(server, 'POST', '/highlight/preview/by-class', {});
  46 + assert.strictEqual(invalidPreview.status, 400);
  47 + assert.strictEqual(invalidPreview.body.code, 10);
  48 +
  49 + const invalidClassFiles = await request(server, 'POST', '/highlight/fileExists', {});
  50 + assert.strictEqual(invalidClassFiles.status, 400);
  51 + assert.strictEqual(invalidClassFiles.body.code, 10);
  52 +
  53 + const originalGetClassFileStatuses = highlightRecordingService.getClassFileStatuses;
  54 + highlightRecordingService.getClassFileStatuses = async () => [
  55 + { highlightId: 4, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/4.mp4' },
  56 + { highlightId: 5, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/5.mp4' }
  57 + ];
  58 + const generatedFiles = await request(server, 'POST', '/highlight/fileExists', { siteId: 'xdyui2', classId: '1001' });
  59 + assert.strictEqual(generatedFiles.body.code, 0);
  60 + assert.strictEqual(generatedFiles.body.onlyHighlight, 1);
  61 + assert.strictEqual(generatedFiles.body.classUrlList.length, 2);
  62 +
  63 + highlightRecordingService.getClassFileStatuses = async () => [
  64 + { highlightId: 4, classId: '1001', siteId: 'xdyui2', generated: true, status: 'generated', url: 'https://example/4.mp4' },
  65 + { highlightId: 5, classId: '1001', siteId: 'xdyui2', generated: false, status: 'uploading', url: null }
  66 + ];
  67 + const partialFiles = await request(server, 'POST', '/highlight/fileExists', { siteId: 'xdyui2', classId: '1001' });
  68 + assert.strictEqual(partialFiles.body.code, 1);
  69 + assert.strictEqual(partialFiles.body.message, '部分文件未生成');
  70 + highlightRecordingService.getClassFileStatuses = originalGetClassFileStatuses;
  71 + } finally {
  72 + await new Promise(resolve => server.close(resolve));
  73 + }
  74 + console.log('highlight routes tests passed');
  75 +}
  76 +
  77 +run().catch(error => {
  78 + console.error(error);
  79 + process.exitCode = 1;
  80 +});