正在显示
13 个修改的文件
包含
2022 行增加
和
2 行删除
HIGHLIGHT_DEPLOYMENT.md
0 → 100644
| 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`。 |
HIGHLIGHT_MP4_SPEC.md
0 → 100644
| 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 | } |
getByClassPrivate.md
0 → 100644
| 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×tamp=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 | +``` |
getBySitePrivate.md
0 → 100644
| 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×tamp=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", |
routes/highlightRecording.js
0 → 100644
| 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) |
services/highlightRecordingService.js
0 → 100644
| 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 | +}; |
test/highlightRecordingService.test.js
0 → 100644
| 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 | +}); |
test/highlightRoutes.test.js
0 → 100644
| 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 | +}); |
-
请 注册 或 登录 后发表评论