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:
@@ -0,0 +1,49 @@
|
||||
-- RVW V4.0 期刊配置中心 MVP
|
||||
-- 一次性补齐 tenants(P0/P1/P2+language) 与 tenant_rvw_configs(4维Prompt+Template)
|
||||
|
||||
-- 1) 枚举:JournalLanguage
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'JournalLanguage'
|
||||
AND n.nspname = 'platform_schema'
|
||||
) THEN
|
||||
CREATE TYPE "platform_schema"."JournalLanguage" AS ENUM ('ZH', 'EN', 'OTHER');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2) 枚举:TenantType 增加 JOURNAL
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TYPE "platform_schema"."TenantType" ADD VALUE IF NOT EXISTS 'JOURNAL';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- 3) tenants 表新增字段
|
||||
ALTER TABLE "platform_schema"."tenants"
|
||||
ADD COLUMN IF NOT EXISTS "journal_language" "platform_schema"."JournalLanguage",
|
||||
ADD COLUMN IF NOT EXISTS "journal_full_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "logo_url" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "brand_color" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "login_background_url" TEXT;
|
||||
|
||||
ALTER TABLE "platform_schema"."tenants"
|
||||
ALTER COLUMN "journal_language" SET DEFAULT 'ZH';
|
||||
|
||||
-- 4) tenant_rvw_configs 表升级为 Prompt/Template 统一结构
|
||||
ALTER TABLE "platform_schema"."tenant_rvw_configs"
|
||||
ADD COLUMN IF NOT EXISTS "editorial_base_standard" TEXT NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN IF NOT EXISTS "editorial_expert_prompt" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "editorial_handlebars_template" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "data_forensics_expert_prompt" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "data_forensics_handlebars_template" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "clinical_handlebars_template" TEXT;
|
||||
|
||||
ALTER TABLE "platform_schema"."tenant_rvw_configs"
|
||||
DROP COLUMN IF EXISTS "editorial_rules",
|
||||
DROP COLUMN IF EXISTS "data_forensics_level",
|
||||
DROP COLUMN IF EXISTS "finer_weights";
|
||||
@@ -1915,6 +1915,11 @@ model tenants {
|
||||
code String @unique
|
||||
name String
|
||||
type TenantType
|
||||
journal_language JournalLanguage? @default(ZH)
|
||||
journal_full_name String?
|
||||
logo_url String?
|
||||
brand_color String?
|
||||
login_background_url String?
|
||||
status TenantStatus @default(ACTIVE)
|
||||
config Json? @default("{}")
|
||||
total_quota BigInt @default(0)
|
||||
@@ -1964,8 +1969,14 @@ model TenantRvwConfig {
|
||||
tenantId String @unique @map("tenant_id")
|
||||
tenant tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// 稿约规范评估:规则数组,每条规则含 code/description/fatal
|
||||
editorialRules Json? @map("editorial_rules")
|
||||
/// 稿约规范评估:默认基线(zh/en),用于继承模式的系统默认 Prompt 路由
|
||||
editorialBaseStandard String @default("en") @map("editorial_base_standard")
|
||||
|
||||
/// 稿约规范评估:租户自定义业务 Prompt(null 表示继承系统默认)
|
||||
editorialExpertPrompt String? @map("editorial_expert_prompt") @db.Text
|
||||
|
||||
/// 稿约规范评估:租户自定义报告模板(null 表示继承系统默认)
|
||||
editorialHandlebarsTemplate String? @map("editorial_handlebars_template") @db.Text
|
||||
|
||||
/// 方法学评估:专家业务评判标准(纯文本,可自由编辑,不需懂JSON)
|
||||
methodologyExpertPrompt String? @map("methodology_expert_prompt") @db.Text
|
||||
@@ -1973,15 +1984,18 @@ model TenantRvwConfig {
|
||||
/// 方法学评估:Handlebars 报告展示模板(可覆盖系统默认模板)
|
||||
methodologyHandlebarsTemplate String? @map("methodology_handlebars_template") @db.Text
|
||||
|
||||
/// 数据验证:验证深度 L1/L2/L3
|
||||
dataForensicsLevel String @default("L2") @map("data_forensics_level")
|
||||
/// 数据验证:租户自定义业务 Prompt(null 表示继承系统默认)
|
||||
dataForensicsExpertPrompt String? @map("data_forensics_expert_prompt") @db.Text
|
||||
|
||||
/// 临床评估:FINER 五维权重 {feasibility, innovation, ethics, relevance, novelty}
|
||||
finerWeights Json? @map("finer_weights")
|
||||
/// 数据验证:租户自定义报告模板(null 表示继承系统默认)
|
||||
dataForensicsHandlebarsTemplate String? @map("data_forensics_handlebars_template") @db.Text
|
||||
|
||||
/// 临床评估:专科特色补充要求(纯文本)
|
||||
clinicalExpertPrompt String? @map("clinical_expert_prompt") @db.Text
|
||||
|
||||
/// 临床评估:租户自定义报告模板(null 表示继承系统默认)
|
||||
clinicalHandlebarsTemplate String? @map("clinical_handlebars_template") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -2011,12 +2025,21 @@ enum TenantStatus {
|
||||
enum TenantType {
|
||||
HOSPITAL // 医院
|
||||
PHARMA // 药企
|
||||
JOURNAL // 期刊
|
||||
INTERNAL // 内部(公司自己)
|
||||
PUBLIC // 个人用户公共池
|
||||
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
enum JournalLanguage {
|
||||
ZH
|
||||
EN
|
||||
OTHER
|
||||
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
SUPER_ADMIN
|
||||
PROMPT_ENGINEER
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -21,6 +21,52 @@ interface FallbackPrompt {
|
||||
* RVW 模块兜底 Prompt
|
||||
*/
|
||||
const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
|
||||
RVW_EDITORIAL_EN: {
|
||||
content: `You are a rigorous medical journal editor. Review the manuscript against international ICMJE-style editorial compliance.
|
||||
|
||||
Fatal checks (must flag as fatal if violated):
|
||||
1) Ethics and informed consent statements for human/animal research.
|
||||
2) Conflict of interest and funding statements (including "None declared" when absent).
|
||||
3) AI authorship compliance (AI tools must not be listed as authors).
|
||||
4) Blind-review leakage in main text (author/institution/funding identifiers).
|
||||
|
||||
Major checks:
|
||||
5) Abstract structure and length compliance by manuscript type.
|
||||
6) Statistical expression standards (exact P values, notation consistency).
|
||||
7) Citation-reference consistency and formatting integrity.
|
||||
8) Figure/table editability and in-text cross-reference integrity.
|
||||
|
||||
Minor checks:
|
||||
9) Consistent English variant (US or UK).
|
||||
10) Number spelling conventions.
|
||||
11) Scientific term formatting (e.g., italics for species/gene symbols).
|
||||
12) SI unit compliance.
|
||||
|
||||
Return structured JSON only, including overall_score and items.
|
||||
If no violation is found, clearly indicate all checks passed.`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_EDITORIAL_ZH: {
|
||||
content: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。
|
||||
|
||||
【评估标准】
|
||||
1. 文稿科学性与实用性
|
||||
2. 文题(中文不超过20字,英文不超过10实词)
|
||||
3. 作者格式
|
||||
4. 摘要(300-500字,含目的、方法、结果、结论)
|
||||
5. 关键词(2-5个)
|
||||
6. 医学名词和药物名称
|
||||
7. 缩略语
|
||||
8. 计量单位
|
||||
9. 图片格式
|
||||
10. 动态图像
|
||||
11. 参考文献
|
||||
|
||||
请输出JSON格式的评估结果,包含overall_score和items数组。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_EDITORIAL: {
|
||||
content: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。
|
||||
|
||||
|
||||
@@ -112,9 +112,11 @@ import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRout
|
||||
import { systemKbRoutes } from './modules/admin/system-kb/index.js';
|
||||
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js';
|
||||
import { rvwConfigRoutes } from './modules/admin/rvw-config/rvwConfigRoutes.js';
|
||||
import { journalConfigRoutes } from './modules/admin/journal-config/journalConfigRoutes.js';
|
||||
import { authenticate, requireRoles } from './common/auth/auth.middleware.js';
|
||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(rvwConfigRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(journalConfigRoutes, { prefix: '/api/admin/journal-configs' });
|
||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||
@@ -133,7 +135,7 @@ await fastify.register(async (scope) => {
|
||||
await scope.register(iitEqueryRoutes);
|
||||
}, { prefix: '/api/v1/admin/iit-projects' });
|
||||
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/journal-configs, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
|
||||
|
||||
// ============================================
|
||||
// 【临时】平台基础设施测试API
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import * as service from './journalConfigService.js';
|
||||
import type { TenantListQuery, UpdateTenantRequest } from '../types/tenant.types.js';
|
||||
import type { UpdateRvwConfigDto } from '../rvw-config/rvwConfigService.js';
|
||||
|
||||
interface TenantIdParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function listJournalConfigs(
|
||||
request: FastifyRequest<{ Querystring: Omit<TenantListQuery, 'type'> }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const result = await service.listJournalConfigs(request.query);
|
||||
return reply.send({ success: true, ...result });
|
||||
} catch (error: any) {
|
||||
logger.error('[JournalConfig] 获取期刊配置列表失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '获取期刊配置列表失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getJournalConfigDetail(
|
||||
request: FastifyRequest<{ Params: TenantIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const detail = await service.getJournalConfigDetail(request.params.id);
|
||||
if (!detail) {
|
||||
return reply.status(404).send({ success: false, message: '期刊租户不存在' });
|
||||
}
|
||||
return reply.send({ success: true, data: detail });
|
||||
} catch (error: any) {
|
||||
logger.error('[JournalConfig] 获取详情失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '获取详情失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJournalBasicInfo(
|
||||
request: FastifyRequest<{ Params: TenantIdParams; Body: UpdateTenantRequest }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const dto = request.body || {};
|
||||
const updated = await service.updateJournalBasicInfo(request.params.id, dto);
|
||||
return reply.send({ success: true, data: updated, message: '基础信息已保存' });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('不存在')) {
|
||||
return reply.status(404).send({ success: false, message: error.message });
|
||||
}
|
||||
logger.error('[JournalConfig] 保存基础信息失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '保存基础信息失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJournalRvwConfig(
|
||||
request: FastifyRequest<{ Params: TenantIdParams; Body: UpdateRvwConfigDto }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const dto = request.body || {};
|
||||
if (dto.editorialBaseStandard && !['zh', 'en'].includes(dto.editorialBaseStandard)) {
|
||||
return reply.status(400).send({ success: false, message: 'editorialBaseStandard 必须为 zh / en' });
|
||||
}
|
||||
const updated = await service.updateJournalRvwConfig(request.params.id, dto);
|
||||
return reply.send({ success: true, data: updated, message: '审稿配置已保存' });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('不存在')) {
|
||||
return reply.status(404).send({ success: false, message: error.message });
|
||||
}
|
||||
logger.error('[JournalConfig] 保存审稿配置失败', { error: error.message });
|
||||
return reply.status(500).send({ success: false, message: error.message || '保存审稿配置失败' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authenticate, requireAnyPermission } from '../../../common/auth/auth.middleware.js';
|
||||
import * as controller from './journalConfigController.js';
|
||||
|
||||
/**
|
||||
* 期刊配置中心路由
|
||||
* 前缀:/api/admin/journal-configs
|
||||
*/
|
||||
export async function journalConfigRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')],
|
||||
handler: controller.listJournalConfigs,
|
||||
});
|
||||
|
||||
fastify.get('/:id', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')],
|
||||
handler: controller.getJournalConfigDetail,
|
||||
});
|
||||
|
||||
fastify.put('/:id/basic-info', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')],
|
||||
handler: controller.updateJournalBasicInfo,
|
||||
});
|
||||
|
||||
fastify.put('/:id/rvw-config', {
|
||||
preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')],
|
||||
handler: controller.updateJournalRvwConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { tenantService } from '../services/tenantService.js';
|
||||
import type { TenantListQuery, UpdateTenantRequest } from '../types/tenant.types.js';
|
||||
import type { UpdateRvwConfigDto } from '../rvw-config/rvwConfigService.js';
|
||||
import * as rvwConfigService from '../rvw-config/rvwConfigService.js';
|
||||
|
||||
export async function listJournalConfigs(query: Omit<TenantListQuery, 'type'>) {
|
||||
return tenantService.listTenants({
|
||||
...query,
|
||||
type: 'JOURNAL',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJournalConfigDetail(tenantId: string) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') return null;
|
||||
|
||||
const rvwConfig = await rvwConfigService.getRvwConfig(tenantId);
|
||||
return { tenant, rvwConfig };
|
||||
}
|
||||
|
||||
export async function updateJournalBasicInfo(tenantId: string, dto: UpdateTenantRequest) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') {
|
||||
throw new Error('期刊租户不存在');
|
||||
}
|
||||
return tenantService.updateTenant(tenantId, dto);
|
||||
}
|
||||
|
||||
export async function updateJournalRvwConfig(tenantId: string, dto: UpdateRvwConfigDto) {
|
||||
const tenant = await tenantService.getTenantDetail(tenantId);
|
||||
if (!tenant || tenant.type !== 'JOURNAL') {
|
||||
throw new Error('期刊租户不存在');
|
||||
}
|
||||
return rvwConfigService.upsertRvwConfig(tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选原子写接口:同时更新基础信息 + RVW 配置,避免跨表写入不一致
|
||||
*/
|
||||
export async function saveJournalConfigAtomic(
|
||||
tenantId: string,
|
||||
basicInfo: UpdateTenantRequest,
|
||||
rvwConfig: UpdateRvwConfigDto
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const tenant = await tx.tenants.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
name: basicInfo.name,
|
||||
type: basicInfo.type as any,
|
||||
journal_language: basicInfo.journalLanguage === null ? null : (basicInfo.journalLanguage as any),
|
||||
journal_full_name: basicInfo.journalFullName === null ? null : basicInfo.journalFullName,
|
||||
logo_url: basicInfo.logoUrl === null ? null : basicInfo.logoUrl,
|
||||
brand_color: basicInfo.brandColor === null ? null : basicInfo.brandColor,
|
||||
login_background_url: basicInfo.loginBackgroundUrl === null ? null : basicInfo.loginBackgroundUrl,
|
||||
contact_name: basicInfo.contactName,
|
||||
contact_phone: basicInfo.contactPhone,
|
||||
contact_email: basicInfo.contactEmail,
|
||||
expires_at: basicInfo.expiresAt === null ? null : (basicInfo.expiresAt ? new Date(basicInfo.expiresAt) : undefined),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const config = await tx.tenantRvwConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
editorialBaseStandard: rvwConfig.editorialBaseStandard ?? 'en',
|
||||
editorialExpertPrompt: rvwConfig.editorialExpertPrompt ?? null,
|
||||
editorialHandlebarsTemplate: rvwConfig.editorialHandlebarsTemplate ?? null,
|
||||
methodologyExpertPrompt: rvwConfig.methodologyExpertPrompt ?? null,
|
||||
methodologyHandlebarsTemplate: rvwConfig.methodologyHandlebarsTemplate ?? null,
|
||||
dataForensicsExpertPrompt: rvwConfig.dataForensicsExpertPrompt ?? null,
|
||||
dataForensicsHandlebarsTemplate: rvwConfig.dataForensicsHandlebarsTemplate ?? null,
|
||||
clinicalExpertPrompt: rvwConfig.clinicalExpertPrompt ?? null,
|
||||
clinicalHandlebarsTemplate: rvwConfig.clinicalHandlebarsTemplate ?? null,
|
||||
},
|
||||
update: {
|
||||
...(rvwConfig.editorialBaseStandard !== undefined && { editorialBaseStandard: rvwConfig.editorialBaseStandard }),
|
||||
...(rvwConfig.editorialExpertPrompt !== undefined && { editorialExpertPrompt: rvwConfig.editorialExpertPrompt }),
|
||||
...(rvwConfig.editorialHandlebarsTemplate !== undefined && { editorialHandlebarsTemplate: rvwConfig.editorialHandlebarsTemplate }),
|
||||
...(rvwConfig.methodologyExpertPrompt !== undefined && { methodologyExpertPrompt: rvwConfig.methodologyExpertPrompt }),
|
||||
...(rvwConfig.methodologyHandlebarsTemplate !== undefined && { methodologyHandlebarsTemplate: rvwConfig.methodologyHandlebarsTemplate }),
|
||||
...(rvwConfig.dataForensicsExpertPrompt !== undefined && { dataForensicsExpertPrompt: rvwConfig.dataForensicsExpertPrompt }),
|
||||
...(rvwConfig.dataForensicsHandlebarsTemplate !== undefined && { dataForensicsHandlebarsTemplate: rvwConfig.dataForensicsHandlebarsTemplate }),
|
||||
...(rvwConfig.clinicalExpertPrompt !== undefined && { clinicalExpertPrompt: rvwConfig.clinicalExpertPrompt }),
|
||||
...(rvwConfig.clinicalHandlebarsTemplate !== undefined && { clinicalHandlebarsTemplate: rvwConfig.clinicalHandlebarsTemplate }),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, config };
|
||||
});
|
||||
}
|
||||
@@ -50,9 +50,11 @@ export async function getRvwConfig(
|
||||
*
|
||||
* Body 示例:
|
||||
* {
|
||||
* "editorialBaseStandard": "zh",
|
||||
* "editorialExpertPrompt": null,
|
||||
* "methodologyExpertPrompt": "...",
|
||||
* "dataForensicsLevel": "L2",
|
||||
* "finerWeights": { "feasibility": 20, "innovation": 20, "ethics": 20, "relevance": 20, "novelty": 20 }
|
||||
* "dataForensicsExpertPrompt": "...",
|
||||
* "clinicalExpertPrompt": "..."
|
||||
* }
|
||||
*/
|
||||
export async function upsertRvwConfig(
|
||||
@@ -66,26 +68,14 @@ export async function upsertRvwConfig(
|
||||
const { id: tenantId } = request.params;
|
||||
const dto = request.body;
|
||||
|
||||
// 验证 dataForensicsLevel(若提供)
|
||||
if (dto.dataForensicsLevel && !['L1', 'L2', 'L3'].includes(dto.dataForensicsLevel)) {
|
||||
// 验证 editorialBaseStandard(若提供)
|
||||
if (dto.editorialBaseStandard && !['zh', 'en'].includes(dto.editorialBaseStandard)) {
|
||||
return reply.status(400).send({
|
||||
error: 'BadRequest',
|
||||
message: 'dataForensicsLevel 必须为 L1 / L2 / L3',
|
||||
message: 'editorialBaseStandard 必须为 zh / en',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 finerWeights(若提供,权重之和应为 100)
|
||||
if (dto.finerWeights && typeof dto.finerWeights === 'object') {
|
||||
const weights = dto.finerWeights as Record<string, number>;
|
||||
const sum = Object.values(weights).reduce((a, b) => a + Number(b), 0);
|
||||
if (Math.abs(sum - 100) > 1) {
|
||||
return reply.status(400).send({
|
||||
error: 'BadRequest',
|
||||
message: `FINER 权重之和应为 100,当前为 ${sum}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config = await rvwConfigService.upsertRvwConfig(tenantId, dto);
|
||||
|
||||
return reply.status(200).send({
|
||||
|
||||
@@ -9,30 +9,35 @@
|
||||
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** 审稿配置响应 DTO */
|
||||
export interface RvwConfigDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
editorialRules: Prisma.JsonValue | null;
|
||||
editorialBaseStandard: string;
|
||||
editorialExpertPrompt: string | null;
|
||||
editorialHandlebarsTemplate: string | null;
|
||||
methodologyExpertPrompt: string | null;
|
||||
methodologyHandlebarsTemplate: string | null;
|
||||
dataForensicsLevel: string;
|
||||
finerWeights: Prisma.JsonValue | null;
|
||||
dataForensicsExpertPrompt: string | null;
|
||||
dataForensicsHandlebarsTemplate: string | null;
|
||||
clinicalExpertPrompt: string | null;
|
||||
clinicalHandlebarsTemplate: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** 审稿配置更新 DTO(所有字段可选,UPSERT 语义) */
|
||||
export interface UpdateRvwConfigDto {
|
||||
editorialRules?: Prisma.InputJsonValue | null;
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
editorialExpertPrompt?: string | null;
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
methodologyExpertPrompt?: string | null;
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
dataForensicsLevel?: string;
|
||||
finerWeights?: Prisma.InputJsonValue | null;
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
clinicalExpertPrompt?: string | null;
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,21 +79,27 @@ export async function upsertRvwConfig(
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
editorialRules: dto.editorialRules ?? undefined,
|
||||
editorialBaseStandard: dto.editorialBaseStandard ?? 'en',
|
||||
editorialExpertPrompt: dto.editorialExpertPrompt ?? null,
|
||||
editorialHandlebarsTemplate: dto.editorialHandlebarsTemplate ?? null,
|
||||
methodologyExpertPrompt: dto.methodologyExpertPrompt ?? null,
|
||||
methodologyHandlebarsTemplate: dto.methodologyHandlebarsTemplate ?? null,
|
||||
dataForensicsLevel: dto.dataForensicsLevel ?? 'L2',
|
||||
finerWeights: dto.finerWeights ?? undefined,
|
||||
dataForensicsExpertPrompt: dto.dataForensicsExpertPrompt ?? null,
|
||||
dataForensicsHandlebarsTemplate: dto.dataForensicsHandlebarsTemplate ?? null,
|
||||
clinicalExpertPrompt: dto.clinicalExpertPrompt ?? null,
|
||||
clinicalHandlebarsTemplate: dto.clinicalHandlebarsTemplate ?? null,
|
||||
updatedAt: now,
|
||||
},
|
||||
update: {
|
||||
...(dto.editorialRules !== undefined && { editorialRules: dto.editorialRules }),
|
||||
...(dto.editorialBaseStandard !== undefined && { editorialBaseStandard: dto.editorialBaseStandard }),
|
||||
...(dto.editorialExpertPrompt !== undefined && { editorialExpertPrompt: dto.editorialExpertPrompt }),
|
||||
...(dto.editorialHandlebarsTemplate !== undefined && { editorialHandlebarsTemplate: dto.editorialHandlebarsTemplate }),
|
||||
...(dto.methodologyExpertPrompt !== undefined && { methodologyExpertPrompt: dto.methodologyExpertPrompt }),
|
||||
...(dto.methodologyHandlebarsTemplate !== undefined && { methodologyHandlebarsTemplate: dto.methodologyHandlebarsTemplate }),
|
||||
...(dto.dataForensicsLevel !== undefined && { dataForensicsLevel: dto.dataForensicsLevel }),
|
||||
...(dto.finerWeights !== undefined && { finerWeights: dto.finerWeights }),
|
||||
...(dto.dataForensicsExpertPrompt !== undefined && { dataForensicsExpertPrompt: dto.dataForensicsExpertPrompt }),
|
||||
...(dto.dataForensicsHandlebarsTemplate !== undefined && { dataForensicsHandlebarsTemplate: dto.dataForensicsHandlebarsTemplate }),
|
||||
...(dto.clinicalExpertPrompt !== undefined && { clinicalExpertPrompt: dto.clinicalExpertPrompt }),
|
||||
...(dto.clinicalHandlebarsTemplate !== undefined && { clinicalHandlebarsTemplate: dto.clinicalHandlebarsTemplate }),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,31 @@ import type {
|
||||
} from '../types/tenant.types.js';
|
||||
|
||||
class TenantService {
|
||||
/**
|
||||
* 期刊租户兜底策略:确保 RVW 模块已开通。
|
||||
* - 创建 JOURNAL 租户时自动开通
|
||||
* - 其他入口将租户改为 JOURNAL 时也自动补齐
|
||||
*/
|
||||
private async ensureJournalRvwModule(tenantId: string): Promise<void> {
|
||||
await prisma.tenant_modules.upsert({
|
||||
where: {
|
||||
tenant_id_module_code: {
|
||||
tenant_id: tenantId,
|
||||
module_code: 'RVW',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenant_id: tenantId,
|
||||
module_code: 'RVW',
|
||||
is_enabled: true,
|
||||
expires_at: null,
|
||||
},
|
||||
update: {
|
||||
is_enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户列表(分页)
|
||||
*/
|
||||
@@ -51,6 +76,11 @@ class TenantService {
|
||||
code: true,
|
||||
name: true,
|
||||
type: true,
|
||||
journal_language: true,
|
||||
journal_full_name: true,
|
||||
logo_url: true,
|
||||
brand_color: true,
|
||||
login_background_url: true,
|
||||
status: true,
|
||||
contact_name: true,
|
||||
contact_phone: true,
|
||||
@@ -70,6 +100,11 @@ class TenantService {
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
type: t.type as any,
|
||||
journalLanguage: t.journal_language as any,
|
||||
journalFullName: t.journal_full_name,
|
||||
logoUrl: t.logo_url,
|
||||
brandColor: t.brand_color,
|
||||
loginBackgroundUrl: t.login_background_url,
|
||||
status: t.status as any,
|
||||
contactName: t.contact_name,
|
||||
contactPhone: t.contact_phone,
|
||||
@@ -120,6 +155,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -154,6 +194,11 @@ class TenantService {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
type: data.type as any,
|
||||
journal_language: data.journalLanguage as any,
|
||||
journal_full_name: data.journalFullName,
|
||||
logo_url: data.logoUrl,
|
||||
brand_color: data.brandColor,
|
||||
login_background_url: data.loginBackgroundUrl,
|
||||
status: 'ACTIVE',
|
||||
contact_name: data.contactName,
|
||||
contact_phone: data.contactPhone,
|
||||
@@ -177,6 +222,11 @@ class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
// JOURNAL 租户自动开通 RVW(避免创建后无模块权限)
|
||||
if (data.type === 'JOURNAL') {
|
||||
await this.ensureJournalRvwModule(tenantId);
|
||||
}
|
||||
|
||||
logger.info('[TenantService] 创建租户', {
|
||||
tenantId,
|
||||
code: data.code,
|
||||
@@ -189,6 +239,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -207,6 +262,12 @@ class TenantService {
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type as any,
|
||||
journal_language: data.journalLanguage === null ? null : (data.journalLanguage as any),
|
||||
journal_full_name: data.journalFullName === null ? null : data.journalFullName,
|
||||
logo_url: data.logoUrl === null ? null : data.logoUrl,
|
||||
brand_color: data.brandColor === null ? null : data.brandColor,
|
||||
login_background_url: data.loginBackgroundUrl === null ? null : data.loginBackgroundUrl,
|
||||
contact_name: data.contactName,
|
||||
contact_phone: data.contactPhone,
|
||||
contact_email: data.contactEmail,
|
||||
@@ -215,6 +276,11 @@ class TenantService {
|
||||
},
|
||||
});
|
||||
|
||||
// 当租户类型为 JOURNAL 时,自动兜底 RVW 模块
|
||||
if (tenant.type === 'JOURNAL') {
|
||||
await this.ensureJournalRvwModule(tenantId);
|
||||
}
|
||||
|
||||
logger.info('[TenantService] 更新租户', { tenantId, data });
|
||||
|
||||
return {
|
||||
@@ -222,6 +288,11 @@ class TenantService {
|
||||
code: tenant.code,
|
||||
name: tenant.name,
|
||||
type: tenant.type as any,
|
||||
journalLanguage: tenant.journal_language as any,
|
||||
journalFullName: tenant.journal_full_name,
|
||||
logoUrl: tenant.logo_url,
|
||||
brandColor: tenant.brand_color,
|
||||
loginBackgroundUrl: tenant.login_background_url,
|
||||
status: tenant.status as any,
|
||||
contactName: tenant.contact_name,
|
||||
contactPhone: tenant.contact_phone,
|
||||
@@ -311,6 +382,7 @@ class TenantService {
|
||||
logo?: string;
|
||||
primaryColor: string;
|
||||
systemName: string;
|
||||
backgroundImageUrl?: string;
|
||||
modules: string[];
|
||||
isReviewOnly: boolean;
|
||||
} | null> {
|
||||
@@ -335,10 +407,11 @@ class TenantService {
|
||||
const isReviewOnly = modules.length === 1 && modules[0] === 'RVW';
|
||||
|
||||
return {
|
||||
name: tenant.name,
|
||||
logo: undefined, // TODO: 未来可从 tenant 扩展字段获取
|
||||
primaryColor: isReviewOnly ? '#6366f1' : '#1890ff',
|
||||
systemName: isReviewOnly ? '智能审稿系统' : 'AI临床研究平台',
|
||||
name: tenant.journal_full_name || tenant.name,
|
||||
logo: tenant.logo_url || undefined,
|
||||
primaryColor: tenant.brand_color || (isReviewOnly ? '#6366f1' : '#1890ff'),
|
||||
systemName: tenant.journal_full_name || (isReviewOnly ? '智能审稿系统' : 'AI临床研究平台'),
|
||||
backgroundImageUrl: tenant.login_background_url || undefined,
|
||||
modules,
|
||||
isReviewOnly,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* 租户管理类型定义
|
||||
*/
|
||||
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'JOURNAL' | 'INTERNAL' | 'PUBLIC';
|
||||
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
|
||||
export type JournalLanguage = 'ZH' | 'EN' | 'OTHER';
|
||||
|
||||
/**
|
||||
* 租户基本信息
|
||||
@@ -14,6 +15,11 @@ export interface TenantInfo {
|
||||
name: string;
|
||||
type: TenantType;
|
||||
status: TenantStatus;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
@@ -47,6 +53,11 @@ export interface CreateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
journalLanguage?: JournalLanguage;
|
||||
journalFullName?: string;
|
||||
logoUrl?: string;
|
||||
brandColor?: string;
|
||||
loginBackgroundUrl?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
@@ -59,6 +70,12 @@ export interface CreateTenantRequest {
|
||||
*/
|
||||
export interface UpdateTenantRequest {
|
||||
name?: string;
|
||||
type?: TenantType;
|
||||
journalLanguage?: JournalLanguage | null;
|
||||
journalFullName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
loginBackgroundUrl?: string | null;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
|
||||
@@ -28,15 +28,18 @@ export interface ClinicalReviewResult {
|
||||
export async function reviewClinical(
|
||||
text: string,
|
||||
modelType: ModelType = 'deepseek-v3',
|
||||
userId?: string
|
||||
userId?: string,
|
||||
tenantExpertPrompt?: string | null
|
||||
): Promise<ClinicalReviewResult> {
|
||||
try {
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_CLINICAL',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
let businessPrompt = tenantExpertPrompt?.trim() || '';
|
||||
let isDraft = false;
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const result = await promptService.get('RVW_CLINICAL', {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
|
||||
@@ -65,16 +65,29 @@ async function repairEditorialToJson(
|
||||
export async function reviewEditorialStandards(
|
||||
text: string,
|
||||
modelType: ModelType = 'deepseek-v3',
|
||||
userId?: string
|
||||
userId?: string,
|
||||
tenantExpertPrompt?: string | null,
|
||||
editorialBaseStandard?: 'zh' | 'en'
|
||||
): Promise<EditorialReview> {
|
||||
try {
|
||||
// 1. 从 PromptService 获取系统Prompt(支持灰度预览)
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_EDITORIAL',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
// 1. 优先使用租户自定义 Prompt;否则按中英基线获取系统默认 Prompt
|
||||
let businessPrompt = tenantExpertPrompt?.trim() || '';
|
||||
let isDraft = false;
|
||||
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const promptCode = editorialBaseStandard === 'zh' ? 'RVW_EDITORIAL_ZH' : 'RVW_EDITORIAL_EN';
|
||||
try {
|
||||
const result = await promptService.get(promptCode, {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
} catch {
|
||||
// 向后兼容:若新 code 尚未入库,降级到旧 code
|
||||
const result = await promptService.get('RVW_EDITORIAL', {}, { userId });
|
||||
businessPrompt = result.content;
|
||||
isDraft = result.isDraft;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
|
||||
@@ -114,16 +114,24 @@ export interface ForensicsResult {
|
||||
* 字段与 TenantRvwConfig 对应,仅携带运行时所需的轻量子集
|
||||
*/
|
||||
export interface TenantRvwConfigSnapshot {
|
||||
/** 稿约规范评估:继承基线(zh/en) */
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
/** 稿约规范评估:租户自定义 Prompt */
|
||||
editorialExpertPrompt?: string | null;
|
||||
/** 稿约规范评估:租户自定义模板 */
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
/** 方法学评估:专家业务评判标准(覆盖 PromptService 默认值) */
|
||||
methodologyExpertPrompt?: string | null;
|
||||
/** 方法学评估:租户自定义模板 */
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
/** 数据验证:租户自定义 Prompt */
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
/** 数据验证:租户自定义模板 */
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
/** 临床评估:专科特色补充要求 */
|
||||
clinicalExpertPrompt?: string | null;
|
||||
/** 稿约规范评估:规则数组 */
|
||||
editorialRules?: unknown;
|
||||
/** 数据验证:深度级别 L1/L2/L3 */
|
||||
dataForensicsLevel?: string;
|
||||
/** 临床评估:FINER 权重 */
|
||||
finerWeights?: unknown;
|
||||
/** 临床评估:租户自定义模板 */
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,7 +83,13 @@ export class ClinicalAssessmentSkill extends BaseSkill<SkillContext, ClinicalCon
|
||||
});
|
||||
}
|
||||
|
||||
const result: ClinicalReviewResult = await reviewClinical(content, 'deepseek-v3', context.userId);
|
||||
const tenantClinicalPrompt = context.tenantRvwConfig?.clinicalExpertPrompt ?? null;
|
||||
const result: ClinicalReviewResult = await reviewClinical(
|
||||
content,
|
||||
'deepseek-v3',
|
||||
context.userId,
|
||||
tenantClinicalPrompt
|
||||
);
|
||||
|
||||
logger.info('[ClinicalAssessmentSkill] Evaluation completed', {
|
||||
taskId: context.taskId,
|
||||
|
||||
@@ -294,12 +294,16 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
tableCount: forensicsResult.tables.length,
|
||||
});
|
||||
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: businessPrompt } = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
);
|
||||
let businessPrompt = context.tenantRvwConfig?.dataForensicsExpertPrompt?.trim() || '';
|
||||
if (!businessPrompt) {
|
||||
const promptService = getPromptService(prisma);
|
||||
const promptResult = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
);
|
||||
businessPrompt = promptResult.content;
|
||||
}
|
||||
|
||||
// 拼接所有表格为 user message
|
||||
const tableTexts: string[] = [];
|
||||
|
||||
@@ -99,8 +99,16 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
|
||||
});
|
||||
}
|
||||
|
||||
// 调用现有 editorialService
|
||||
const result = await reviewEditorialStandards(content, 'deepseek-v3', context.userId);
|
||||
// V4.0 Hybrid Prompt:优先租户自定义稿约 Prompt;否则按 editorialBaseStandard 路由系统默认中英基线
|
||||
const tenantEditorialPrompt = context.tenantRvwConfig?.editorialExpertPrompt ?? null;
|
||||
const editorialBaseStandard = context.tenantRvwConfig?.editorialBaseStandard ?? 'en';
|
||||
const result = await reviewEditorialStandards(
|
||||
content,
|
||||
'deepseek-v3',
|
||||
context.userId,
|
||||
tenantEditorialPrompt,
|
||||
editorialBaseStandard
|
||||
);
|
||||
|
||||
// 转换为 SkillResult 格式
|
||||
const issues = this.convertToIssues(result);
|
||||
|
||||
@@ -491,31 +491,60 @@ async function executeWithSkills(
|
||||
select: { tenantId: true, contextData: true },
|
||||
});
|
||||
let tenantRvwConfig: {
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
editorialExpertPrompt?: string | null;
|
||||
editorialHandlebarsTemplate?: string | null;
|
||||
methodologyExpertPrompt?: string | null;
|
||||
methodologyHandlebarsTemplate?: string | null;
|
||||
dataForensicsExpertPrompt?: string | null;
|
||||
dataForensicsHandlebarsTemplate?: string | null;
|
||||
clinicalExpertPrompt?: string | null;
|
||||
editorialRules?: unknown;
|
||||
dataForensicsLevel?: string;
|
||||
finerWeights?: unknown;
|
||||
clinicalHandlebarsTemplate?: string | null;
|
||||
} | null = null;
|
||||
if (taskWithTenant?.tenantId) {
|
||||
const cfg = await prisma.tenantRvwConfig.findUnique({
|
||||
const tenantMeta = await (prisma as any).tenants.findUnique({
|
||||
where: { id: taskWithTenant.tenantId },
|
||||
select: { journal_language: true },
|
||||
}) as { journal_language?: 'ZH' | 'EN' | 'OTHER' } | null;
|
||||
const inferredEditorialBase: 'zh' | 'en' =
|
||||
tenantMeta?.journal_language === 'ZH' ? 'zh' : 'en';
|
||||
|
||||
const cfg = await (prisma as any).tenantRvwConfig.findUnique({
|
||||
where: { tenantId: taskWithTenant.tenantId },
|
||||
select: {
|
||||
editorialBaseStandard: true,
|
||||
editorialExpertPrompt: true,
|
||||
editorialHandlebarsTemplate: true,
|
||||
methodologyExpertPrompt: true,
|
||||
methodologyHandlebarsTemplate: true,
|
||||
dataForensicsExpertPrompt: true,
|
||||
dataForensicsHandlebarsTemplate: true,
|
||||
clinicalExpertPrompt: true,
|
||||
editorialRules: true,
|
||||
dataForensicsLevel: true,
|
||||
finerWeights: true,
|
||||
clinicalHandlebarsTemplate: true,
|
||||
},
|
||||
});
|
||||
if (cfg) {
|
||||
tenantRvwConfig = cfg;
|
||||
tenantRvwConfig = {
|
||||
...cfg,
|
||||
editorialBaseStandard: cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase,
|
||||
};
|
||||
logger.info('[reviewWorker] 已加载租户审稿配置', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
|
||||
hasEditorialPrompt: !!cfg.editorialExpertPrompt,
|
||||
hasDataPrompt: !!cfg.dataForensicsExpertPrompt,
|
||||
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
|
||||
});
|
||||
} else {
|
||||
tenantRvwConfig = {
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
};
|
||||
logger.info('[reviewWorker] 未找到租户审稿配置,回退系统基线', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user