/** * RVW V4.0 期刊配置中心 E2E 脚本 * * 覆盖链路: * 1) 登录(密码) * 2) 期刊配置(journalLanguage + editorialBaseStandard) * 3) 业务端上传稿件并执行审评 * 4) 中英文场景使用不同 Skills * 5) 轮询任务完成并输出结果快照 * * 运行方式(PowerShell): * npx tsx scripts/test-rvw-journal-e2e.ts * * 可选环境变量: * - RVW_E2E_BASE_URL=http://localhost:3001 * - RVW_E2E_PHONE=13900139001 * - RVW_E2E_PASSWORD=Test@1234 * - RVW_E2E_ZH_TENANT_CODE=cmj * - RVW_E2E_EN_TENANT_CODE=jtim * - RVW_E2E_POLL_INTERVAL_MS=8000 * - RVW_E2E_MAX_POLLS=180 */ import axios, { AxiosError, AxiosInstance } from 'axios'; import FormData from 'form-data'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { PrismaClient } from '@prisma/client'; type AgentType = 'editorial' | 'methodology' | 'clinical'; type JournalLanguage = 'ZH' | 'EN' | 'OTHER'; type EditorialBaseStandard = 'zh' | 'en'; type TaskStatus = 'pending' | 'extracting' | 'reviewing' | 'completed' | 'partial_completed' | 'failed'; interface LoginResponse { success: boolean; data?: { tokens?: { accessToken?: string; }; }; message?: string; } interface JournalItem { id: string; code: string; name: string; journalLanguage?: JournalLanguage | null; } interface JournalListResponse { success: boolean; data: JournalItem[]; } interface CreateTaskResponse { success: boolean; data?: { taskId: string; }; message?: string; } interface RunTaskResponse { success: boolean; data?: { taskId: string; jobId: string; }; message?: string; } interface TaskDetailResponse { success: boolean; data?: { id: string; status: TaskStatus; errorMessage?: string | null; overallScore?: number | null; editorialScore?: number | null; methodologyScore?: number | null; modelUsed?: string | null; completedAt?: string | null; }; } interface Scenario { id: string; name: string; tenantCode: string; journalLanguage: JournalLanguage; editorialBaseStandard: EditorialBaseStandard; filePath: string; agents: AgentType[]; } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..', '..'); const BASE_URL = process.env.RVW_E2E_BASE_URL || 'http://localhost:3001'; const PHONE = process.env.RVW_E2E_PHONE || '13900139001'; const PASSWORD = process.env.RVW_E2E_PASSWORD || 'Test@1234'; const ZH_TENANT_CODE = (process.env.RVW_E2E_ZH_TENANT_CODE || 'cmj').toLowerCase(); const EN_TENANT_CODE = (process.env.RVW_E2E_EN_TENANT_CODE || 'jtim').toLowerCase(); const POLL_INTERVAL_MS = Number(process.env.RVW_E2E_POLL_INTERVAL_MS || 8000); const MAX_POLLS = Number(process.env.RVW_E2E_MAX_POLLS || 180); const ALLOW_DB_FALLBACK = (process.env.RVW_E2E_ALLOW_DB_FALLBACK || 'true') === 'true'; const SCENARIO_FILTER = (process.env.RVW_E2E_SCENARIOS || '').trim(); const prisma = new PrismaClient(); const scenarios: Scenario[] = [ { id: 'zh-docx', name: '中文期刊场景(稿约+方法学)', tenantCode: ZH_TENANT_CODE, journalLanguage: 'ZH', editorialBaseStandard: 'zh', filePath: path.resolve( repoRoot, 'docs/03-业务模块/RVW-稿件审查系统/05-测试文档/骶骨瘤患者围术期大量输血的术前危险因素分析及输血策略2月27 - 副本.docx' ), agents: ['editorial', 'methodology'], }, { id: 'en-pdf', name: '英文期刊场景(稿约+临床)', tenantCode: EN_TENANT_CODE, journalLanguage: 'EN', editorialBaseStandard: 'en', filePath: path.resolve( repoRoot, 'docs/03-业务模块/RVW-稿件审查系统/05-测试文档/Dongen 2003.pdf' ), agents: ['editorial', 'clinical'], }, ]; function getEnabledScenarios(): Scenario[] { if (!SCENARIO_FILTER) return scenarios; const ids = new Set( SCENARIO_FILTER .split(',') .map(s => s.trim()) .filter(Boolean) ); return scenarios.filter(s => ids.has(s.id)); } function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } function createClient(token?: string): AxiosInstance { const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; return axios.create({ baseURL: BASE_URL, timeout: 120000, headers, validateStatus: () => true, }); } function unwrapError(error: unknown): string { if (axios.isAxiosError(error)) { const e = error as AxiosError; const status = e.response?.status; const message = (e.response?.data as any)?.message || e.message; return `[HTTP ${status ?? 'N/A'}] ${message}`; } return error instanceof Error ? error.message : String(error); } async function loginByPassword(): Promise { const client = createClient(); const resp = await client.post('/api/v1/auth/login/password', { phone: PHONE, password: PASSWORD, }); if (resp.status !== 200 || !resp.data?.success || !resp.data?.data?.tokens?.accessToken) { throw new Error(`登录失败: ${resp.status} ${resp.data?.message || ''}`.trim()); } return resp.data.data.tokens.accessToken; } async function getJournalByCode(client: AxiosInstance, code: string): Promise { const resp = await client.get('/api/admin/journal-configs', { params: { search: code, page: 1, limit: 200 }, }); if (resp.status !== 200 || !resp.data?.success || !Array.isArray(resp.data.data)) { throw new Error(`获取期刊列表失败: ${resp.status}`); } const exact = resp.data.data.find(item => item.code.toLowerCase() === code.toLowerCase()); if (!exact) { throw new Error(`未找到期刊租户 code=${code},请先在管理端创建并启用 JOURNAL 租户`); } return exact; } async function getJournalByCodeFromDb(code: string): Promise { const tenant = await prisma.tenants.findUnique({ where: { code }, select: { id: true, code: true, name: true, journal_language: true }, }); if (!tenant) { throw new Error(`DB fallback 失败:未找到租户 code=${code}`); } return { id: tenant.id, code: tenant.code, name: tenant.name, journalLanguage: (tenant.journal_language as JournalLanguage | null) ?? null, }; } async function configureJournal( client: AxiosInstance, journalId: string, journalLanguage: JournalLanguage, editorialBaseStandard: EditorialBaseStandard ) { const basicResp = await client.put(`/api/admin/journal-configs/${journalId}/basic-info`, { journalLanguage, }); if (basicResp.status < 200 || basicResp.status >= 300) { throw new Error(`更新期刊基础信息失败: ${basicResp.status} ${(basicResp.data as any)?.message || ''}`.trim()); } const rvwResp = await client.put(`/api/admin/journal-configs/${journalId}/rvw-config`, { editorialBaseStandard, // 继承系统默认,验证 null 语义 editorialExpertPrompt: null, methodologyExpertPrompt: null, dataForensicsExpertPrompt: null, clinicalExpertPrompt: null, }); if (rvwResp.status < 200 || rvwResp.status >= 300) { throw new Error(`更新期刊审稿配置失败: ${rvwResp.status} ${(rvwResp.data as any)?.message || ''}`.trim()); } } async function configureJournalByDb( journalId: string, journalLanguage: JournalLanguage, editorialBaseStandard: EditorialBaseStandard ) { await prisma.tenants.update({ where: { id: journalId }, data: { journal_language: journalLanguage as any, }, }); await prisma.tenantRvwConfig.upsert({ where: { tenantId: journalId }, create: { tenantId: journalId, editorialBaseStandard, editorialExpertPrompt: null, methodologyExpertPrompt: null, dataForensicsExpertPrompt: null, clinicalExpertPrompt: null, }, update: { editorialBaseStandard, editorialExpertPrompt: null, methodologyExpertPrompt: null, dataForensicsExpertPrompt: null, clinicalExpertPrompt: null, updatedAt: new Date(), }, }); } async function uploadTask( client: AxiosInstance, tenantCode: string, filePath: string ): Promise { if (!fs.existsSync(filePath)) { throw new Error(`测试文件不存在: ${filePath}`); } const form = new FormData(); form.append('file', fs.createReadStream(filePath), path.basename(filePath)); form.append('modelType', 'deepseek-v3'); const resp = await client.post('/api/v1/rvw/tasks', form, { headers: { ...form.getHeaders(), 'x-tenant-id': tenantCode, }, maxBodyLength: Infinity, maxContentLength: Infinity, }); if (resp.status !== 200 || !resp.data?.success || !resp.data?.data?.taskId) { throw new Error(`上传稿件失败: ${resp.status} ${(resp.data as any)?.message || ''}`.trim()); } return resp.data.data.taskId; } async function runTask(client: AxiosInstance, tenantCode: string, taskId: string, agents: AgentType[]): Promise { // 文档提取为异步,run 可能返回“尚未提取完成”,这里做重试以稳定 E2E for (let i = 1; i <= 30; i += 1) { const resp = await client.post( `/api/v1/rvw/tasks/${taskId}/run`, { agents }, { headers: { 'x-tenant-id': tenantCode } } ); if (resp.status === 200 && resp.data?.success && resp.data?.data?.jobId) { return resp.data.data.jobId; } const message = String((resp.data as any)?.message || ''); if (resp.status === 400 && message.includes('文档尚未提取完成')) { await sleep(2000); continue; } throw new Error(`执行审评失败: ${resp.status} ${message}`.trim()); } throw new Error('执行审评超时:文档提取长时间未就绪'); } async function pollTask(client: AxiosInstance, tenantCode: string, taskId: string) { let lastStatus: TaskStatus | null = null; for (let i = 1; i <= MAX_POLLS; i += 1) { const resp = await client.get(`/api/v1/rvw/tasks/${taskId}`, { headers: { 'x-tenant-id': tenantCode }, }); if (resp.status !== 200 || !resp.data?.success || !resp.data?.data) { throw new Error(`查询任务失败: ${resp.status} ${(resp.data as any)?.message || ''}`.trim()); } const data = resp.data.data; if (data.status !== lastStatus) { lastStatus = data.status; console.log(` - 任务状态: ${data.status}`); } if (data.status === 'completed' || data.status === 'partial_completed' || data.status === 'failed') { return data; } await sleep(POLL_INTERVAL_MS); } throw new Error(`任务轮询超时(>${MAX_POLLS} 次)`); } async function runScenario(client: AxiosInstance, scenario: Scenario) { console.log(`\n▶ 场景: ${scenario.name}`); console.log(` 期刊租户: ${scenario.tenantCode}`); console.log(` 测试文件: ${scenario.filePath}`); console.log(` Skills: ${scenario.agents.join(', ')}`); let journal: JournalItem; let configMode: 'api' | 'db-fallback' = 'api'; try { journal = await getJournalByCode(client, scenario.tenantCode); } catch (error) { const msg = unwrapError(error); if (!ALLOW_DB_FALLBACK || !msg.includes('403')) throw error; journal = await getJournalByCodeFromDb(scenario.tenantCode); configMode = 'db-fallback'; } console.log(` 已找到期刊: ${journal.name} (${journal.code})`); try { await configureJournal(client, journal.id, scenario.journalLanguage, scenario.editorialBaseStandard); configMode = 'api'; } catch (error) { const msg = unwrapError(error); if (!ALLOW_DB_FALLBACK || !msg.includes('403')) throw error; await configureJournalByDb(journal.id, scenario.journalLanguage, scenario.editorialBaseStandard); configMode = 'db-fallback'; } console.log(` 已配置期刊(${configMode}): journalLanguage=${scenario.journalLanguage}, editorialBaseStandard=${scenario.editorialBaseStandard}`); const taskId = await uploadTask(client, scenario.tenantCode, scenario.filePath); console.log(` 上传成功,taskId=${taskId}`); const jobId = await runTask(client, scenario.tenantCode, taskId, scenario.agents); console.log(` 已启动审评,jobId=${jobId}`); const finalTask = await pollTask(client, scenario.tenantCode, taskId); console.log(` 场景完成,最终状态=${finalTask.status}`); let reportStatus = 'not-requested'; if (finalTask.status !== 'failed') { const reportResp = await client.get(`/api/v1/rvw/tasks/${taskId}/report`, { headers: { 'x-tenant-id': scenario.tenantCode }, validateStatus: () => true, }); reportStatus = `${reportResp.status}`; } return { scenarioId: scenario.id, scenarioName: scenario.name, tenantCode: scenario.tenantCode, journalId: journal.id, configMode, journalLanguage: scenario.journalLanguage, editorialBaseStandard: scenario.editorialBaseStandard, filePath: scenario.filePath, agents: scenario.agents, taskId, jobId, finalStatus: finalTask.status, overallScore: finalTask.overallScore ?? null, editorialScore: finalTask.editorialScore ?? null, methodologyScore: finalTask.methodologyScore ?? null, modelUsed: finalTask.modelUsed ?? null, completedAt: finalTask.completedAt ?? null, errorMessage: finalTask.errorMessage ?? null, reportStatus, }; } async function main() { console.log('═══════════════════════════════════════════════════════'); console.log('RVW V4.0 期刊配置中心 E2E 测试'); console.log('═══════════════════════════════════════════════════════'); console.log(`BASE_URL: ${BASE_URL}`); console.log(`登录账号: ${PHONE}`); console.log(`中文租户: ${ZH_TENANT_CODE} | 英文租户: ${EN_TENANT_CODE}`); console.log(`轮询参数: interval=${POLL_INTERVAL_MS}ms, max=${MAX_POLLS}`); console.log(`配置模式: API优先,DB fallback=${ALLOW_DB_FALLBACK ? '启用' : '禁用'}`); console.log(`场景过滤: ${SCENARIO_FILTER || '全部'}`); const startedAt = new Date(); const token = await loginByPassword(); console.log('\n✅ 登录成功'); const client = createClient(token); const results: any[] = []; const enabledScenarios = getEnabledScenarios(); for (const scenario of enabledScenarios) { try { const result = await runScenario(client, scenario); results.push({ success: true, ...result }); } catch (error) { const message = unwrapError(error); console.error(` ❌ 场景失败: ${message}`); results.push({ success: false, scenarioId: scenario.id, scenarioName: scenario.name, tenantCode: scenario.tenantCode, filePath: scenario.filePath, agents: scenario.agents, error: message, }); } } const endedAt = new Date(); const outputDir = path.resolve(repoRoot, 'backend/scripts/output'); await fs.promises.mkdir(outputDir, { recursive: true }); const outputPath = path.resolve(outputDir, `rvw-journal-e2e-${Date.now()}.json`); const summary = { startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationSeconds: Math.round((endedAt.getTime() - startedAt.getTime()) / 1000), baseUrl: BASE_URL, operatorPhone: PHONE, scenarios: results, }; await fs.promises.writeFile(outputPath, JSON.stringify(summary, null, 2), 'utf-8'); const passed = results.filter(r => r.success).length; const failed = results.length - passed; console.log('\n═══════════════════════════════════════════════════════'); console.log(`完成:通过 ${passed},失败 ${failed}`); console.log(`结果文件:${outputPath}`); console.log('═══════════════════════════════════════════════════════'); if (failed > 0) { process.exitCode = 1; } } main().catch((error) => { console.error('\n❌ 脚本执行失败:', unwrapError(error)); process.exit(1); });