import fs from 'node:fs' import path from 'node:path' import { randomBytes } from 'node:crypto' import { execFileSync } from 'node:child_process' import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData, loginAsAdmin } from './helpers' type JsonRecord = Record type Headers = Record interface Activity { module: string action: string result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const responseData = async (response: APIResponse, expected: number | number[], label: string) => { const expectedStatuses = Array.isArray(expected) ? expected : [expected] expect(expectedStatuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) return asRecord(envelopeData(await response.json())) } const addPass = ( activities: Activity[], module: string, action: string, response: APIResponse, resourceId?: string, detail?: string, ) => activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail }) async function waitForJob( request: APIRequestContext, headers: Headers, jobId: string, timeoutMs = 120_000, ) { const deadline = Date.now() + timeoutMs let latest: JsonRecord = {} while (Date.now() < deadline) { const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) expect(response.ok(), `读取数字形象任务失败:HTTP ${response.status()}`).toBeTruthy() latest = asRecord(envelopeData(await response.json())) const status = String(latest.status ?? '').toLowerCase() if (status === 'succeeded') return latest if (status === 'failed' || status === 'cancelled') { throw new Error(`数字形象任务未成功:${String(latest.errorMessage ?? latest.message ?? status)}`) } await new Promise((resolve) => setTimeout(resolve, 750)) } throw new Error(`数字形象任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`) } function createShortWebm(outputPath: string) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }) const ffmpeg = process.env.FFMPEG_BINARY?.trim() || 'ffmpeg' execFileSync(ffmpeg, [ '-y', '-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=15', '-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000', '-t', '1.8', '-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '8', '-b:v', '420k', '-c:a', 'libopus', '-b:a', '48k', outputPath, ], { stdio: 'pipe', timeout: 60_000, windowsHide: true }) const size = fs.statSync(outputPath).size expect(size, '本机 FFmpeg 应生成非空的真实 WebM 测试视频').toBeGreaterThan(10_000) } async function latestAgent( request: APIRequestContext, headers: Headers, agentId: string, ) { const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) return { response, item: response.status() === 200 ? asRecord(envelopeData(await response.json())) : {} } } async function cleanupAgent( request: APIRequestContext, headers: Headers, agentId: string, activities: Activity[], cleanupErrors: string[], ) { try { const { response, item } = await latestAgent(request, headers, agentId) if (response.status() === 404) return if (response.status() !== 200) throw new Error(`读取失败 HTTP ${response.status()}`) let current = item if (String(current.status ?? '').toLowerCase() === 'published') { const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, { headers, data: { dataVersion: Number(current.dataVersion) }, }) if (disabled.status() !== 200) throw new Error(`停用失败 HTTP ${disabled.status()}`) current = asRecord(envelopeData(await disabled.json())) } const removed = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers, params: { dataVersion: String(Number(current.dataVersion)) }, }) if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: agentId }) } catch (error) { const message = `智能体清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: agentId, detail: message }) } } async function cleanupAvatar( request: APIRequestContext, headers: Headers, avatarId: string, activities: Activity[], cleanupErrors: string[], ) { try { const detail = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) if (detail.status() === 404) return if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`) const item = asRecord(envelopeData(await detail.json())) const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers, params: { dataVersion: String(Number(item.dataVersion)) }, }) if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: avatarId }) } catch (error) { const message = `数字形象清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: avatarId, detail: message }) } } async function cleanupVideoResources( request: APIRequestContext, headers: Headers, refs: { videoId: string; folderId: string; projectId: string }, activities: Activity[], cleanupErrors: string[], ) { const cleanupDetail = async (module: string, id: string, detailPath: string, deletePath: string) => { if (!id) return try { const detail = await request.get(detailPath, { headers }) if (detail.status() === 404) return if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`) const item = asRecord(envelopeData(await detail.json())) const removed = await request.delete(deletePath, { headers, params: { dataVersion: String(Number(item.dataVersion)) }, }) if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) } catch (error) { const message = `${module}清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) } } await cleanupDetail('成片', refs.videoId, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`) if (refs.folderId) { try { const response = await request.get('/api/v1/video-folders', { headers }) if (response.status() === 200) { const payload = asRecord(envelopeData(await response.json())) const item = (Array.isArray(payload.items) ? payload.items : []) .map(asRecord) .find((candidate) => String(candidate.id ?? '') === refs.folderId) if (item) { const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(refs.folderId)}`, { headers, params: { dataVersion: String(Number(item.dataVersion)) }, }) if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: refs.folderId }) } } else { throw new Error(`读取失败 HTTP ${response.status()}`) } } catch (error) { const message = `视频文件夹清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: refs.folderId, detail: message }) } } await cleanupDetail('视频工程', refs.projectId, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`) } async function attachReport(testInfo: TestInfo, activities: Activity[], cleanupErrors: string[]) { await attachJson(testInfo, '智能体公开交付生命周期与清理', { runId, runPrefix, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '报告未记录管理员密码、访问令牌、会话令牌、WebSocket ticket 或一次性 API Key。', }) } test('数字形象、七区配置、发布、公开问答、反馈、停用与清理完整闭环', async ({ page, request }, testInfo) => { test.setTimeout(600_000) const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) // 原型的专属数字人链路明确要求上传本人视频,而不是静态头像图片。 const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') expect(fs.existsSync(fixturePath), `数字形象测试素材不存在:${fixturePath}`).toBeTruthy() let avatarId = '' let agentId = '' const videoRefs = { videoId: '', folderId: '', projectId: '' } let generatedWebmPath = '' let captureJobId = '' let captureTitle = '' let primaryError: unknown const allowedOrigin = new URL(process.env.BASE_URL || 'http://127.0.0.1:8003').origin const accessPassword = `ApE2e!${randomBytes(12).toString('hex')}A1` try { const createdAvatarResponse = await request.post('/api/v1/avatars', { headers, multipart: { name: `${runPrefix}-维修教员形象`, resize: 'true', matting: 'off', file: { name: `${runPrefix}-avatar.mp4`, mimeType: 'video/mp4', buffer: fs.readFileSync(fixturePath), }, }, timeout: 120_000, }) const avatarPayload = await responseData(createdAvatarResponse, 201, '上传创建数字形象') const createdAvatar = asRecord(avatarPayload.avatar) const avatarJob = asRecord(avatarPayload.job) avatarId = String(createdAvatar.id ?? '') const jobId = String(avatarJob.id ?? '') expect(avatarId).not.toBe('') expect(jobId).not.toBe('') addPass(activities, '数字形象', '上传原型视频并创建任务', createdAvatarResponse, avatarId) await waitForJob(request, headers, jobId) const readyResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) const readyAvatar = await responseData(readyResponse, 200, '刷新数字形象处理结果') expect(String(readyAvatar.status).toLowerCase()).toBe('ready') addPass(activities, '数字形象', '任务完成并持久化 READY 状态', readyResponse, avatarId) const editedAvatarResponse = await request.put(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers, data: { name: `${runPrefix}-维修教员形象-已编辑`, role: '装备维修教员', specialty: '液压与电气检修', gender: '男', visibility: 'internal', dataVersion: Number(readyAvatar.dataVersion), }, }) const editedAvatar = await responseData(editedAvatarResponse, 200, '编辑数字形象') expect(editedAvatar.name).toBe(`${runPrefix}-维修教员形象-已编辑`) addPass(activities, '数字形象', '编辑并刷新持久化', editedAvatarResponse, avatarId) const ttsResponse = await request.get('/api/v1/capabilities', { headers, params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' }, }) const ttsPage = await responseData(ttsResponse, 200, '读取正式 TTS 能力') const ttsItems = (Array.isArray(ttsPage.items) ? ttsPage.items : []).map(asRecord) const tts = ttsItems.find((item) => item.capabilityType === 'TTS' && item.enabled === true) const ttsId = String(tts?.id ?? '') expect(ttsId, '平台必须存在一项已启用的正式 TTS 能力').not.toBe('') addPass(activities, '视频工程', '选择平台正式 TTS 能力', ttsResponse, ttsId) const createProjectResponse = await request.post('/api/v1/video-projects', { headers, data: { name: `${runPrefix}-设备点检口播工程`, scriptText: '开始检修前,请先停机、断电、验电并确认液压系统已经卸压。', avatarId, voiceCapabilityId: ttsId, voiceSpeed: 1, orientation: 'portrait', backgroundType: 'workshop', subtitleEnabled: true, subtitleFont: 'Microsoft YaHei', subtitleFontSize: 42, subtitlePosition: 'bottom', durationMs: 1800, }, }) let videoProject = await responseData(createProjectResponse, 201, '创建视频工程') videoRefs.projectId = String(videoProject.id ?? '') expect(videoRefs.projectId).not.toBe('') expect(videoProject.voiceCapabilityId).toBe(ttsId) addPass(activities, '视频工程', '创建并绑定形象与正式 TTS', createProjectResponse, videoRefs.projectId) const updateProjectResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers, data: { name: `${runPrefix}-设备点检标准口播工程`, scriptText: '停机、断电、验电、卸压后,确认压力表归零再开始设备点检。', voiceSpeed: 1.05, dataVersion: Number(videoProject.dataVersion), }, }) videoProject = await responseData(updateProjectResponse, 200, '编辑视频工程') expect(videoProject.voiceSpeed).toBe(1.05) addPass(activities, '视频工程', '编辑工程基本参数', updateProjectResponse, videoRefs.projectId) const timelineResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}/timeline`, { headers, data: { dataVersion: Number(videoProject.dataVersion), durationMs: 1800, tracks: [ { code: 'AVATAR', name: '数字人', order: 0, clips: [{ id: `${runId}-avatar`.slice(0, 64), type: 'AVATAR', startMs: 0, endMs: 1800 }], }, { code: 'MEDIA', name: '画面素材', order: 1, clips: [{ id: `${runId}-media`.slice(0, 64), type: 'TITLE', text: '设备点检安全提示', startMs: 0, endMs: 1800 }], }, { code: 'AUDIO', name: '正式配音', order: 2, clips: [{ id: `${runId}-audio`.slice(0, 64), type: 'AUDIO', capabilityId: ttsId, startMs: 0, endMs: 1800 }], }, { code: 'SUBTITLE', name: '同步字幕', order: 3, clips: [{ id: `${runId}-subtitle`.slice(0, 64), type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 1800, style: { font: 'Microsoft YaHei', size: 42 } }], }, ], }, }) videoProject = await responseData(timelineResponse, 200, '保存四轨时间线') expect(asRecord(videoProject.timeline).tracks).toBeInstanceOf(Array) addPass(activities, '视频工程', '保存四轨时间线', timelineResponse, videoRefs.projectId) const refreshedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) videoProject = await responseData(refreshedProjectResponse, 200, '刷新视频工程') expect((asRecord(videoProject.timeline).tracks as unknown[]).map((track) => String(asRecord(track).code))) .toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) addPass(activities, '视频工程', '刷新后四轨及片段保持一致', refreshedProjectResponse, videoRefs.projectId) const createFolderResponse = await request.post('/api/v1/video-folders', { headers, data: { name: `${runPrefix}-装备点检成片`, description: 'E2E 真实采集成片临时文件夹' }, }) let videoFolder = await responseData(createFolderResponse, 201, '创建视频文件夹') videoRefs.folderId = String(videoFolder.id ?? '') expect(videoRefs.folderId).not.toBe('') addPass(activities, '视频文件夹', '创建文件夹', createFolderResponse, videoRefs.folderId) const updateFolderResponse = await request.put(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, { headers, data: { name: `${runPrefix}-装备点检正式成片`, description: '设备点检口播与安全培训成片', dataVersion: Number(videoFolder.dataVersion), }, }) videoFolder = await responseData(updateFolderResponse, 200, '编辑视频文件夹') expect(videoFolder.name).toBe(`${runPrefix}-装备点检正式成片`) addPass(activities, '视频文件夹', '编辑并持久化文件夹', updateFolderResponse, videoRefs.folderId) generatedWebmPath = testInfo.outputPath(`${runPrefix}-real-capture.webm`) createShortWebm(generatedWebmPath) captureTitle = `${runPrefix}-真实浏览器采集成片` const captureResponse = await request.post('/api/v1/captures', { headers, multipart: { kind: 'oral', title: captureTitle, avatarId, text: '停机、断电、验电、卸压后再进行设备点检。', speaker: '1', speed: '0.7', subtitleEnabled: 'true', file: { name: `${runPrefix}-real-capture.webm`, mimeType: 'video/webm', buffer: fs.readFileSync(generatedWebmPath), }, }, timeout: 120_000, }) const capture = await responseData(captureResponse, 202, '提交真实 WebM 采集文件') captureJobId = String(capture.jobId ?? '') expect(captureJobId).toMatch(/^[a-f0-9]{32}$/) addPass(activities, '视频采集', '本机 FFmpeg WebM 提交后台转码', captureResponse, captureJobId) fs.rmSync(generatedWebmPath, { force: true }) generatedWebmPath = '' const completedJob = await waitForJob(request, headers, captureJobId, 180_000) expect(String(completedJob.status).toLowerCase()).toBe('succeeded') addPass(activities, '制作队列', '真实 DB 任务完成', captureResponse, captureJobId) const jobListResponse = await request.get('/api/v1/jobs', { headers, params: { keyword: captureTitle, status: 'SUCCEEDED', page: '1', pageSize: '20' }, }) const jobList = await responseData(jobListResponse, 200, '筛选制作任务') expect((Array.isArray(jobList.items) ? jobList.items : []).map(asRecord) .some((item) => String(item.id ?? '') === captureJobId)).toBeTruthy() addPass(activities, '制作队列', '按标题和状态筛选任务', jobListResponse, captureJobId) const jobDetailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}`, { headers }) const jobDetail = await responseData(jobDetailResponse, 200, '读取任务详情') expect(String(jobDetail.status).toLowerCase()).toBe('succeeded') addPass(activities, '制作队列', '读取完成状态与任务详情', jobDetailResponse, captureJobId) const jobEventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/events`, { headers }) const jobEvents = await responseData(jobEventsResponse, 200, '读取任务事件') expect(Array.isArray(jobEvents.items) ? jobEvents.items.length : 0).toBeGreaterThan(0) addPass(activities, '制作队列', '读取真实任务事件流', jobEventsResponse, captureJobId) const jobResultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/result`, { headers }) expect(jobResultResponse.status()).toBe(200) expect(String(jobResultResponse.headers()['content-type'] ?? '')).toContain('video/mp4') expect((await jobResultResponse.body()).length).toBeGreaterThan(10_000) addPass(activities, '制作队列', '下载真实任务结果', jobResultResponse, captureJobId) const commitResponse = await request.post(`/api/v1/videos/capture-jobs/${encodeURIComponent(captureJobId)}/commit`, { headers, data: { projectId: videoRefs.projectId, folderId: videoRefs.folderId, title: `${runPrefix}-设备点检安全口播`, description: '真实 WebM 经 FFmpeg 转码并提交为平台成片。', category: '装备维修', tags: ['设备点检', '安全培训', '设备点检'], visibility: 'private', }, }) let video = await responseData(commitResponse, 201, '提交采集任务为成片') videoRefs.videoId = String(video.id ?? '') expect(videoRefs.videoId).not.toBe('') expect(video.status).toBe('ready') expect(video.folderId).toBe(videoRefs.folderId) expect(video.tags).toEqual(['设备点检', '安全培训']) addPass(activities, '成片管理', '任务提交为真实持久化成片', commitResponse, videoRefs.videoId) const linkedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) const linkedProject = await responseData(linkedProjectResponse, 200, '刷新成片关联工程') expect(linkedProject.lastVideoId).toBe(videoRefs.videoId) expect(linkedProject.status).toBe('ready') addPass(activities, '视频工程', '工程回写最新成片与 READY 状态', linkedProjectResponse, videoRefs.projectId) const protectedFileResponse = await request.get(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/file`, { headers }) expect(protectedFileResponse.status()).toBe(200) expect(String(protectedFileResponse.headers()['content-type'] ?? '')).toContain('video/mp4') expect((await protectedFileResponse.body()).length).toBeGreaterThan(10_000) addPass(activities, '成片管理', '登录态下载真实 MP4 文件', protectedFileResponse, videoRefs.videoId) const updateVideoResponse = await request.put(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, { headers, data: { title: `${runPrefix}-设备点检标准安全口播`, description: '停机、断电、验电、卸压后的标准设备点检流程。', category: '维修工艺', tags: ['点检', '安全', '点检'], visibility: 'private', folderId: videoRefs.folderId, dataVersion: Number(video.dataVersion), }, }) video = await responseData(updateVideoResponse, 200, '编辑成片信息') expect(video.title).toBe(`${runPrefix}-设备点检标准安全口播`) expect(video.tags).toEqual(['点检', '安全']) addPass(activities, '成片管理', '编辑标题、分类、标签与文件夹', updateVideoResponse, videoRefs.videoId) const sharePassword = `Vs!${randomBytes(14).toString('hex')}A1` const shareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, { headers, data: { expiresInSeconds: 3600, password: sharePassword, maxAccessCount: 1, dataVersion: Number(video.dataVersion), }, }) const limitedShare = await responseData(shareResponse, 201, '创建单次密码分享') const limitedToken = String(limitedShare.shareToken ?? '') expect(limitedToken).toContain('.') expect(String(limitedShare.tokenPreview ?? '')).not.toContain(limitedToken) addPass(activities, '成片分享', '签发密码保护且限访问一次的分享', shareResponse, videoRefs.videoId, '密码与完整令牌仅保存在内存') const rawFileDenied = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}/file`, { headers: { 'X-Share-Password': sharePassword }, }) expect(rawFileDenied.status()).toBe(404) addPass(activities, '成片分享', '未取得媒体 grant 时拒绝直接访问文件', rawFileDenied, videoRefs.videoId) const wrongPasswordResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: { 'X-Share-Password': `${sharePassword}-wrong` }, }) expect(wrongPasswordResponse.status()).toBe(404) addPass(activities, '成片分享', '错误分享密码以不存在响应拒绝,避免枚举链接', wrongPasswordResponse, videoRefs.videoId) const limitedHeaders = { 'X-Share-Password': sharePassword } const limitedDetailResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) const limitedDetail = await responseData(limitedDetailResponse, 200, '单次访问分享详情') expect(limitedDetail.title).toBe(`${runPrefix}-设备点检标准安全口播`) expect(limitedDetail.ownerId).toBeUndefined() const grantedFileUrl = String(limitedDetail.fileUrl ?? '') expect(grantedFileUrl).toContain('grantExpires=') expect(grantedFileUrl).toContain('grantSignature=') addPass(activities, '成片分享', '密码通过后签发短时媒体 grant', limitedDetailResponse, videoRefs.videoId) const grantedHeadResponse = await request.head(grantedFileUrl, { headers: limitedHeaders }) expect(grantedHeadResponse.status()).toBe(200) addPass(activities, '成片分享', '媒体 grant 支持 HEAD', grantedHeadResponse, videoRefs.videoId) const grantedRangeResponse = await request.get(grantedFileUrl, { headers: { ...limitedHeaders, Range: 'bytes=0-1023' }, }) expect(grantedRangeResponse.status()).toBe(206) expect((await grantedRangeResponse.body()).length).toBeGreaterThan(0) addPass(activities, '成片分享', '媒体 grant 支持 Range 分段播放', grantedRangeResponse, videoRefs.videoId) const grantedFileResponse = await request.get(grantedFileUrl, { headers: limitedHeaders }) expect(grantedFileResponse.status()).toBe(200) expect((await grantedFileResponse.body()).length).toBeGreaterThan(10_000) addPass(activities, '成片分享', '同一 grant 支持完整 GET 播放', grantedFileResponse, videoRefs.videoId) const exhaustedResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) expect(exhaustedResponse.status()).toBe(404) addPass(activities, '成片分享', '第二次访问因 maxAccessCount=1 被拒绝', exhaustedResponse, videoRefs.videoId) const rotatedPassword = `Vr!${randomBytes(14).toString('hex')}A1` const rotateShareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/rotate-share`, { headers, data: { expiresInSeconds: 3600, password: rotatedPassword, maxAccessCount: 2, dataVersion: Number(limitedShare.dataVersion), }, }) const rotatedShare = await responseData(rotateShareResponse, 201, '轮换成片分享') const rotatedToken = String(rotatedShare.shareToken ?? '') expect(rotatedToken).not.toBe(limitedToken) addPass(activities, '成片分享', '轮换分享令牌', rotateShareResponse, videoRefs.videoId, '新旧完整令牌均未写入报告') const oldTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) expect(oldTokenResponse.status()).toBe(404) addPass(activities, '成片分享', '轮换后旧令牌立即失效', oldTokenResponse, videoRefs.videoId) await page.goto(`/share/videos/${encodeURIComponent(rotatedToken)}`) await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible() const rotatedPasswordInput = page.locator('#share-password') await rotatedPasswordInput.fill(rotatedPassword) await page.getByRole('button', { name: '验证并播放' }).click() await expect(page.getByRole('heading', { name: `${runPrefix}-设备点检标准安全口播` })).toBeVisible({ timeout: 30_000 }) const sharedVideo = page.locator('.shared-video-player video') await expect(sharedVideo).toBeVisible() await captureScreenshot(page, testInfo, 'protected-shared-video-page', [rotatedPasswordInput]) addPass(activities, '成片分享', '公开分享页密码验证并展示成片信息', rotateShareResponse, videoRefs.videoId) const revokeShareResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, { headers, data: { reason: 'APE2E 分享闭环完成' }, }) const revoked = await responseData(revokeShareResponse, 200, '停止成片分享') expect(revoked.revoked).toBe(true) addPass(activities, '成片分享', '停止分享', revokeShareResponse, videoRefs.videoId) const revokedTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(rotatedToken)}`, { headers: { 'X-Share-Password': rotatedPassword }, }) expect(revokedTokenResponse.status()).toBe(404) addPass(activities, '成片分享', '停止后公开链接失效', revokedTokenResponse, videoRefs.videoId) const deleteVideoResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, { headers, params: { dataVersion: String(Number(rotatedShare.dataVersion)) }, }) await responseData(deleteVideoResponse, 200, '删除成片') const deletedVideoId = videoRefs.videoId videoRefs.videoId = '' addPass(activities, '成片管理', '删除成片', deleteVideoResponse, deletedVideoId) expect((await request.get(`/api/v1/videos/${encodeURIComponent(deletedVideoId)}`, { headers })).status()).toBe(404) const folderListResponse = await request.get('/api/v1/video-folders', { headers }) const folderList = await responseData(folderListResponse, 200, '刷新文件夹版本') const currentFolder = (Array.isArray(folderList.items) ? folderList.items : []).map(asRecord) .find((item) => String(item.id ?? '') === videoRefs.folderId) expect(currentFolder).toBeTruthy() const deleteFolderResponse = await request.delete(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, { headers, params: { dataVersion: String(Number(currentFolder?.dataVersion)) }, }) await responseData(deleteFolderResponse, 200, '删除视频文件夹') const deletedFolderId = videoRefs.folderId videoRefs.folderId = '' addPass(activities, '视频文件夹', '成片删除后逆序删除文件夹', deleteFolderResponse, deletedFolderId) const currentProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) const currentProject = await responseData(currentProjectResponse, 200, '刷新工程版本') const deleteProjectResponse = await request.delete(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers, params: { dataVersion: String(Number(currentProject.dataVersion)) }, }) await responseData(deleteProjectResponse, 200, '删除视频工程') const deletedProjectId = videoRefs.projectId videoRefs.projectId = '' addPass(activities, '视频工程', '成片与文件夹删除后逆序删除工程', deleteProjectResponse, deletedProjectId) expect((await request.get(`/api/v1/video-projects/${encodeURIComponent(deletedProjectId)}`, { headers })).status()).toBe(404) const createdAgentResponse = await request.post('/api/v1/realtime-agents', { headers, data: { name: `${runPrefix}-公开维修教员`, expiresAt: '2035-08-15T12:00:00+08:00', }, }) let agent = await responseData(createdAgentResponse, 201, '创建智能体草稿') agentId = String(agent.id ?? '') expect(agentId).not.toBe('') expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('') addPass(activities, '智能体', '创建草稿并一次性签发密钥', createdAgentResponse, agentId, '密钥仅在内存校验,未写入报告') const saveSection = async (section: string, data: JsonRecord, label: string) => { const response = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/sections/${section}`, { headers, data: { ...data, dataVersion: Number(agent.dataVersion) }, }) agent = await responseData(response, 200, label) addPass(activities, '智能体七区配置', label, response, agentId) } await saveSection('avatar', { avatarIds: [avatarId], avatarId }, '形象与声音区保存') await saveSection('core', { description: `${runPrefix} 装备维修公开问答验证`, systemPrompt: '你是装备维修数字教员。回答应准确、简洁,并优先引用知识库。', }, '基本信息区保存') await saveSection('identity', { llmModel: 'llm-local-main', knowledgeBaseIds: ['default'], knowledgeDocumentIds: [], interactionModes: ['text'], }, '模型与知识区保存') await saveSection('scene', { sceneBackgroundType: 'transparent', showInteractionButtons: true, background: '#0f2d32', }, '场景区保存') await saveSection('interaction', { welcomeMessage: '欢迎进入装备维修实训,请输入你的问题。', fallbackMessage: '暂未找到匹配资料,请换一种问法。', sensitiveReply: '该问题无法回答。', }, '交互区保存') await saveSection('ui', { uiPosition: 'fullscreen', brandVisible: true, uiConfig: { schemaVersion: 1, suggestedQuestions: ['如何开展设备点检?'] }, }, '界面区保存') await saveSection('delivery', { accessMode: 'internal', allowedOrigins: [], concurrencyLimit: 3, brandVisible: true, }, '交付区保存') const persistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) agent = await responseData(persistedResponse, 200, '刷新智能体并核验七区持久化') expect(agent.avatarIds).toEqual([avatarId]) expect(agent.knowledgeBaseIds).toEqual(['default']) expect(agent.interactionModes).toEqual(['text']) expect(agent.accessMode).toBe('internal') expect(agent.concurrencyLimit).toBe(3) addPass(activities, '智能体七区配置', '刷新后字段保持一致', persistedResponse, agentId) // 原型的「发布接入」是用户可编辑的正式配置,不能只在 API 层验证。 await loginAsAdmin(page) await page.goto('/jobs') await page.getByRole('tab', { name: '全部任务' }).click() const jobSearch = page.getByRole('searchbox', { name: '搜索任务' }) await jobSearch.fill(captureTitle) const jobRow = page.locator('tbody tr').filter({ hasText: captureTitle }).first() await expect(jobRow).toContainText('已完成', { timeout: 30_000 }) await jobRow.getByRole('button', { name: '详情' }).click() const jobDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last() await expect(jobDialog).toContainText(captureJobId) await expect(jobDialog).toContainText('任务已完成') await captureScreenshot(page, testInfo, 'capture-job-list-and-detail') addPass(activities, '制作队列', '页面筛选、完成状态与详情展示', jobDetailResponse, captureJobId) await page.goto(`/agents/${encodeURIComponent(agentId)}?tab=delivery`) await expect(page.getByText('访问策略', { exact: true })).toBeVisible() const accessPolicyCard = page.locator('.detail-card').filter({ hasText: '访问策略' }).last() await accessPolicyCard.locator('label').filter({ hasText: '访问方式' }).locator('select').selectOption('password') const passwordInput = accessPolicyCard.locator('input[type="password"]') await passwordInput.fill(accessPassword) const advanced = accessPolicyCard.locator('details.delivery-advanced') await advanced.locator('summary').click() await advanced.locator('textarea').fill(`${allowedOrigin}\n${allowedOrigin}`) await captureScreenshot(page, testInfo, 'agent-delivery-policy-before-save', [ passwordInput, page.locator('.delivery-field pre'), ]) await page.getByRole('button', { name: '保存配置' }).click() await expect(page.locator('.el-message').filter({ hasText: /配置已保存|保存成功/ }).last()).toBeVisible() const uiPersistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) agent = await responseData(uiPersistedResponse, 200, '刷新核验发布接入页面提交') expect(agent.accessMode).toBe('password') expect(agent.allowedOrigins).toEqual([allowedOrigin]) expect(agent.brandVisible).toBe(true) expect(agent.accessPassword).toBeUndefined() await expect(passwordInput).toHaveValue('') addPass(activities, '智能体七区配置', '发布接入页面保存密码、Origin 去重和平台标识', uiPersistedResponse, agentId) const publishResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/publish`, { headers, data: { dataVersion: Number(agent.dataVersion) }, }) agent = await responseData(publishResponse, 200, '发布智能体') expect(agent.status).toBe('published') const slug = String(agent.slug ?? '') expect(slug).not.toBe('') addPass(activities, '智能体', '发布成功', publishResponse, agentId) const integrationResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/integration`, { headers }) const integration = await responseData(integrationResponse, 200, '读取接入信息') expect(String(integration.sessionEndpoint ?? '')).toContain(slug) expect(String(integration.chatEndpoint ?? '')).toContain(slug) addPass(activities, '智能体', '接入信息端点可用', integrationResponse, agentId) const extensionResponse = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/expiration`, { headers, data: { extendDays: 30, dataVersion: Number(agent.dataVersion) }, }) agent = await responseData(extensionResponse, 200, '延长有效期') addPass(activities, '智能体', '延长有效期', extensionResponse, agentId) const rotateResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/rotate-key`, { headers, data: { dataVersion: Number(agent.dataVersion) }, }) agent = await responseData(rotateResponse, 200, '轮换一次性 API Key') expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('') addPass(activities, '智能体', '轮换一次性 API Key', rotateResponse, agentId, '新密钥仅在内存校验,未写入报告') const publicConfigResponse = await request.get(`/open/v1/realtime/${encodeURIComponent(slug)}`, { headers: { Origin: allowedOrigin }, }) const publicConfig = await responseData(publicConfigResponse, 200, '读取公开最小配置') expect(publicConfig.name).toBe(`${runPrefix}-公开维修教员`) expect(publicConfig.systemPrompt).toBeUndefined() expect(publicConfig.apiKeyPreview).toBeUndefined() addPass(activities, '公开访问', '读取公开最小配置且不泄露内部字段', publicConfigResponse, agentId) const rejectedSession = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { headers: { Origin: allowedOrigin }, data: { password: `${accessPassword}-wrong` }, }) expect(rejectedSession.status()).toBe(401) addPass(activities, '公开访问', '错误访问密码被拒绝', rejectedSession, agentId) const sessionResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { headers: { Origin: allowedOrigin }, data: { password: accessPassword }, }) const session = await responseData(sessionResponse, 201, '创建公开会话') const sessionToken = String(session.sessionToken ?? '') expect(sessionToken).toMatch(/^dhs_/) expect(String(session.websocketTicket ?? '')).toMatch(/^dhw_/) addPass(activities, '公开访问', '创建短期会话与一次性 WebSocket ticket', sessionResponse, agentId) const chatPayload = { message: '请简要说明设备点检的主要目的。', sessionToken, clientMessageId: `${runId}-public-chat`.slice(0, 64), synthesizeAudio: false, } const firstChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, { headers: { Origin: allowedOrigin }, data: chatPayload, timeout: 120_000, }) const firstChat = await responseData(firstChatResponse, 200, '公开文字问答') expect(String(firstChat.reply ?? '').length).toBeGreaterThan(0) expect(String(firstChat.requestId ?? '').length).toBeGreaterThan(0) addPass(activities, '公开访问', '真实文字问答并落库', firstChatResponse, agentId, `引用 ${Array.isArray(firstChat.citations) ? firstChat.citations.length : 0} 条`) const repeatedChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, { headers: { Origin: allowedOrigin }, data: chatPayload, timeout: 120_000, }) const repeatedChat = await responseData(repeatedChatResponse, 200, '重复 clientMessageId 幂等回放') expect(repeatedChat.requestId).toBe(firstChat.requestId) addPass(activities, '公开访问', '相同 clientMessageId 不重复生成消息', repeatedChatResponse, agentId) const feedbackResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/feedback`, { headers: { Origin: allowedOrigin }, data: { sessionToken, satisfaction: 'satisfied' }, }) const feedback = await responseData(feedbackResponse, 200, '提交满意度') expect(feedback.satisfaction).toBe('satisfied') addPass(activities, '公开访问', '满意度反馈持久化', feedbackResponse, agentId) await page.goto(`/agents/manage?keyword=${encodeURIComponent(runPrefix)}`) await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`) const persistedAgentImage = page.getByAltText(`${runPrefix}-公开维修教员形象`).first() await expect(persistedAgentImage).toBeVisible() await expect.poll( () => persistedAgentImage.evaluate((image) => (image as HTMLImageElement).naturalWidth), { message: '智能体列表中的真实数字形象缩略图必须成功加载' }, ).toBeGreaterThan(0) await captureScreenshot(page, testInfo, 'agent-published-and-persisted') await page.goto(`/live/${encodeURIComponent(slug)}`) await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`) const publicPassword = page.locator('input[type="password"]') await publicPassword.fill(accessPassword) await page.getByRole('textbox', { name: '维修问题' }).fill('请说明日常设备点检应关注哪些内容。') const publicSessionResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/sessions$/.test(new URL(response.url()).pathname)) const publicChatResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/chat$/.test(new URL(response.url()).pathname)) await page.getByRole('button', { name: '发送问题' }).click() expect((await publicSessionResponsePromise).status(), 'Live 页面应成功创建受 Origin 约束的公开会话').toBe(201) expect((await publicChatResponsePromise).status(), 'Live 页面应成功完成真实文字问答').toBe(200) await expect(page.getByText('本次回答是否有帮助?')).toBeVisible({ timeout: 120_000 }) await page.getByRole('button', { name: '满意', exact: true }).click() await expect(page.getByText('感谢反馈,已记录本次评价。')).toBeVisible() await captureScreenshot(page, testInfo, 'public-live-chat-and-feedback', [publicPassword]) addPass(activities, '公开访问', 'Live 页面密码验证、真实问答与满意度反馈', feedbackResponse, agentId) const endResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/end`, { headers: { Origin: allowedOrigin }, data: { sessionToken, result: 'normal' }, }) const ended = await responseData(endResponse, 200, '结束公开会话') expect(ended.ended).toBe(true) addPass(activities, '公开访问', '正常结束会话', endResponse, agentId) const disableResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, { headers, data: { dataVersion: Number(agent.dataVersion) }, }) agent = await responseData(disableResponse, 200, '停用智能体') expect(agent.status).toBe('disabled') addPass(activities, '智能体', '停用并持久化', disableResponse, agentId) const deniedAfterDisable = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { headers: { Origin: allowedOrigin }, data: { password: accessPassword }, }) expect([401, 404], `停用后不应再创建公开会话:HTTP ${deniedAfterDisable.status()}`).toContain(deniedAfterDisable.status()) addPass(activities, '公开访问', '停用后公开会话被拒绝', deniedAfterDisable, agentId) const deleteAgentResponse = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers, params: { dataVersion: String(Number(agent.dataVersion)) }, }) await responseData(deleteAgentResponse, 200, '删除智能体') addPass(activities, '智能体', '删除并从列表消失', deleteAgentResponse, agentId) const deletedAgentId = agentId agentId = '' const absentAgent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(deletedAgentId)}`, { headers }) expect(absentAgent.status()).toBe(404) const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) const avatarDetail = await responseData(avatarDetailResponse, 200, '删除前刷新数字形象版本') const deleteAvatarResponse = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers, params: { dataVersion: String(Number(avatarDetail.dataVersion)) }, timeout: 120_000, }) await responseData(deleteAvatarResponse, 200, '删除数字形象') addPass(activities, '数字形象', '删除并确认无残留', deleteAvatarResponse, avatarId) const deletedAvatarId = avatarId avatarId = '' const absentAvatar = await request.get(`/api/v1/avatars/${encodeURIComponent(deletedAvatarId)}`, { headers }) expect(absentAvatar.status()).toBe(404) } catch (error) { primaryError = error activities.push({ module: '智能体公开交付', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error), }) } finally { if (generatedWebmPath) fs.rmSync(generatedWebmPath, { force: true }) await cleanupVideoResources(request, headers, videoRefs, activities, cleanupErrors) if (agentId) await cleanupAgent(request, headers, agentId, activities, cleanupErrors) if (avatarId) await cleanupAvatar(request, headers, avatarId, activities, cleanupErrors) await attachReport(testInfo, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '智能体/数字形象清理必须全部成功').toEqual([]) })