huz1xuan

完善高光时刻入口

... ... @@ -44,5 +44,15 @@
"outputNamespace": "",
"outputBaseUrl": "https://xdymp4.xuedianyun.com"
},
"RECORDINGV2CONFIG": {
"taskListUrl": "https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do",
"pageSize": 100,
"maxPages": 1000,
"maxTasksPerRun": 4,
"maxFullConcurrent": 2,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500
},
"classLastNumber":["0","1","2","3","4","5","6","7","8","9"]
}
... ...
... ... @@ -4,6 +4,7 @@
- 原 `/recordingTask`、`/fileExists` 保持不变。
- `/recordingTaskV2` 兼容原整课请求并支持 `onlyHighlight=1`。
- `/recordingTaskV2/scheduled` 供 cron 触发,由 WebScreen 自行拉取 SaaS 任务。
- `/fileExistsV2` 是新功能的客户查询接口。
- `/highlight/*` 仅允许运维内网访问。
... ... @@ -23,39 +24,37 @@
还需配置 OSS AccessKey、`web_capture_c`、显示服务及现有 OSS 搬运任务。
不录制站点已由 xdySDK 上游流程筛除,WebScreen V2 不再维护第二份站点策略。
## 外部 cron
录制任务拉取配置:
正式任务链路由现有外部 cron 分发,不使用 WebScreen 本机的
`/highlight/recording/scheduled` 作为正式入口。
```json
"RECORDINGV2CONFIG": {
"taskListUrl": "https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do",
"pageSize": 100,
"maxPages": 1000,
"maxTasksPerRun": 4,
"maxFullConcurrent": 2,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500
}
```
cron 继续调用 SaaS 的 `getRecordingTasksPrivate.do`,但需要将目标地址从:
不录制站点已由 xdySDK 上游流程筛除,WebScreen V2 不再维护第二份站点策略。
```text
POST /recordingTask
```
## cron
改为:
cron 不查询 SaaS,也不转换 `taskList`,只触发 WebScreen:
```text
POST /recordingTaskV2
```cron
31 7-20 * * * curl -fsS -X POST http://127.0.0.1:3001/recordingTaskV2/scheduled >> /var/log/webscreen_recording_v2.log 2>&1
```
原来的字段映射可以继续使用,只需增加 `onlyHighlight`:
此入口只接受本机回环地址请求。
```json
{
"classId": "task.meetingNumber",
"siteId": "task.siteId",
"yymmdd": "原有课堂日期",
"onlyHighlight": "task.onlyHighlight"
}
```
WebScreen 随后按照 `RECORDINGV2CONFIG` 分页调用
`getRecordingTasksPrivate.do`,并将任务交给 V2 录制服务。
也可以不做映射,直接转发 SaaS 的完整 `taskList`;V2 同时兼容
`classId/meetingNumber`、`taskId/id` 两种字段名。`id` 和
`beginTime/endTime` 不是状态回写的必填字段。
`/highlight/recording/scheduled` 仅用于按站点批量补录,不作为正式任务入口。
## 灰度与回滚
... ...
... ... @@ -7,6 +7,8 @@
- [部署说明](./HIGHLIGHT_DEPLOYMENT.md)
- [部署与验收测试](./HIGHLIGHT_DEPLOYMENT_USAGE_TEST.md)
正式 cron 入口:`POST /recordingTaskV2/scheduled`。cron 只触发该入口,SaaS 任务拉取和分流由 WebScreen 完成。
SaaS 已有接口:
- `/3m/api/recording/getRecordingTasksPrivate.do`
... ...
... ... @@ -13,6 +13,7 @@ POST /fileExists
```http
POST /recordingTaskV2
POST /recordingTaskV2/scheduled
POST /fileExistsV2
```
... ... @@ -23,14 +24,76 @@ V2 兼容原整课录制格式,并通过 SaaS 字段 `onlyHighlight=1` 支持
| 不传或 `0` | 复用原整课录制实现 |
| `1` | 仅录制该课堂的高光时刻 |
## 2. 创建 V2 录制任务
## 2. cron 统一入口
正式定时任务只调用 WebScreen 内部入口,不负责查询或转换 SaaS 数据:
```http
POST /recordingTaskV2/scheduled
```
请求体为空。WebScreen 会自行完成:
1. 分页调用 SaaS `/3m/api/recording/getRecordingTasksPrivate.do`。
2. 生成接口要求的 `timestamp` 和 `authId`。
3. 根据 `onlyHighlight` 将任务交给现有 V2 整课或高光流程。
4. 使用本地任务快照防止 cron 重复投递。
该入口只允许从 WebScreen 所在服务器的回环地址调用,不作为公网接口开放。
配置:
```json
"RECORDINGV2CONFIG": {
"taskListUrl": "https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do",
"pageSize": 100,
"maxPages": 1000,
"maxTasksPerRun": 4,
"maxFullConcurrent": 2,
"apiTimeoutMs": 10000,
"apiRetryCount": 2,
"apiRetryBaseDelayMs": 500
}
```
`maxTasksPerRun` 限制一次 cron 最多新接收多少个任务;重复任务不占用该额度。
`maxFullConcurrent` 限制同时运行的整课录制进程数。高光录制仍由
`HIGHLIGHTCONFIG.maxConcurrent` 单独限制。
cron 示例:
```cron
31 7-20 * * * curl -fsS -X POST http://127.0.0.1:3001/recordingTaskV2/scheduled >> /var/log/webscreen_recording_v2.log 2>&1
```
成功响应:
```json
{
"code": "0",
"message": "success",
"data": {
"received": 12,
"taskCount": 12,
"pages": 1,
"inspected": 5,
"accepted": 4,
"duplicates": 1,
"noMedia": 0,
"invalid": 0,
"limited": true
}
}
```
## 3. 创建 V2 录制任务
```http
POST /recordingTaskV2
Content-Type: application/json
```
### 2.1 兼容原整课请求
### 3.1 兼容原整课请求
```json
{
... ... @@ -47,7 +110,7 @@ Content-Type: application/json
未传 `onlyHighlight` 时,V2 调用现有整课录制代码,文件仍为 `{classId}.mp4`。
### 2.2 仅高光请求
### 3.2 仅高光请求
V2 可直接接受 SaaS `getRecordingTasksPrivate.do` 返回项的字段形式:
... ... @@ -90,7 +153,7 @@ V2 可直接接受 SaaS `getRecordingTasksPrivate.do` 返回项的字段形式
`getBySitePrivate.do` 不属于单课堂任务主流程,只用于批量排查或补录。
### 2.3 响应
### 3.3 响应
```json
{
... ... @@ -109,7 +172,7 @@ V2 可直接接受 SaaS `getRecordingTasksPrivate.do` 返回项的字段形式
| `duplicates` | 已处理或正在执行的重复任务数 |
| `noMedia` | `onlyHighlight=1` 但 SaaS 正常返回零条高光的任务数 |
## 3. 查询 V2 录制文件
## 4. 查询 V2 录制文件
```http
POST /fileExistsV2
... ... @@ -186,7 +249,7 @@ V2 根据 `/recordingTaskV2` 保存的任务快照判断文件类型,客户不
查询接口只检查明确的 OSS Key,不触发录制,也不重新调用 SaaS 高光接口。
## 4. 文件规则
## 5. 文件规则
```text
整课:oss/{siteId}/{yyyyMMdd}/{classId}.mp4
... ...
... ... @@ -103,10 +103,19 @@ class MediaCreat {
fs.appendFileSync(logFile, new Date().toLocaleString() + " " + text + '\r\n');
}
recordingCreat(id, siteId, type,yymmdd="") {
recordingCreat(id, siteId, type,yymmdd="", done) {
let completed = false
const finish = error => {
if (completed || typeof done !== 'function') return
completed = true
done(error || null)
}
this.wrieLog(" 课堂录制开始:------>" + id)
let fileConfig = this.getConfigFileJson()
if (!fileConfig) return false
if (!fileConfig) {
finish(new Error('无效的fileConfig'))
return false
}
const { BACKMEDIACONFIG, PROJECTWINCATALOG, PROJECTCATALOG } = JSON.parse(fileConfig)
let mediaDir = PROJECTCATALOG + "/media/"
let classDir = PROJECTCATALOG + "/media/" + siteId
... ... @@ -137,6 +146,7 @@ class MediaCreat {
this.wrieLog("files:" + files)
if (files.indexOf(id + ".mp4") != -1) {
this.wrieLog("已存在:" + id + "课堂号,停止继续录制")
finish()
if (type == 'post') {
if (classidPost.length) {
let shiftData = classidPost.shift()
... ... @@ -163,13 +173,16 @@ class MediaCreat {
this.wrieLog(" 错误" + id + ":" + err)
this.wrieLog(" 错误 stdout" + id + ":" + stdout)
this.wrieLog(" 错误 stderr" + id + ":" + stderr)
finish(err)
return
}
let files = fs.readdirSync(ymdDir);
// let interValGetFile = setInterval(()=>{
if (files.indexOf(id + ".mp4") == -1) {
this.wrieLog(" 课堂录制未发现该" + id + "课堂号")
finish(new Error(`课堂录制未生成文件: ${id}`))
} else {
finish()
if (type == 'get') {
if (classid.length) {
let shiftData = classid.shift()
... ... @@ -410,18 +423,28 @@ router.post('/recordingTask', async function (req, res, next) {
res.send({ code: "0",message:"success",v:version });
})
function getRecordingTaskV2Handlers() {
return {
recordFullClass: task => new Promise((resolve, reject) => {
new MediaCreat().recordingCreat(
task.classId,
task.siteId,
'post',
task.classStartTime || task.classDate,
error => error ? reject(error) : resolve()
);
})
};
}
function isLoopbackRequest(req) {
const address = String(req.socket && req.socket.remoteAddress || '');
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
}
router.post('/recordingTaskV2', async function (req, res, next) {
try {
const result = await recordingTaskService.acceptTasks(req.body || {}, {
recordFullClass: task => {
new MediaCreat().recordingCreat(
task.classId,
task.siteId,
'post',
task.classStartTime || task.classDate
);
}
});
const result = await recordingTaskService.acceptTasks(req.body || {}, getRecordingTaskV2Handlers());
return res.send({
code: "0",
message: "success",
... ... @@ -441,4 +464,28 @@ router.post('/recordingTaskV2', async function (req, res, next) {
}
})
// cron 只触发本入口;SaaS 任务查询、分页和 V2 分流均由 WebScreen 完成。
router.post('/recordingTaskV2/scheduled', async function (req, res, next) {
if (!isLoopbackRequest(req)) {
return res.status(403).send({ code: "1", message: "仅允许本机调用", data: {}, v: version });
}
try {
const result = await recordingTaskService.runScheduledTaskPull(getRecordingTaskV2Handlers());
return res.send({
code: "0",
message: result.received ? "success" : "无录制任务",
data: result,
v: version
});
} catch (error) {
if (error instanceof RecordingTaskValidationError || error instanceof HighlightValidationError) {
return res.status(400).send({ code: "1", message: error.message, data: {}, v: version });
}
if (error instanceof HighlightUpstreamError) {
return res.status(502).send({ code: String(error.upstreamCode), message: error.message, data: {}, v: version });
}
return next(error);
}
})
module.exports = router
... ...
... ... @@ -103,6 +103,10 @@ function buildManifestKey(task) {
return `${task.siteId}:${task.classId}`;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
class RecordingTaskService {
constructor(options) {
const opts = options || {};
... ... @@ -110,9 +114,13 @@ class RecordingTaskService {
this.highlightService = opts.highlightService || new HighlightRecordingService({ configPath: this.configPath });
this.inspectObjectOverride = opts.inspectObject;
this.updateTaskStatusOverride = opts.updateTaskStatus;
this.fetchTaskPageOverride = opts.fetchTaskPage;
this.ossClient = opts.ossClient || null;
this.inFlight = new Set();
this.objectStatusCache = new Map();
this.scheduledPullActive = false;
this.fullClassQueue = [];
this.fullClassActiveCount = 0;
}
readConfig() {
... ... @@ -154,6 +162,217 @@ class RecordingTaskService {
'https://xdymp4.xuedianyun.com').replace(/\/$/, '');
}
getTaskPullConfig() {
const raw = this.readConfig();
const config = Object.assign({
taskListUrl: 'https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do',
pageSize: 100,
maxPages: 1000,
maxTasksPerRun: 4,
maxFullConcurrent: 2,
apiTimeoutMs: 10000,
apiRetryCount: 2,
apiRetryBaseDelayMs: 500
}, raw.RECORDINGV2CONFIG || {});
try {
const url = new URL(String(config.taskListUrl || ''));
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol');
config.taskListUrl = url.toString();
} catch (error) {
throw new RecordingTaskValidationError('RECORDINGV2CONFIG.taskListUrl 无效');
}
const integerFields = [
['pageSize', 1, 1000],
['maxPages', 1, 1000],
['maxTasksPerRun', 1, 1000],
['maxFullConcurrent', 1, 100],
['apiTimeoutMs', 1, 300000],
['apiRetryCount', 0, 10],
['apiRetryBaseDelayMs', 0, 60000]
];
for (const [field, minimum, maximum] of integerFields) {
const value = Number(config[field]);
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RecordingTaskValidationError(`RECORDINGV2CONFIG.${field} 无效`);
}
config[field] = value;
}
return config;
}
async fetchTaskPage(pageNo, config) {
if (this.fetchTaskPageOverride) {
return this.fetchTaskPageOverride(pageNo, config.pageSize);
}
const timestamp = String(Date.now());
const body = {
pageNo,
pageSize: config.pageSize,
timestamp,
authId: crypto.createHash('md5')
.update(`${pageNo}${config.pageSize}${timestamp}`, 'utf8')
.digest('hex')
};
let response;
let lastError;
for (let attempt = 0; attempt <= config.apiRetryCount; attempt += 1) {
try {
response = await axios.post(config.taskListUrl, querystring.stringify(body), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: config.apiTimeoutMs
});
break;
} catch (error) {
lastError = error;
const status = error && error.response && error.response.status;
const retryable = !status || status >= 500;
if (!retryable || attempt >= config.apiRetryCount) {
const upstreamError = new HighlightUpstreamError(-1,
`录制任务列表请求失败${status ? ` HTTP ${status}` : ''}`);
upstreamError.cause = error;
throw upstreamError;
}
await delay(config.apiRetryBaseDelayMs * Math.pow(2, attempt));
}
}
if (!response) {
const upstreamError = new HighlightUpstreamError(-1, '录制任务列表请求失败');
upstreamError.cause = lastError;
throw upstreamError;
}
return response.data || {};
}
async fetchReadyTasks() {
const config = this.getTaskPullConfig();
const tasks = [];
const seen = new Set();
let taskCount = 0;
let pages = 0;
for (let pageNo = 1; pageNo <= config.maxPages; pageNo += 1) {
const result = await this.fetchTaskPage(pageNo, config);
const code = Number(result.code);
if (code !== 0) {
throw new HighlightUpstreamError(code, `录制任务列表返回错误 code=${result.code}`);
}
if (!Array.isArray(result.taskList)) {
throw new HighlightUpstreamError(10, '录制任务列表 taskList 不是数组');
}
pages = pageNo;
const count = Number(result.taskCount);
if (Number.isFinite(count) && count >= 0) taskCount = count;
for (const task of result.taskList) {
const identity = task && task.id != null
? `id:${task.id}`
: `task:${task && task.siteId}:${task && (task.meetingNumber || task.classId)}:${task && task.onlyHighlight}`;
if (!seen.has(identity)) {
seen.add(identity);
tasks.push(task);
}
}
const responsePageSize = Number(result.pageSize) || config.pageSize;
const responsePageNo = Number(result.pageNo) || pageNo;
const complete = result.taskList.length === 0 ||
(Number.isFinite(count) && responsePageNo * responsePageSize >= count) ||
(!Number.isFinite(count) && result.taskList.length < config.pageSize);
if (complete) break;
if (pageNo === config.maxPages) {
throw new HighlightUpstreamError(2, '录制任务列表分页超过安全上限');
}
}
return { config, tasks, taskCount, pages };
}
async runScheduledTaskPull(handlers) {
if (this.scheduledPullActive) {
throw new RecordingTaskValidationError('V2录制任务正在读取中');
}
this.scheduledPullActive = true;
try {
const fetched = await this.fetchReadyTasks();
const summary = {
received: fetched.tasks.length,
taskCount: fetched.taskCount,
pages: fetched.pages,
inspected: 0,
accepted: 0,
duplicates: 0,
noMedia: 0,
invalid: 0,
limited: false
};
for (const task of fetched.tasks) {
if (summary.accepted >= fetched.config.maxTasksPerRun) {
summary.limited = true;
break;
}
summary.inspected += 1;
try {
const result = await this.acceptTasks({ list: [task] }, handlers);
summary.accepted += result.accepted;
summary.duplicates += result.duplicates;
summary.noMedia += result.noMedia;
} catch (error) {
if (error instanceof RecordingTaskValidationError || error instanceof HighlightValidationError) {
summary.invalid += 1;
continue;
}
throw error;
}
}
return summary;
} finally {
this.scheduledPullActive = false;
}
}
enqueueFullClass(task, recordFullClass) {
if (typeof recordFullClass !== 'function') {
throw new RecordingTaskValidationError('整课录制处理器未配置');
}
const key = buildManifestKey(task);
this.inFlight.add(key);
this.fullClassQueue.push({ key, task, recordFullClass });
this.drainFullClassQueue();
}
drainFullClassQueue() {
const maxConcurrent = this.getTaskPullConfig().maxFullConcurrent;
while (this.fullClassActiveCount < maxConcurrent && this.fullClassQueue.length > 0) {
const queued = this.fullClassQueue.shift();
this.fullClassActiveCount += 1;
this.processFullClassTask(queued).catch(error => {
console.error(`V2整课录制任务执行失败 ${queued.key}:`, error);
}).finally(() => {
this.fullClassActiveCount -= 1;
this.inFlight.delete(queued.key);
this.drainFullClassQueue();
});
}
}
async processFullClassTask(queued) {
const task = queued.task;
try {
await queued.recordFullClass(task);
await this.updateTaskStatus(task, 2);
const manifest = this.loadManifest(queued.key);
if (manifest) {
manifest.status = 'completed';
manifest.updatedAt = Date.now();
this.saveManifest(manifest);
}
} catch (error) {
const manifest = this.loadManifest(queued.key);
if (manifest) {
manifest.status = 'failed';
manifest.updatedAt = Date.now();
this.saveManifest(manifest);
}
throw error;
}
}
buildFullFile(task) {
const ossKey = `oss/${task.siteId}/${task.classDate}/${task.classId}.mp4`;
return { type: 'full', ossKey, url: `${this.getOutputBaseUrl()}/${ossKey}` };
... ... @@ -338,12 +557,9 @@ class RecordingTaskService {
const processTask = async task => {
const key = buildManifestKey(task);
const existing = this.loadManifest(key);
const sameTaskId = task.taskId && existing && Array.isArray(existing.taskIds) &&
existing.taskIds.includes(task.taskId);
const sameMode = existing && existing.onlyHighlight === task.onlyHighlight;
const completed = existing && (existing.status === 'completed' || existing.status === 'no_media');
const acceptedFullTask = sameTaskId && task.onlyHighlight === 0 && existing.status === 'accepted';
if (this.inFlight.has(key) || (sameMode && (completed || acceptedFullTask))) {
if (this.inFlight.has(key) || (sameMode && completed)) {
duplicates += 1;
return;
}
... ... @@ -351,7 +567,7 @@ class RecordingTaskService {
if (task.onlyHighlight === 0) {
const manifest = this.buildManifest(task, [], 'accepted');
this.saveManifest(manifest);
if (typeof recordFullClass === 'function') await recordFullClass(task);
this.enqueueFullClass(task, recordFullClass);
accepted += 1;
return;
}
... ...
... ... @@ -33,6 +33,7 @@ async function run() {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const originalAcceptTasks = recordingTaskService.acceptTasks;
const originalGetFileResult = recordingTaskService.getFileResult;
const originalRunScheduledTaskPull = recordingTaskService.runScheduledTaskPull;
let acceptedCalls = 0;
let fileCalls = 0;
const acceptedBodies = [];
... ... @@ -54,6 +55,20 @@ async function run() {
files: [{ type: 'highlight', highlightId: 5, url: 'https://example/highlight.mp4' }]
};
};
recordingTaskService.runScheduledTaskPull = async handlers => {
assert.strictEqual(typeof handlers.recordFullClass, 'function');
return {
received: 3,
taskCount: 3,
pages: 1,
inspected: 3,
accepted: 2,
duplicates: 1,
noMedia: 0,
invalid: 0,
limited: false
};
};
const oldTask = await request(server, '/recordingTask', {});
assert.strictEqual(oldTask.status, 200);
... ... @@ -89,6 +104,12 @@ async function run() {
assert.strictEqual(acceptedCalls, 2);
assert.strictEqual(acceptedBodies[1].list[0].onlyHighlight, undefined);
const scheduledTask = await request(server, '/recordingTaskV2/scheduled', {});
assert.strictEqual(scheduledTask.status, 200);
assert.strictEqual(scheduledTask.body.code, '0');
assert.strictEqual(scheduledTask.body.data.accepted, 2);
assert.strictEqual(scheduledTask.body.data.duplicates, 1);
const oldFile = await request(server, '/fileExists', {
siteId: 'doctest', classId: '1001'
});
... ... @@ -108,6 +129,7 @@ async function run() {
} finally {
recordingTaskService.acceptTasks = originalAcceptTasks;
recordingTaskService.getFileResult = originalGetFileResult;
recordingTaskService.runScheduledTaskPull = originalRunScheduledTaskPull;
await new Promise(resolve => server.close(resolve));
}
console.log('recording V2 routes tests passed');
... ...
... ... @@ -63,6 +63,15 @@ async function run() {
const configPath = path.join(tempRoot, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
PROJECTCATALOG: tempRoot,
RECORDINGV2CONFIG: {
taskListUrl: 'https://saas.xuedianyun.com/3m/api/recording/getRecordingTasksPrivate.do',
pageSize: 2,
maxPages: 10,
maxTasksPerRun: 2,
apiTimeoutMs: 10000,
apiRetryCount: 0,
apiRetryBaseDelayMs: 0
},
HIGHLIGHTCONFIG: {
enabled: true,
siteIds: ['doctest'],
... ... @@ -155,7 +164,7 @@ async function run() {
assert.strictEqual(calls.fetch, 1);
assert.strictEqual(calls.enqueue, 1);
assert.strictEqual(calls.wait, 1);
assert.deepStrictEqual(statusUpdates, ['1002:2']);
assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2']);
const highManifest = service.loadManifest(buildManifestKey({ siteId: 'doctest', classId: '1002' }));
assert.strictEqual(highManifest.onlyHighlight, 1);
... ... @@ -190,7 +199,7 @@ async function run() {
onlyHighlight: 1
}] });
assert.deepStrictEqual(noMedia, { accepted: 1, duplicates: 0, noMedia: 1 });
assert.deepStrictEqual(statusUpdates, ['1002:2', '1003:3']);
assert.deepStrictEqual(statusUpdates, ['1001:2', '1002:2', '1003:3']);
const emptyResult = await service.getFileResult({ siteId: 'doctest', classId: '1003' });
assert.strictEqual(emptyResult.fileExists, false);
... ... @@ -259,6 +268,82 @@ async function run() {
releaseConcurrentFetch([]);
assert.deepStrictEqual(await firstConcurrent, { accepted: 1, duplicates: 0, noMedia: 1 });
const scheduledFullCalls = [];
const scheduledService = new RecordingTaskService({
configPath,
highlightService,
updateTaskStatus: async () => {},
fetchTaskPage: async pageNo => ({
code: 0,
taskCount: 3,
pageNo,
pageSize: 2,
taskList: pageNo === 1 ? [{
id: 'scheduled-1', siteId: 'doctest', meetingNumber: '1010',
beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
}, {
id: 'scheduled-2', siteId: 'doctest', meetingNumber: '1011',
beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
}] : [{
id: 'scheduled-3', siteId: 'doctest', meetingNumber: '1012',
beginTime: '2026-08-05 10:00:00', endTime: '2026-08-05 11:00:00', onlyHighlight: 0
}]
})
});
const scheduledHandlers = {
recordFullClass: task => { scheduledFullCalls.push(task.classId); }
};
const firstScheduled = await scheduledService.runScheduledTaskPull(scheduledHandlers);
assert.deepStrictEqual(firstScheduled, {
received: 3,
taskCount: 3,
pages: 2,
inspected: 2,
accepted: 2,
duplicates: 0,
noMedia: 0,
invalid: 0,
limited: true
});
const secondScheduled = await scheduledService.runScheduledTaskPull(scheduledHandlers);
assert.strictEqual(secondScheduled.inspected, 3);
assert.strictEqual(secondScheduled.accepted, 1);
assert.strictEqual(secondScheduled.duplicates, 2);
assert.strictEqual(secondScheduled.limited, false);
assert.deepStrictEqual(scheduledFullCalls, ['1010', '1011', '1012']);
await waitForBackground(scheduledService);
const fullQueueStarts = [];
const fullQueueReleases = [];
const fullQueueService = new RecordingTaskService({
configPath,
highlightService,
updateTaskStatus: async () => {}
});
const fullQueueResult = await fullQueueService.acceptTasks({
list: ['1020', '1021', '1022'].map(classId => ({
id: `queue-${classId}`,
siteId: 'doctest',
classId,
yymmdd: '20260805'
}))
}, {
recordFullClass: task => new Promise(resolve => {
fullQueueStarts.push(task.classId);
fullQueueReleases.push(resolve);
})
});
assert.strictEqual(fullQueueResult.accepted, 3);
assert.deepStrictEqual(fullQueueStarts, ['1020', '1021']);
assert.strictEqual(fullQueueService.fullClassActiveCount, 2);
assert.strictEqual(fullQueueService.fullClassQueue.length, 1);
fullQueueReleases[0]();
await new Promise(resolve => setImmediate(resolve));
assert.deepStrictEqual(fullQueueStarts, ['1020', '1021', '1022']);
fullQueueReleases[1]();
fullQueueReleases[2]();
await waitForBackground(fullQueueService);
fs.rmSync(tempRoot, { recursive: true, force: true });
console.log('recording task V2 service tests passed');
}
... ...