25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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