Files
AIclinicalresearch/backend/scripts/test-rvw-journal-e2e.ts
HaHafeng 83e395824b feat(rvw): complete journal config center MVP and tenant login routing
Deliver the RVW V4.0 journal configuration center across backend, frontend, migration, and docs with zh/en editorial baseline support and tenant-level prompt/template overrides. Unify tenant login to /:tenantCode/login and auto-enable RVW module when tenant type is JOURNAL to prevent post-login access gaps.

Made-with: Cursor
2026-03-15 11:51:35 +08:00

497 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {};
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<any>;
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<string> {
const client = createClient();
const resp = await client.post<LoginResponse>('/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<JournalItem> {
const resp = await client.get<JournalListResponse>('/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<JournalItem> {
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<string> {
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<CreateTaskResponse>('/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<string> {
// 文档提取为异步run 可能返回“尚未提取完成”,这里做重试以稳定 E2E
for (let i = 1; i <= 30; i += 1) {
const resp = await client.post<RunTaskResponse>(
`/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<TaskDetailResponse>(`/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);
});