您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

923 行
47 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { randomBytes } from 'node:crypto'
  4. import { execFileSync } from 'node:child_process'
  5. import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test'
  6. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  7. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  8. type JsonRecord = Record<string, unknown>
  9. type Headers = Record<string, string>
  10. interface Activity {
  11. module: string
  12. action: string
  13. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  14. httpStatus?: number
  15. resourceId?: string
  16. detail?: string
  17. }
  18. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
  19. ? value as JsonRecord
  20. : {}
  21. const responseData = async (response: APIResponse, expected: number | number[], label: string) => {
  22. const expectedStatuses = Array.isArray(expected) ? expected : [expected]
  23. expect(expectedStatuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  24. return asRecord(envelopeData(await response.json()))
  25. }
  26. const addPass = (
  27. activities: Activity[],
  28. module: string,
  29. action: string,
  30. response: APIResponse,
  31. resourceId?: string,
  32. detail?: string,
  33. ) => activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail })
  34. async function waitForJob(
  35. request: APIRequestContext,
  36. headers: Headers,
  37. jobId: string,
  38. timeoutMs = 120_000,
  39. ) {
  40. const deadline = Date.now() + timeoutMs
  41. let latest: JsonRecord = {}
  42. while (Date.now() < deadline) {
  43. const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  44. expect(response.ok(), `读取数字形象任务失败:HTTP ${response.status()}`).toBeTruthy()
  45. latest = asRecord(envelopeData(await response.json()))
  46. const status = String(latest.status ?? '').toLowerCase()
  47. if (status === 'succeeded') return latest
  48. if (status === 'failed' || status === 'cancelled') {
  49. throw new Error(`数字形象任务未成功:${String(latest.errorMessage ?? latest.message ?? status)}`)
  50. }
  51. await new Promise((resolve) => setTimeout(resolve, 750))
  52. }
  53. throw new Error(`数字形象任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`)
  54. }
  55. function createShortWebm(outputPath: string) {
  56. fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  57. const ffmpeg = process.env.FFMPEG_BINARY?.trim() || 'ffmpeg'
  58. execFileSync(ffmpeg, [
  59. '-y',
  60. '-hide_banner',
  61. '-loglevel', 'error',
  62. '-f', 'lavfi',
  63. '-i', 'testsrc2=size=640x360:rate=15',
  64. '-f', 'lavfi',
  65. '-i', 'sine=frequency=660:sample_rate=48000',
  66. '-t', '1.8',
  67. '-c:v', 'libvpx-vp9',
  68. '-deadline', 'realtime',
  69. '-cpu-used', '8',
  70. '-b:v', '420k',
  71. '-c:a', 'libopus',
  72. '-b:a', '48k',
  73. outputPath,
  74. ], { stdio: 'pipe', timeout: 60_000, windowsHide: true })
  75. const size = fs.statSync(outputPath).size
  76. expect(size, '本机 FFmpeg 应生成非空的真实 WebM 测试视频').toBeGreaterThan(10_000)
  77. }
  78. async function latestAgent(
  79. request: APIRequestContext,
  80. headers: Headers,
  81. agentId: string,
  82. ) {
  83. const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  84. return { response, item: response.status() === 200 ? asRecord(envelopeData(await response.json())) : {} }
  85. }
  86. async function cleanupAgent(
  87. request: APIRequestContext,
  88. headers: Headers,
  89. agentId: string,
  90. activities: Activity[],
  91. cleanupErrors: string[],
  92. ) {
  93. try {
  94. const { response, item } = await latestAgent(request, headers, agentId)
  95. if (response.status() === 404) return
  96. if (response.status() !== 200) throw new Error(`读取失败 HTTP ${response.status()}`)
  97. let current = item
  98. if (String(current.status ?? '').toLowerCase() === 'published') {
  99. const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
  100. headers,
  101. data: { dataVersion: Number(current.dataVersion) },
  102. })
  103. if (disabled.status() !== 200) throw new Error(`停用失败 HTTP ${disabled.status()}`)
  104. current = asRecord(envelopeData(await disabled.json()))
  105. }
  106. const removed = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
  107. headers,
  108. params: { dataVersion: String(Number(current.dataVersion)) },
  109. })
  110. if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
  111. activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: agentId })
  112. } catch (error) {
  113. const message = `智能体清理异常:${error instanceof Error ? error.message : String(error)}`
  114. cleanupErrors.push(message)
  115. activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: agentId, detail: message })
  116. }
  117. }
  118. async function cleanupAvatar(
  119. request: APIRequestContext,
  120. headers: Headers,
  121. avatarId: string,
  122. activities: Activity[],
  123. cleanupErrors: string[],
  124. ) {
  125. try {
  126. const detail = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  127. if (detail.status() === 404) return
  128. if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`)
  129. const item = asRecord(envelopeData(await detail.json()))
  130. const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
  131. headers,
  132. params: { dataVersion: String(Number(item.dataVersion)) },
  133. })
  134. if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
  135. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: avatarId })
  136. } catch (error) {
  137. const message = `数字形象清理异常:${error instanceof Error ? error.message : String(error)}`
  138. cleanupErrors.push(message)
  139. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: avatarId, detail: message })
  140. }
  141. }
  142. async function cleanupVideoResources(
  143. request: APIRequestContext,
  144. headers: Headers,
  145. refs: { videoId: string; folderId: string; projectId: string },
  146. activities: Activity[],
  147. cleanupErrors: string[],
  148. ) {
  149. const cleanupDetail = async (module: string, id: string, detailPath: string, deletePath: string) => {
  150. if (!id) return
  151. try {
  152. const detail = await request.get(detailPath, { headers })
  153. if (detail.status() === 404) return
  154. if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`)
  155. const item = asRecord(envelopeData(await detail.json()))
  156. const removed = await request.delete(deletePath, {
  157. headers,
  158. params: { dataVersion: String(Number(item.dataVersion)) },
  159. })
  160. if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
  161. activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  162. } catch (error) {
  163. const message = `${module}清理异常:${error instanceof Error ? error.message : String(error)}`
  164. cleanupErrors.push(message)
  165. activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: id, detail: message })
  166. }
  167. }
  168. await cleanupDetail('成片', refs.videoId, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`)
  169. if (refs.folderId) {
  170. try {
  171. const response = await request.get('/api/v1/video-folders', { headers })
  172. if (response.status() === 200) {
  173. const payload = asRecord(envelopeData(await response.json()))
  174. const item = (Array.isArray(payload.items) ? payload.items : [])
  175. .map(asRecord)
  176. .find((candidate) => String(candidate.id ?? '') === refs.folderId)
  177. if (item) {
  178. const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(refs.folderId)}`, {
  179. headers,
  180. params: { dataVersion: String(Number(item.dataVersion)) },
  181. })
  182. if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
  183. activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: refs.folderId })
  184. }
  185. } else {
  186. throw new Error(`读取失败 HTTP ${response.status()}`)
  187. }
  188. } catch (error) {
  189. const message = `视频文件夹清理异常:${error instanceof Error ? error.message : String(error)}`
  190. cleanupErrors.push(message)
  191. activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: refs.folderId, detail: message })
  192. }
  193. }
  194. await cleanupDetail('视频工程', refs.projectId, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`)
  195. }
  196. async function attachReport(testInfo: TestInfo, activities: Activity[], cleanupErrors: string[]) {
  197. await attachJson(testInfo, '智能体公开交付生命周期与清理', {
  198. runId,
  199. runPrefix,
  200. activities,
  201. cleanupComplete: cleanupErrors.length === 0,
  202. cleanupErrors,
  203. security: '报告未记录管理员密码、访问令牌、会话令牌、WebSocket ticket 或一次性 API Key。',
  204. })
  205. }
  206. test('数字形象、七区配置、发布、公开问答、反馈、停用与清理完整闭环', async ({ page, request }, testInfo) => {
  207. test.setTimeout(600_000)
  208. const activities: Activity[] = []
  209. const cleanupErrors: string[] = []
  210. const headers = await authHeaders(request)
  211. // 原型的专属数字人链路明确要求上传本人视频,而不是静态头像图片。
  212. const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  213. expect(fs.existsSync(fixturePath), `数字形象测试素材不存在:${fixturePath}`).toBeTruthy()
  214. let avatarId = ''
  215. let agentId = ''
  216. const videoRefs = { videoId: '', folderId: '', projectId: '' }
  217. let generatedWebmPath = ''
  218. let captureJobId = ''
  219. let captureTitle = ''
  220. let primaryError: unknown
  221. const allowedOrigin = new URL(process.env.BASE_URL || 'http://127.0.0.1:8003').origin
  222. const accessPassword = `ApE2e!${randomBytes(12).toString('hex')}A1`
  223. try {
  224. const createdAvatarResponse = await request.post('/api/v1/avatars', {
  225. headers,
  226. multipart: {
  227. name: `${runPrefix}-维修教员形象`,
  228. specialty: '机械维修教学',
  229. gender: '男',
  230. resize: 'true',
  231. matting: 'off',
  232. file: {
  233. name: `${runPrefix}-avatar.mp4`,
  234. mimeType: 'video/mp4',
  235. buffer: fs.readFileSync(fixturePath),
  236. },
  237. },
  238. timeout: 120_000,
  239. })
  240. const avatarPayload = await responseData(createdAvatarResponse, 201, '上传创建数字形象')
  241. const createdAvatar = asRecord(avatarPayload.avatar)
  242. const avatarJob = asRecord(avatarPayload.job)
  243. avatarId = String(createdAvatar.id ?? '')
  244. const jobId = String(avatarJob.id ?? '')
  245. expect(avatarId).not.toBe('')
  246. expect(jobId).not.toBe('')
  247. addPass(activities, '数字形象', '上传原型视频并创建任务', createdAvatarResponse, avatarId)
  248. await waitForJob(request, headers, jobId)
  249. const readyResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  250. const readyAvatar = await responseData(readyResponse, 200, '刷新数字形象处理结果')
  251. expect(String(readyAvatar.status).toLowerCase()).toBe('ready')
  252. addPass(activities, '数字形象', '任务完成并持久化 READY 状态', readyResponse, avatarId)
  253. const editedAvatarResponse = await request.put(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
  254. headers,
  255. data: {
  256. name: `${runPrefix}-维修教员形象-已编辑`,
  257. role: '装备维修教员',
  258. specialty: '液压与电气检修',
  259. gender: '男',
  260. visibility: 'internal',
  261. dataVersion: Number(readyAvatar.dataVersion),
  262. },
  263. })
  264. const editedAvatar = await responseData(editedAvatarResponse, 200, '编辑数字形象')
  265. expect(editedAvatar.name).toBe(`${runPrefix}-维修教员形象-已编辑`)
  266. addPass(activities, '数字形象', '编辑并刷新持久化', editedAvatarResponse, avatarId)
  267. const ttsResponse = await request.get('/api/v1/capabilities', {
  268. headers,
  269. params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' },
  270. })
  271. const ttsPage = await responseData(ttsResponse, 200, '读取正式 TTS 能力')
  272. const ttsItems = (Array.isArray(ttsPage.items) ? ttsPage.items : []).map(asRecord)
  273. const tts = ttsItems.find((item) => item.capabilityType === 'TTS' && item.enabled === true)
  274. const ttsId = String(tts?.id ?? '')
  275. expect(ttsId, '平台必须存在一项已启用的正式 TTS 能力').not.toBe('')
  276. addPass(activities, '视频工程', '选择平台正式 TTS 能力', ttsResponse, ttsId)
  277. const createProjectResponse = await request.post('/api/v1/video-projects', {
  278. headers,
  279. data: {
  280. name: `${runPrefix}-设备点检口播工程`,
  281. scriptText: '开始检修前,请先停机、断电、验电并确认液压系统已经卸压。',
  282. avatarId,
  283. voiceCapabilityId: ttsId,
  284. voiceSpeed: 1,
  285. orientation: 'portrait',
  286. backgroundType: 'workshop',
  287. subtitleEnabled: true,
  288. subtitleFont: 'Microsoft YaHei',
  289. subtitleFontSize: 42,
  290. subtitlePosition: 'bottom',
  291. durationMs: 1800,
  292. },
  293. })
  294. let videoProject = await responseData(createProjectResponse, 201, '创建视频工程')
  295. videoRefs.projectId = String(videoProject.id ?? '')
  296. expect(videoRefs.projectId).not.toBe('')
  297. expect(videoProject.voiceCapabilityId).toBe(ttsId)
  298. addPass(activities, '视频工程', '创建并绑定形象与正式 TTS', createProjectResponse, videoRefs.projectId)
  299. const updateProjectResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, {
  300. headers,
  301. data: {
  302. name: `${runPrefix}-设备点检标准口播工程`,
  303. scriptText: '停机、断电、验电、卸压后,确认压力表归零再开始设备点检。',
  304. voiceSpeed: 1.05,
  305. dataVersion: Number(videoProject.dataVersion),
  306. },
  307. })
  308. videoProject = await responseData(updateProjectResponse, 200, '编辑视频工程')
  309. expect(videoProject.voiceSpeed).toBe(1.05)
  310. addPass(activities, '视频工程', '编辑工程基本参数', updateProjectResponse, videoRefs.projectId)
  311. const timelineResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}/timeline`, {
  312. headers,
  313. data: {
  314. dataVersion: Number(videoProject.dataVersion),
  315. durationMs: 1800,
  316. tracks: [
  317. {
  318. code: 'AVATAR', name: '数字人', order: 0,
  319. clips: [{ id: `${runId}-avatar`.slice(0, 64), type: 'AVATAR', startMs: 0, endMs: 1800 }],
  320. },
  321. {
  322. code: 'MEDIA', name: '画面素材', order: 1,
  323. clips: [{ id: `${runId}-media`.slice(0, 64), type: 'TITLE', text: '设备点检安全提示', startMs: 0, endMs: 1800 }],
  324. },
  325. {
  326. code: 'AUDIO', name: '正式配音', order: 2,
  327. clips: [{ id: `${runId}-audio`.slice(0, 64), type: 'AUDIO', capabilityId: ttsId, startMs: 0, endMs: 1800 }],
  328. },
  329. {
  330. code: 'SUBTITLE', name: '同步字幕', order: 3,
  331. clips: [{ id: `${runId}-subtitle`.slice(0, 64), type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 1800, style: { font: 'Microsoft YaHei', size: 42 } }],
  332. },
  333. ],
  334. },
  335. })
  336. videoProject = await responseData(timelineResponse, 200, '保存四轨时间线')
  337. expect(asRecord(videoProject.timeline).tracks).toBeInstanceOf(Array)
  338. addPass(activities, '视频工程', '保存四轨时间线', timelineResponse, videoRefs.projectId)
  339. const refreshedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
  340. videoProject = await responseData(refreshedProjectResponse, 200, '刷新视频工程')
  341. expect((asRecord(videoProject.timeline).tracks as unknown[]).map((track) => String(asRecord(track).code)))
  342. .toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
  343. addPass(activities, '视频工程', '刷新后四轨及片段保持一致', refreshedProjectResponse, videoRefs.projectId)
  344. const createFolderResponse = await request.post('/api/v1/video-folders', {
  345. headers,
  346. data: { name: `${runPrefix}-装备点检成片`, description: 'E2E 真实采集成片临时文件夹' },
  347. })
  348. let videoFolder = await responseData(createFolderResponse, 201, '创建视频文件夹')
  349. videoRefs.folderId = String(videoFolder.id ?? '')
  350. expect(videoRefs.folderId).not.toBe('')
  351. addPass(activities, '视频文件夹', '创建文件夹', createFolderResponse, videoRefs.folderId)
  352. const updateFolderResponse = await request.put(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, {
  353. headers,
  354. data: {
  355. name: `${runPrefix}-装备点检正式成片`,
  356. description: '设备点检口播与安全培训成片',
  357. dataVersion: Number(videoFolder.dataVersion),
  358. },
  359. })
  360. videoFolder = await responseData(updateFolderResponse, 200, '编辑视频文件夹')
  361. expect(videoFolder.name).toBe(`${runPrefix}-装备点检正式成片`)
  362. addPass(activities, '视频文件夹', '编辑并持久化文件夹', updateFolderResponse, videoRefs.folderId)
  363. generatedWebmPath = testInfo.outputPath(`${runPrefix}-real-capture.webm`)
  364. createShortWebm(generatedWebmPath)
  365. captureTitle = `${runPrefix}-真实浏览器采集成片`
  366. const captureResponse = await request.post('/api/v1/captures', {
  367. headers,
  368. multipart: {
  369. kind: 'oral',
  370. title: captureTitle,
  371. avatarId,
  372. text: '停机、断电、验电、卸压后再进行设备点检。',
  373. speaker: '1',
  374. speed: '0.7',
  375. subtitleEnabled: 'true',
  376. file: {
  377. name: `${runPrefix}-real-capture.webm`,
  378. mimeType: 'video/webm',
  379. buffer: fs.readFileSync(generatedWebmPath),
  380. },
  381. },
  382. timeout: 120_000,
  383. })
  384. const capture = await responseData(captureResponse, 202, '提交真实 WebM 采集文件')
  385. captureJobId = String(capture.jobId ?? '')
  386. expect(captureJobId).toMatch(/^[a-f0-9]{32}$/)
  387. addPass(activities, '视频采集', '本机 FFmpeg WebM 提交后台转码', captureResponse, captureJobId)
  388. fs.rmSync(generatedWebmPath, { force: true })
  389. generatedWebmPath = ''
  390. const completedJob = await waitForJob(request, headers, captureJobId, 180_000)
  391. expect(String(completedJob.status).toLowerCase()).toBe('succeeded')
  392. addPass(activities, '制作队列', '真实 DB 任务完成', captureResponse, captureJobId)
  393. const jobListResponse = await request.get('/api/v1/jobs', {
  394. headers,
  395. params: { keyword: captureTitle, status: 'SUCCEEDED', page: '1', pageSize: '20' },
  396. })
  397. const jobList = await responseData(jobListResponse, 200, '筛选制作任务')
  398. expect((Array.isArray(jobList.items) ? jobList.items : []).map(asRecord)
  399. .some((item) => String(item.id ?? '') === captureJobId)).toBeTruthy()
  400. addPass(activities, '制作队列', '按标题和状态筛选任务', jobListResponse, captureJobId)
  401. const jobDetailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}`, { headers })
  402. const jobDetail = await responseData(jobDetailResponse, 200, '读取任务详情')
  403. expect(String(jobDetail.status).toLowerCase()).toBe('succeeded')
  404. addPass(activities, '制作队列', '读取完成状态与任务详情', jobDetailResponse, captureJobId)
  405. const jobEventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/events`, { headers })
  406. const jobEvents = await responseData(jobEventsResponse, 200, '读取任务事件')
  407. expect(Array.isArray(jobEvents.items) ? jobEvents.items.length : 0).toBeGreaterThan(0)
  408. addPass(activities, '制作队列', '读取真实任务事件流', jobEventsResponse, captureJobId)
  409. const jobResultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/result`, { headers })
  410. expect(jobResultResponse.status()).toBe(200)
  411. expect(String(jobResultResponse.headers()['content-type'] ?? '')).toContain('video/mp4')
  412. expect((await jobResultResponse.body()).length).toBeGreaterThan(10_000)
  413. addPass(activities, '制作队列', '下载真实任务结果', jobResultResponse, captureJobId)
  414. const commitResponse = await request.post(`/api/v1/videos/capture-jobs/${encodeURIComponent(captureJobId)}/commit`, {
  415. headers,
  416. data: {
  417. projectId: videoRefs.projectId,
  418. folderId: videoRefs.folderId,
  419. title: `${runPrefix}-设备点检安全口播`,
  420. description: '真实 WebM 经 FFmpeg 转码并提交为平台成片。',
  421. category: '装备维修',
  422. tags: ['设备点检', '安全培训', '设备点检'],
  423. visibility: 'private',
  424. },
  425. })
  426. let video = await responseData(commitResponse, 201, '提交采集任务为成片')
  427. videoRefs.videoId = String(video.id ?? '')
  428. expect(videoRefs.videoId).not.toBe('')
  429. expect(video.status).toBe('ready')
  430. expect(video.folderId).toBe(videoRefs.folderId)
  431. expect(video.tags).toEqual(['设备点检', '安全培训'])
  432. addPass(activities, '成片管理', '任务提交为真实持久化成片', commitResponse, videoRefs.videoId)
  433. const linkedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
  434. const linkedProject = await responseData(linkedProjectResponse, 200, '刷新成片关联工程')
  435. expect(linkedProject.lastVideoId).toBe(videoRefs.videoId)
  436. expect(linkedProject.status).toBe('ready')
  437. addPass(activities, '视频工程', '工程回写最新成片与 READY 状态', linkedProjectResponse, videoRefs.projectId)
  438. const protectedFileResponse = await request.get(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/file`, { headers })
  439. expect(protectedFileResponse.status()).toBe(200)
  440. expect(String(protectedFileResponse.headers()['content-type'] ?? '')).toContain('video/mp4')
  441. expect((await protectedFileResponse.body()).length).toBeGreaterThan(10_000)
  442. addPass(activities, '成片管理', '登录态下载真实 MP4 文件', protectedFileResponse, videoRefs.videoId)
  443. const updateVideoResponse = await request.put(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, {
  444. headers,
  445. data: {
  446. title: `${runPrefix}-设备点检标准安全口播`,
  447. description: '停机、断电、验电、卸压后的标准设备点检流程。',
  448. category: '维修工艺',
  449. tags: ['点检', '安全', '点检'],
  450. visibility: 'private',
  451. folderId: videoRefs.folderId,
  452. dataVersion: Number(video.dataVersion),
  453. },
  454. })
  455. video = await responseData(updateVideoResponse, 200, '编辑成片信息')
  456. expect(video.title).toBe(`${runPrefix}-设备点检标准安全口播`)
  457. expect(video.tags).toEqual(['点检', '安全'])
  458. addPass(activities, '成片管理', '编辑标题、分类、标签与文件夹', updateVideoResponse, videoRefs.videoId)
  459. const sharePassword = `Vs!${randomBytes(14).toString('hex')}A1`
  460. const shareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, {
  461. headers,
  462. data: {
  463. expiresInSeconds: 3600,
  464. password: sharePassword,
  465. maxAccessCount: 1,
  466. dataVersion: Number(video.dataVersion),
  467. },
  468. })
  469. const limitedShare = await responseData(shareResponse, 201, '创建单次密码分享')
  470. const limitedToken = String(limitedShare.shareToken ?? '')
  471. expect(limitedToken).toContain('.')
  472. expect(String(limitedShare.tokenPreview ?? '')).not.toContain(limitedToken)
  473. addPass(activities, '成片分享', '签发密码保护且限访问一次的分享', shareResponse, videoRefs.videoId, '密码与完整令牌仅保存在内存')
  474. const rawFileDenied = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}/file`, {
  475. headers: { 'X-Share-Password': sharePassword },
  476. })
  477. expect(rawFileDenied.status()).toBe(404)
  478. addPass(activities, '成片分享', '未取得媒体 grant 时拒绝直接访问文件', rawFileDenied, videoRefs.videoId)
  479. const wrongPasswordResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, {
  480. headers: { 'X-Share-Password': `${sharePassword}-wrong` },
  481. })
  482. expect(wrongPasswordResponse.status()).toBe(404)
  483. addPass(activities, '成片分享', '错误分享密码以不存在响应拒绝,避免枚举链接', wrongPasswordResponse, videoRefs.videoId)
  484. const limitedHeaders = { 'X-Share-Password': sharePassword }
  485. const limitedDetailResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
  486. const limitedDetail = await responseData(limitedDetailResponse, 200, '单次访问分享详情')
  487. expect(limitedDetail.title).toBe(`${runPrefix}-设备点检标准安全口播`)
  488. expect(limitedDetail.ownerId).toBeUndefined()
  489. const grantedFileUrl = String(limitedDetail.fileUrl ?? '')
  490. expect(grantedFileUrl).toContain('grantExpires=')
  491. expect(grantedFileUrl).toContain('grantSignature=')
  492. addPass(activities, '成片分享', '密码通过后签发短时媒体 grant', limitedDetailResponse, videoRefs.videoId)
  493. const grantedHeadResponse = await request.head(grantedFileUrl, { headers: limitedHeaders })
  494. expect(grantedHeadResponse.status()).toBe(200)
  495. addPass(activities, '成片分享', '媒体 grant 支持 HEAD', grantedHeadResponse, videoRefs.videoId)
  496. const grantedRangeResponse = await request.get(grantedFileUrl, {
  497. headers: { ...limitedHeaders, Range: 'bytes=0-1023' },
  498. })
  499. expect(grantedRangeResponse.status()).toBe(206)
  500. expect((await grantedRangeResponse.body()).length).toBeGreaterThan(0)
  501. addPass(activities, '成片分享', '媒体 grant 支持 Range 分段播放', grantedRangeResponse, videoRefs.videoId)
  502. const grantedFileResponse = await request.get(grantedFileUrl, { headers: limitedHeaders })
  503. expect(grantedFileResponse.status()).toBe(200)
  504. expect((await grantedFileResponse.body()).length).toBeGreaterThan(10_000)
  505. addPass(activities, '成片分享', '同一 grant 支持完整 GET 播放', grantedFileResponse, videoRefs.videoId)
  506. const exhaustedResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
  507. expect(exhaustedResponse.status()).toBe(404)
  508. addPass(activities, '成片分享', '第二次访问因 maxAccessCount=1 被拒绝', exhaustedResponse, videoRefs.videoId)
  509. const rotatedPassword = `Vr!${randomBytes(14).toString('hex')}A1`
  510. const rotateShareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/rotate-share`, {
  511. headers,
  512. data: {
  513. expiresInSeconds: 3600,
  514. password: rotatedPassword,
  515. maxAccessCount: 2,
  516. dataVersion: Number(limitedShare.dataVersion),
  517. },
  518. })
  519. const rotatedShare = await responseData(rotateShareResponse, 201, '轮换成片分享')
  520. const rotatedToken = String(rotatedShare.shareToken ?? '')
  521. expect(rotatedToken).not.toBe(limitedToken)
  522. addPass(activities, '成片分享', '轮换分享令牌', rotateShareResponse, videoRefs.videoId, '新旧完整令牌均未写入报告')
  523. const oldTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
  524. expect(oldTokenResponse.status()).toBe(404)
  525. addPass(activities, '成片分享', '轮换后旧令牌立即失效', oldTokenResponse, videoRefs.videoId)
  526. await page.goto(`/share/videos/${encodeURIComponent(rotatedToken)}`)
  527. await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible()
  528. const rotatedPasswordInput = page.locator('#share-password')
  529. await rotatedPasswordInput.fill(rotatedPassword)
  530. await page.getByRole('button', { name: '验证并播放' }).click()
  531. await expect(page.getByRole('heading', { name: `${runPrefix}-设备点检标准安全口播` })).toBeVisible({ timeout: 30_000 })
  532. const sharedVideo = page.locator('.shared-video-player video')
  533. await expect(sharedVideo).toBeVisible()
  534. await captureScreenshot(page, testInfo, 'protected-shared-video-page', [rotatedPasswordInput])
  535. addPass(activities, '成片分享', '公开分享页密码验证并展示成片信息', rotateShareResponse, videoRefs.videoId)
  536. const revokeShareResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, {
  537. headers,
  538. data: { reason: 'APE2E 分享闭环完成' },
  539. })
  540. const revoked = await responseData(revokeShareResponse, 200, '停止成片分享')
  541. expect(revoked.revoked).toBe(true)
  542. addPass(activities, '成片分享', '停止分享', revokeShareResponse, videoRefs.videoId)
  543. const revokedTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(rotatedToken)}`, {
  544. headers: { 'X-Share-Password': rotatedPassword },
  545. })
  546. expect(revokedTokenResponse.status()).toBe(404)
  547. addPass(activities, '成片分享', '停止后公开链接失效', revokedTokenResponse, videoRefs.videoId)
  548. const deleteVideoResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, {
  549. headers,
  550. params: { dataVersion: String(Number(rotatedShare.dataVersion)) },
  551. })
  552. await responseData(deleteVideoResponse, 200, '删除成片')
  553. const deletedVideoId = videoRefs.videoId
  554. videoRefs.videoId = ''
  555. addPass(activities, '成片管理', '删除成片', deleteVideoResponse, deletedVideoId)
  556. expect((await request.get(`/api/v1/videos/${encodeURIComponent(deletedVideoId)}`, { headers })).status()).toBe(404)
  557. const folderListResponse = await request.get('/api/v1/video-folders', { headers })
  558. const folderList = await responseData(folderListResponse, 200, '刷新文件夹版本')
  559. const currentFolder = (Array.isArray(folderList.items) ? folderList.items : []).map(asRecord)
  560. .find((item) => String(item.id ?? '') === videoRefs.folderId)
  561. expect(currentFolder).toBeTruthy()
  562. const deleteFolderResponse = await request.delete(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, {
  563. headers,
  564. params: { dataVersion: String(Number(currentFolder?.dataVersion)) },
  565. })
  566. await responseData(deleteFolderResponse, 200, '删除视频文件夹')
  567. const deletedFolderId = videoRefs.folderId
  568. videoRefs.folderId = ''
  569. addPass(activities, '视频文件夹', '成片删除后逆序删除文件夹', deleteFolderResponse, deletedFolderId)
  570. const currentProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
  571. const currentProject = await responseData(currentProjectResponse, 200, '刷新工程版本')
  572. const deleteProjectResponse = await request.delete(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, {
  573. headers,
  574. params: { dataVersion: String(Number(currentProject.dataVersion)) },
  575. })
  576. await responseData(deleteProjectResponse, 200, '删除视频工程')
  577. const deletedProjectId = videoRefs.projectId
  578. videoRefs.projectId = ''
  579. addPass(activities, '视频工程', '成片与文件夹删除后逆序删除工程', deleteProjectResponse, deletedProjectId)
  580. expect((await request.get(`/api/v1/video-projects/${encodeURIComponent(deletedProjectId)}`, { headers })).status()).toBe(404)
  581. const createdAgentResponse = await request.post('/api/v1/realtime-agents', {
  582. headers,
  583. data: {
  584. name: `${runPrefix}-公开维修教员`,
  585. expiresAt: '2035-08-15T12:00:00+08:00',
  586. },
  587. })
  588. let agent = await responseData(createdAgentResponse, 201, '创建智能体草稿')
  589. agentId = String(agent.id ?? '')
  590. expect(agentId).not.toBe('')
  591. expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('')
  592. addPass(activities, '智能体', '创建草稿并一次性签发密钥', createdAgentResponse, agentId, '密钥仅在内存校验,未写入报告')
  593. const saveSection = async (section: string, data: JsonRecord, label: string) => {
  594. const response = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/sections/${section}`, {
  595. headers,
  596. data: { ...data, dataVersion: Number(agent.dataVersion) },
  597. })
  598. agent = await responseData(response, 200, label)
  599. addPass(activities, '智能体七区配置', label, response, agentId)
  600. }
  601. await saveSection('avatar', { avatarIds: [avatarId], avatarId }, '形象与声音区保存')
  602. await saveSection('core', {
  603. description: `${runPrefix} 装备维修公开问答验证`,
  604. systemPrompt: '你是装备维修数字教员。回答应准确、简洁,并优先引用知识库。',
  605. }, '基本信息区保存')
  606. await saveSection('identity', {
  607. llmModel: 'llm-local-main',
  608. knowledgeBaseIds: ['default'],
  609. knowledgeDocumentIds: [],
  610. interactionModes: ['text'],
  611. }, '模型与知识区保存')
  612. await saveSection('scene', {
  613. sceneBackgroundType: 'transparent',
  614. showInteractionButtons: true,
  615. background: '#0f2d32',
  616. }, '场景区保存')
  617. await saveSection('interaction', {
  618. welcomeMessage: '欢迎进入装备维修实训,请输入你的问题。',
  619. fallbackMessage: '暂未找到匹配资料,请换一种问法。',
  620. sensitiveReply: '该问题无法回答。',
  621. }, '交互区保存')
  622. await saveSection('ui', {
  623. uiPosition: 'fullscreen',
  624. brandVisible: true,
  625. uiConfig: { schemaVersion: 1, suggestedQuestions: ['如何开展设备点检?'] },
  626. }, '界面区保存')
  627. await saveSection('delivery', {
  628. accessMode: 'internal',
  629. allowedOrigins: [],
  630. concurrencyLimit: 3,
  631. brandVisible: true,
  632. }, '交付区保存')
  633. const persistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  634. agent = await responseData(persistedResponse, 200, '刷新智能体并核验七区持久化')
  635. expect(agent.avatarIds).toEqual([avatarId])
  636. expect(agent.knowledgeBaseIds).toEqual(['default'])
  637. expect(agent.interactionModes).toEqual(['text'])
  638. expect(agent.accessMode).toBe('internal')
  639. expect(agent.concurrencyLimit).toBe(3)
  640. addPass(activities, '智能体七区配置', '刷新后字段保持一致', persistedResponse, agentId)
  641. // 原型的「发布接入」是用户可编辑的正式配置,不能只在 API 层验证。
  642. await loginAsAdmin(page)
  643. await page.goto('/jobs')
  644. await page.getByRole('tab', { name: '全部任务' }).click()
  645. const jobSearch = page.getByRole('searchbox', { name: '搜索任务' })
  646. await jobSearch.fill(captureTitle)
  647. const jobRow = page.locator('tbody tr').filter({ hasText: captureTitle }).first()
  648. await expect(jobRow).toContainText('已完成', { timeout: 30_000 })
  649. await jobRow.getByRole('button', { name: '详情' }).click()
  650. const jobDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last()
  651. await expect(jobDialog).toContainText(captureJobId)
  652. await expect(jobDialog).toContainText('任务已完成')
  653. await captureScreenshot(page, testInfo, 'capture-job-list-and-detail')
  654. addPass(activities, '制作队列', '页面筛选、完成状态与详情展示', jobDetailResponse, captureJobId)
  655. await page.goto(`/agents/${encodeURIComponent(agentId)}?tab=delivery`)
  656. await expect(page.getByText('访问策略', { exact: true })).toBeVisible()
  657. const accessPolicyCard = page.locator('.detail-card').filter({ hasText: '访问策略' }).last()
  658. await accessPolicyCard.locator('label').filter({ hasText: '访问方式' }).locator('select').selectOption('password')
  659. const passwordInput = accessPolicyCard.locator('input[type="password"]')
  660. await passwordInput.fill(accessPassword)
  661. const advanced = accessPolicyCard.locator('details.delivery-advanced')
  662. await advanced.locator('summary').click()
  663. await advanced.locator('textarea').fill(`${allowedOrigin}\n${allowedOrigin}`)
  664. await captureScreenshot(page, testInfo, 'agent-delivery-policy-before-save', [
  665. passwordInput,
  666. page.locator('.delivery-field pre'),
  667. ])
  668. await page.getByRole('button', { name: '保存配置' }).click()
  669. await expect(page.locator('.el-message').filter({ hasText: /配置已保存|保存成功/ }).last()).toBeVisible()
  670. const uiPersistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  671. agent = await responseData(uiPersistedResponse, 200, '刷新核验发布接入页面提交')
  672. expect(agent.accessMode).toBe('password')
  673. expect(agent.allowedOrigins).toEqual([allowedOrigin])
  674. expect(agent.brandVisible).toBe(true)
  675. expect(agent.accessPassword).toBeUndefined()
  676. await expect(passwordInput).toHaveValue('')
  677. addPass(activities, '智能体七区配置', '发布接入页面保存密码、Origin 去重和平台标识', uiPersistedResponse, agentId)
  678. const publishResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/publish`, {
  679. headers,
  680. data: { dataVersion: Number(agent.dataVersion) },
  681. })
  682. agent = await responseData(publishResponse, 200, '发布智能体')
  683. expect(agent.status).toBe('published')
  684. const slug = String(agent.slug ?? '')
  685. expect(slug).not.toBe('')
  686. addPass(activities, '智能体', '发布成功', publishResponse, agentId)
  687. const integrationResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/integration`, { headers })
  688. const integration = await responseData(integrationResponse, 200, '读取接入信息')
  689. expect(String(integration.sessionEndpoint ?? '')).toContain(slug)
  690. expect(String(integration.chatEndpoint ?? '')).toContain(slug)
  691. addPass(activities, '智能体', '接入信息端点可用', integrationResponse, agentId)
  692. const extensionResponse = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/expiration`, {
  693. headers,
  694. data: { extendDays: 30, dataVersion: Number(agent.dataVersion) },
  695. })
  696. agent = await responseData(extensionResponse, 200, '延长有效期')
  697. addPass(activities, '智能体', '延长有效期', extensionResponse, agentId)
  698. const rotateResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/rotate-key`, {
  699. headers,
  700. data: { dataVersion: Number(agent.dataVersion) },
  701. })
  702. agent = await responseData(rotateResponse, 200, '轮换一次性 API Key')
  703. expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('')
  704. addPass(activities, '智能体', '轮换一次性 API Key', rotateResponse, agentId, '新密钥仅在内存校验,未写入报告')
  705. const publicConfigResponse = await request.get(`/open/v1/realtime/${encodeURIComponent(slug)}`, {
  706. headers: { Origin: allowedOrigin },
  707. })
  708. const publicConfig = await responseData(publicConfigResponse, 200, '读取公开最小配置')
  709. expect(publicConfig.name).toBe(`${runPrefix}-公开维修教员`)
  710. expect(publicConfig.systemPrompt).toBeUndefined()
  711. expect(publicConfig.apiKeyPreview).toBeUndefined()
  712. addPass(activities, '公开访问', '读取公开最小配置且不泄露内部字段', publicConfigResponse, agentId)
  713. const rejectedSession = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
  714. headers: { Origin: allowedOrigin },
  715. data: { password: `${accessPassword}-wrong` },
  716. })
  717. expect(rejectedSession.status()).toBe(401)
  718. addPass(activities, '公开访问', '错误访问密码被拒绝', rejectedSession, agentId)
  719. const sessionResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
  720. headers: { Origin: allowedOrigin },
  721. data: { password: accessPassword },
  722. })
  723. const session = await responseData(sessionResponse, 201, '创建公开会话')
  724. const sessionToken = String(session.sessionToken ?? '')
  725. expect(sessionToken).toMatch(/^dhs_/)
  726. expect(String(session.websocketTicket ?? '')).toMatch(/^dhw_/)
  727. addPass(activities, '公开访问', '创建短期会话与一次性 WebSocket ticket', sessionResponse, agentId)
  728. const chatPayload = {
  729. message: '请简要说明设备点检的主要目的。',
  730. sessionToken,
  731. clientMessageId: `${runId}-public-chat`.slice(0, 64),
  732. synthesizeAudio: false,
  733. }
  734. const firstChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, {
  735. headers: { Origin: allowedOrigin },
  736. data: chatPayload,
  737. timeout: 120_000,
  738. })
  739. const firstChat = await responseData(firstChatResponse, 200, '公开文字问答')
  740. expect(String(firstChat.reply ?? '').length).toBeGreaterThan(0)
  741. expect(String(firstChat.requestId ?? '').length).toBeGreaterThan(0)
  742. addPass(activities, '公开访问', '真实文字问答并落库', firstChatResponse, agentId, `引用 ${Array.isArray(firstChat.citations) ? firstChat.citations.length : 0} 条`)
  743. const repeatedChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, {
  744. headers: { Origin: allowedOrigin },
  745. data: chatPayload,
  746. timeout: 120_000,
  747. })
  748. const repeatedChat = await responseData(repeatedChatResponse, 200, '重复 clientMessageId 幂等回放')
  749. expect(repeatedChat.requestId).toBe(firstChat.requestId)
  750. addPass(activities, '公开访问', '相同 clientMessageId 不重复生成消息', repeatedChatResponse, agentId)
  751. const feedbackResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/feedback`, {
  752. headers: { Origin: allowedOrigin },
  753. data: { sessionToken, satisfaction: 'satisfied' },
  754. })
  755. const feedback = await responseData(feedbackResponse, 200, '提交满意度')
  756. expect(feedback.satisfaction).toBe('satisfied')
  757. addPass(activities, '公开访问', '满意度反馈持久化', feedbackResponse, agentId)
  758. await page.goto(`/agents/manage?keyword=${encodeURIComponent(runPrefix)}`)
  759. await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`)
  760. const persistedAgentImage = page.getByAltText(`${runPrefix}-公开维修教员形象`).first()
  761. await expect(persistedAgentImage).toBeVisible()
  762. await expect.poll(
  763. () => persistedAgentImage.evaluate((image) => (image as HTMLImageElement).naturalWidth),
  764. { message: '智能体列表中的真实数字形象缩略图必须成功加载' },
  765. ).toBeGreaterThan(0)
  766. await captureScreenshot(page, testInfo, 'agent-published-and-persisted')
  767. await page.goto(`/live/${encodeURIComponent(slug)}`)
  768. await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`)
  769. const publicPassword = page.locator('input[type="password"]')
  770. await publicPassword.fill(accessPassword)
  771. await page.getByRole('textbox', { name: '维修问题' }).fill('请说明日常设备点检应关注哪些内容。')
  772. const publicSessionResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/sessions$/.test(new URL(response.url()).pathname))
  773. const publicChatResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/chat$/.test(new URL(response.url()).pathname))
  774. await page.getByRole('button', { name: '发送问题' }).click()
  775. expect((await publicSessionResponsePromise).status(), 'Live 页面应成功创建受 Origin 约束的公开会话').toBe(201)
  776. expect((await publicChatResponsePromise).status(), 'Live 页面应成功完成真实文字问答').toBe(200)
  777. await expect(page.getByText('本次回答是否有帮助?')).toBeVisible({ timeout: 120_000 })
  778. await page.getByRole('button', { name: '满意', exact: true }).click()
  779. await expect(page.getByText('感谢反馈,已记录本次评价。')).toBeVisible()
  780. await captureScreenshot(page, testInfo, 'public-live-chat-and-feedback', [publicPassword])
  781. addPass(activities, '公开访问', 'Live 页面密码验证、真实问答与满意度反馈', feedbackResponse, agentId)
  782. const endResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/end`, {
  783. headers: { Origin: allowedOrigin },
  784. data: { sessionToken, result: 'normal' },
  785. })
  786. const ended = await responseData(endResponse, 200, '结束公开会话')
  787. expect(ended.ended).toBe(true)
  788. addPass(activities, '公开访问', '正常结束会话', endResponse, agentId)
  789. const disableResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
  790. headers,
  791. data: { dataVersion: Number(agent.dataVersion) },
  792. })
  793. agent = await responseData(disableResponse, 200, '停用智能体')
  794. expect(agent.status).toBe('disabled')
  795. addPass(activities, '智能体', '停用并持久化', disableResponse, agentId)
  796. const deniedAfterDisable = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
  797. headers: { Origin: allowedOrigin },
  798. data: { password: accessPassword },
  799. })
  800. expect([401, 404], `停用后不应再创建公开会话:HTTP ${deniedAfterDisable.status()}`).toContain(deniedAfterDisable.status())
  801. addPass(activities, '公开访问', '停用后公开会话被拒绝', deniedAfterDisable, agentId)
  802. const deleteAgentResponse = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
  803. headers,
  804. params: { dataVersion: String(Number(agent.dataVersion)) },
  805. })
  806. await responseData(deleteAgentResponse, 200, '删除智能体')
  807. addPass(activities, '智能体', '删除并从列表消失', deleteAgentResponse, agentId)
  808. const deletedAgentId = agentId
  809. agentId = ''
  810. const absentAgent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(deletedAgentId)}`, { headers })
  811. expect(absentAgent.status()).toBe(404)
  812. const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  813. const avatarDetail = await responseData(avatarDetailResponse, 200, '删除前刷新数字形象版本')
  814. const deleteAvatarResponse = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
  815. headers,
  816. params: { dataVersion: String(Number(avatarDetail.dataVersion)) },
  817. timeout: 120_000,
  818. })
  819. await responseData(deleteAvatarResponse, 200, '删除数字形象')
  820. addPass(activities, '数字形象', '删除并确认无残留', deleteAvatarResponse, avatarId)
  821. const deletedAvatarId = avatarId
  822. avatarId = ''
  823. const absentAvatar = await request.get(`/api/v1/avatars/${encodeURIComponent(deletedAvatarId)}`, { headers })
  824. expect(absentAvatar.status()).toBe(404)
  825. } catch (error) {
  826. primaryError = error
  827. activities.push({
  828. module: '智能体公开交付',
  829. action: '主流程',
  830. result: 'FAIL',
  831. detail: error instanceof Error ? error.message : String(error),
  832. })
  833. } finally {
  834. if (generatedWebmPath) fs.rmSync(generatedWebmPath, { force: true })
  835. await cleanupVideoResources(request, headers, videoRefs, activities, cleanupErrors)
  836. if (agentId) await cleanupAgent(request, headers, agentId, activities, cleanupErrors)
  837. if (avatarId) await cleanupAvatar(request, headers, avatarId, activities, cleanupErrors)
  838. await attachReport(testInfo, activities, cleanupErrors)
  839. }
  840. if (primaryError) throw primaryError
  841. expect(cleanupErrors, '智能体/数字形象清理必须全部成功').toEqual([])
  842. })