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
This commit is contained in:
496
backend/scripts/test-rvw-journal-e2e.ts
Normal file
496
backend/scripts/test-rvw-journal-e2e.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user