Toggle navigation
Toggle navigation
此项目
正在载入...
Sign in
张艾宸
/
webScreen
转到一个项目
Toggle navigation
项目
群组
代码片段
帮助
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
huz1xuan
2026-08-11 11:49:05 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
01c1cc839b816ed10d818aa01e5eb41a60fd7e4f
01c1cc83
1 parent
092d23f0
完善高光时刻入口
显示空白字符变更
内嵌
并排对比
正在显示
9 个修改的文件
包含
490 行增加
和
46 行删除
config/config.json
docs/HIGHLIGHT_DEPLOYMENT.md
docs/README.md
docs/RECORDING_API.md
release-packages/webScreen-recording-v2-20260811.tar.gz
routes/index.js
services/recordingTaskService.js
test/recordingRoutes.test.js
test/recordingTaskService.test.js
config/config.json
查看文件 @
01c1cc8
...
...
@@ -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"
]
}
...
...
docs/HIGHLIGHT_DEPLOYMENT.md
查看文件 @
01c1cc8
...
...
@@ -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`
仅用于按站点批量补录,不作为正式任务入口。
## 灰度与回滚
...
...
docs/README.md
查看文件 @
01c1cc8
...
...
@@ -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`
...
...
docs/RECORDING_API.md
查看文件 @
01c1cc8
...
...
@@ -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
...
...
release-packages/webScreen-recording-v2-20260811.tar.gz
0 → 100644
查看文件 @
01c1cc8
不能预览此文件类型
routes/index.js
查看文件 @
01c1cc8
...
...
@@ -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
});
})
router
.
post
(
'/recordingTaskV2'
,
async
function
(
req
,
res
,
next
)
{
try
{
const
result
=
await
recordingTaskService
.
acceptTasks
(
req
.
body
||
{},
{
recordFullClass
:
task
=>
{
function
getRecordingTaskV2Handlers
()
{
return
{
recordFullClass
:
task
=>
new
Promise
((
resolve
,
reject
)
=>
{
new
MediaCreat
().
recordingCreat
(
task
.
classId
,
task
.
siteId
,
'post'
,
task
.
classStartTime
||
task
.
classDate
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
||
{},
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
...
...
services/recordingTaskService.js
查看文件 @
01c1cc8
...
...
@@ -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
;
}
...
...
test/recordingRoutes.test.js
查看文件 @
01c1cc8
...
...
@@ -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'
);
...
...
test/recordingTaskService.test.js
查看文件 @
01c1cc8
...
...
@@ -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
,
[
'100
1:2'
,
'100
2: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
,
[
'100
1:2'
,
'100
2: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'
);
}
...
...
请
注册
或
登录
后发表评论