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:
2026-03-15 11:51:35 +08:00
parent 16179e16ca
commit 83e395824b
44 changed files with 2555 additions and 312 deletions

View File

@@ -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";

View File

@@ -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")
/// 稿约规范评估:租户自定义业务 Promptnull 表示继承系统默认)
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")
/// 数据验证:租户自定义业务 Promptnull 表示继承系统默认)
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

View 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);
});

View File

@@ -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: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。

View File

@@ -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

View File

@@ -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 || '保存审稿配置失败' });
}
}

View File

@@ -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,
});
}

View File

@@ -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 };
});
}

View File

@@ -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({

View File

@@ -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,
},
});

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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;
}
/**

View File

@@ -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,

View File

@@ -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[] = [];

View File

@@ -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);

View File

@@ -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,
});
}
}

View File

@@ -1,10 +1,11 @@
# AIclinicalresearch 系统当前状态与开发指南
> **文档版本:** v7.1
> **文档版本:** v7.2
> **创建日期:** 2025-11-28
> **维护者:** 开发团队
> **最后更新:** 2026-03-14
> **最后更新:** 2026-03-15
> **🎉 重大里程碑:**
> - **🆕 2026-03-15RVW V4.0 期刊配置中心 MVP 完成!** 新增 ADMIN 一级菜单「期刊配置中心」+ JOURNAL 租户能力 + 中英稿约基线 + 运行时最小配置适配;租户登录路径统一为 `/:tenantCode/login`,创建/更新 JOURNAL 自动开通 RVW 模块
> - **🆕 2026-03-14RVW V4.0 租户门户 MVP 链路打通!** 期刊专属登录页 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看闭环可用;租户列表状态图标展示修正(完成态不再误显示审稿中)
> - **🆕 2026-03-13RVW 方法学稳定性增强V3.0.2** 方法学 20 检查点结构化增强 + A/B/C 分治并行评估 + 规则汇总器统一结论 + 前端展示口径收敛(按三大项分组展示检查点)
> - **🆕 2026-03-09认证模块接入阿里云短信验证码** 登录验证码链路支持 `mock/aliyun` 双模式 + 后端短信服务封装 + 独立联调脚本(`npm run test:sms`+ 实机发送验证通过
@@ -40,6 +41,9 @@
> - **2026-01-22OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
>
> **🆕 最新进展(含认证短信集成 2026-03-09**
> - ✅ **🆕 RVW V4.0 期刊配置中心 MVP 完成** — 运营管理端新增期刊配置中心,支持 JOURNAL 期刊中英基线配置(`editorial_base_standard`)与 4 维 Prompt/Template 管理;执行链路接入 `tenantCustom ?? systemDefault` 最小适配
> - ✅ **🆕 RVW 登录路径统一** — 期刊登录统一为 `/:tenantCode/login`(开发/生产同路径,仅域名不同);不再保留 `/t/:tenantCode/login`
> - ✅ **🆕 JOURNAL 自动模块兜底** — 创建/更新为 `JOURNAL` 时自动开通 `RVW`,解决“用户能登录但无智能审稿模块权限”问题
> - ✅ **🆕 RVW V4.0 租户门户 MVP 联调通过** — `/:tenantSlug/rvw` 链路可用,执行审稿后回归旧版过程页交互(动态进度 + 动态Tab上传按钮与状态图标问题修复
> - ✅ **🆕 RVW 方法学稳定性增强V3.0.2** — `checkpoints` 结构化输出20项+ 方法学 A/B/C 分治并行评估1-9/10-14/15-20+ 规则汇总器统一 `summary/conclusion` + 前端展示按三大项分组
> - ✅ **🆕 认证短信验证码接入完成** — `sendVerificationCode` 接入阿里云短信网关(保留 `mock`+ 发送成功后再落库验证码 + 环境变量校验 + 联调脚本 `test:sms` + 实机发送验证通过
@@ -1352,6 +1356,7 @@ AIclinicalresearch/
| **2026-03-01** | **IIT GCP报表+Bug修复** 🎉 | ✅ 4张GCP标准报表+AI时间线增强+一键全量质控+6项Bug修复 |
| **2026-02-27** | **DB文档+部署体系** 📚 | ✅ 6篇数据库文档+部署归档+统一操作手册+开发规范v3.0+Schema对齐 |
| **2026-03-14** | **RVW V4.0 租户门户联调通过** 🎉 | ✅ 上传/审稿/过程页/报告闭环可用,状态图标与交互体验修正 |
| **2026-03-15** | **RVW V4.0 期刊配置中心 MVP 完成** 🚀 | ✅ ADMIN 期刊配置中心 + JOURNAL 中英基线 + 登录路径统一 + JOURNAL 自动开通 RVW |
| **当前** | **PKB模块生产可用** | ✅ 核心功能全部实现95%自研RAG+OSS存储上线 |
| **2026-01-07 晚** | **RVW模块开发完成** 🎉 | ✅ Phase 1-3完成后端迁移+数据库扩展+前端重构) |
@@ -1689,9 +1694,9 @@ if (items.length >= 50) {
---
**文档版本**v7.1
**最后更新**2026-03-14
**本次更新**RVW V4.0 租户门户 MVP 联调通过(期刊专属登录 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看)并修复租户列表维度状态显示误判问题;明日进入运营管理端期刊配置中心完善(中英期刊 Skills 匹配 + 租户登录风格 + URL 配置)
**文档版本**v7.2
**最后更新**2026-03-15
**本次更新**RVW V4.0 期刊配置中心 MVP 完成JOURNAL 模型与配置能力补齐、执行链路接入中英基线与继承覆盖、登录路径统一为 `/:tenantCode/login`、JOURNAL 自动开通 RVW 模块),进入生产部署与灰度验证阶段
---

View File

@@ -1,10 +1,10 @@
# RVW稿件审查模块 - 当前状态与开发指南
> **文档版本:** v6.3
> **文档版本:** v6.4
> **创建日期:** 2026-01-07
> **最后更新:** 2026-03-14
> **最后更新:** 2026-03-15
> **维护者:** 开发团队
> **当前状态:** 🚀 **V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告全链路可用**
> **当前状态:** 🚀 **V4.0 期刊配置中心 MVP 开发完成(租户门户+配置中心+中英基线+权限兜底**
> **文档目的:** 快速了解RVW模块状态为新AI助手提供上下文
>
> **🎉 V3.0 进展2026-03-07**
@@ -29,12 +29,19 @@
> - ✅ **前端展示口径统一**:方法学报告按“三大项->检查点”展示去除重复占位文案并显示真实LLM内容
>
> **🆕 V4.0 租户门户联调收敛2026-03-14**
> - ✅ **期刊专属登录页可用**`/t/:tenantCode/login` 支持密码登录 + 验证码登录与主站能力对齐UI为期刊门户风格
> - ✅ **期刊专属登录页可用**`/:tenantCode/login` 支持密码登录 + 验证码登录与主站能力对齐UI为期刊门户风格
> - ✅ **上传链路修复**:租户工作台上传按钮稳定触发,`POST /api/v1/rvw/tasks` 创建任务成功
> - ✅ **执行审稿体验回归旧流程**:点击“开始审稿”后直接进入过程页(复用 `TaskDetail` 动态进度 + 动态Tab
> - ✅ **租户详情页全屏化复用**:租户路由下适配旧版过程页交互,无 Sidebar 干扰
> - ✅ **列表状态图标修复**:审稿完成后维度状态正确展示(绿色对钩/警告/错误),不再误显示“审稿中”
>
> **🆕 V4.0 期刊配置中心 MVP2026-03-15**
> - ✅ **运营管理端新增一级菜单「期刊配置中心」**:支持列表/详情/配置保存
> - ✅ **JOURNAL 租户模型补齐**`tenants` 增加 `journal_language/journal_full_name/logo_url/brand_color/login_background_url`
> - ✅ **审稿配置补齐**`tenant_rvw_configs` 支持 4 维 Prompt + Handlebars 模板 + `editorial_base_standard(zh/en)`
> - ✅ **执行链路最小适配**`finalPrompt = tenantCustom ?? systemDefault`,稿约支持中英基线路由
> - ✅ **租户权限兜底**:创建/更新为 `JOURNAL` 时自动开通 `RVW` 模块,避免新用户登录后无模块权限
>
> **V2.0 进展回顾:**
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
> - ✅ Skills 核心框架types, registry, executor, profile
@@ -54,7 +61,7 @@
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
| **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) |
| **目标用户** | 期刊初审编辑 |
| **开发状态** | 🚀 **V4.0 租户门户 MVP 已联调通过:上传→审稿→过程页→报告闭环可用** |
| **开发状态** | 🚀 **V4.0 期刊配置中心 MVP 已开发完成:配置中心→租户门户→上传→审稿→报告闭环可用** |
### 核心目标
@@ -104,7 +111,7 @@
| 状态筛选 | ❌ | ✅ | ✅ 已完成 |
| 历史归档 | ❌ | ✅ | ⏸️ 数据库已支持UI暂缓 |
| 系统设置 | ❌ | ✅ | ⏸️ 暂不开发 |
| 登录页面 | ❌ | ⏸️ | ⏸️ 复用平台登录 |
| 期刊登录页面(单一路径) | ❌ | | `/:tenantCode/login` 已上线,开发/生产同路径 |
| PICO卡片 | ❌ | ✅ | ⏸️ 数据库已支持UI暂缓 |
---
@@ -499,7 +506,7 @@ Content-Type: multipart/form-data
---
**文档版本:** v6.3
**最后更新:** 2026-03-14
**当前状态:** 🚀 V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告闭环
**下一步:** 完善运营管理端期刊配置中心(中英期刊 Skills 匹配 + 租户登录风格 + URL/品牌配置联动
**文档版本:** v6.4
**最后更新:** 2026-03-15
**当前状态:** 🚀 V4.0 期刊配置中心 MVP 开发完成(配置中心 + 租户门户 + 中英基线 + 权限兜底
**下一步:** 进入生产部署与灰度验证Nginx 深链回退、前端缓存清理、期刊链路冒烟回归

View File

@@ -0,0 +1,30 @@
# **JTIM 期刊稿约规范梳理**
# **分析对象: *Journal of Translational Internal Medicine* (JTIM) 2026-03-10 最新版稿约**
【JTIM 期刊全维稿约核查规则】
作为严谨的医学期刊审稿人,请对稿件的结构、伦理及排版进行全面深度核查。必须严格遵守以下规则池:
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal
1\. 双盲审查 (Double-blind):正文 (Main Text/Blinded Article) 中绝对不可出现作者姓名、机构、致谢、资助编号等身份信息。
2\. 伦理与知情同意:涉及人类或动物实验必须有 IRB/IACUC 批准说明涉及人类受试者含病例报告必须有知情同意书Informed Consent获取声明。
3\. 声明完整性:正文或结尾必须包含利益冲突声明 (Conflict of Interest)、资金资助声明 (Funding)。若是临床干预试验必须有注册号。
4\. AI 与署名:严禁 AI 工具作为作者署名。若声明使用了 AI 辅助必须予以记录。
二、 🟡 JTIM 专属体例与排版要求(重点核查项,若违反请标记为 major\_issue
5\. 语言校验:全篇必须严格统一使用美式英语 (American English)。
6\. 引言篇幅Original articles原创论著的引言 (Introduction) 必须极其简短,严格校验是否在 75-100 个单词范围内。
7\. 摘要合规性:论著必须为 Structured 格式(包含 Objectives, Methods, Results, Conclusions限 250 字;综述限 200 字;病例限 150 字。
8\. 参考文献与引文格式:
\- 正文引用:必须使用阿拉伯数字上标,用方括号包裹,且必须置于标点符号【之后】(例:...disease.^\[1\])。
\- 列表格式:必须列出前 6 位作者,其余用 et al. 替代。
9\. 统计与图表:必须提供 P 值的精确数值,且统计学字母必须为斜体(如:\*P\* \= 0.048图表图例Figure legends不得超过 40 个单词。表格严禁为图片格式。
10\. 作者数量警报论著与综述共同作者co-authors超 3 人、其余类型超 2 人时,必须予以警告。
11\. 标题要求:章节标题使用 Title case实词首字母大写绝对不能全部大写 (Not ALL CAPITALS)。Running title 限制在 50 个字符以内。
三、 🟢 通用学术写作规范(常规核查项,若违反请标记为 minor\_issue
12\. 数字拼写:句首的数字必须拼写成英文单词(如 "Fifteen patients...");正文中 1-10 的数字通常要求拼写(带有度量衡单位的除外)。
13\. 学术专有名词:种属名称(如 \*E. coli\*)、基因符号必须使用斜体。
14\. 度量单位:必须使用标准的国际单位制 (SI Units)。
请系统性地输出上述规则的违反情况,如果没有任何违反,请回复“符合所有规范要求”。

View File

@@ -0,0 +1,39 @@
# **Medical Review (MR) 期刊专属稿约审查提示词 (Prompt)**
**使用场景:** 在 ADMIN 配置中心,当运营人员为 Medical Review 期刊配置稿约规范时,填入专家评判标准 (Expert Prompt) 文本框的内容。
**设计逻辑:** 依据 MR 2025-09-23 最新版 Author Guidelines 深度定制。重点加强了其特有的 Sentence case 标题规范、包容性语言要求、单盲属性(移除双盲核查)以及严格的文章类型硬指标。
请将以下内容直接复制到 MR 期刊配置的 Expert Prompt 中:
【Medical Review (MR) 期刊全维稿约核查规则】
作为严谨的国际医学期刊《Medical Review》的审稿人请对稿件的结构、伦理及排版进行全面深度核查。必须严格遵守以下 MR 官方指南要求:
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal
1\. 伦理与知情同意:涉及人类/动物实验必须有 IRB/IACUC 批准说明涉及人类受试者必须有知情同意书Informed consent获取声明。
2\. 声明完整性:正文末尾(或模板中)必须包含利益冲突声明 (Conflict of Interest)、资金资助声明 (Research funding) 以及临床试验注册号 (UTN, 若适用)。
3\. 数据共享声明 (Data availability)MR 强制要求符合欧洲 GDPR 规范的数据共享政策,必须说明研究数据在哪里可以获取。
4\. AI 与署名合规:严禁将大模型(如 ChatGPT, AI列为作者。若研究方法或写作过程中使用了 AI 工具,必须在 Methods 或声明模板中清晰披露。
\*(注MR 采取单盲 Peer Review正文可保留作者与机构信息无需进行双盲排查)\*
二、 🟡 MR 专属体例与排版要求(重点核查项,若违反请标记为 major\_issue
5\. 标题与层级要求 (Sentence case)
\- 文章标题 (Title) 和所有章节小标题 (Headings) 必须使用 Sentence case仅首字母大写冒号后使用小写字母
\- 短标题 (Running head) 限制在 75 个字符以内(含空格)。
\- 章节标题不可带数字编号,最多不超过 4 级。
6\. 摘要合规性 (严格限制 200 words 以内)
\- Research articles / Short Communications必须为结构化摘要 (Objectives; Methods; Results; Conclusions)。
\- Reviews可为非结构化摘要或特定结构的摘要 (Objectives; Content; Summary and Outlook)。
7\. 各文章类型硬性指标:
\- Research Article正文字数 3000-5000字图表总数不超过 6 个;参考文献大于 50 篇。
\- Review正文字数大于 6000 字;图表总数 5-10 个;参考文献大于 80 篇。
\- Systematic Review正文字数小于 6000 字;图表总数 3-8 个;参考文献大于 50 篇。
8\. 图表与引用独立性表格必须是可编辑格式Word/LaTeX严禁使用图片格式的表格避免使用垂直线和底纹图表必须在正文中按阿拉伯数字顺序交叉引用如 Figure 1, Table 1
三、 🟢 学术写作规范与细节(常规核查项,若违反请标记为 minor\_issue
9\. 包容性语言 (Inclusive language):检查是否符合包容性原则,建议使用性别中立词汇(如尽量使用复数名词或 "they",避免使用 "he" 或 "she")。
10\. 语言与拼写:全篇必须一致地使用美式英语或英式英语。
11\. 关键词与缩写:提供 3-8 个全小写的关键词(用分号隔开);所有非常规缩写在摘要和正文首次出现时必须写出全称。
12\. 格式规范:数字与国际标准单位 (SI Units) 之间必须保留一个窄空格°C 和 % 除外);物种拉丁名(如 \*E. coli\*)和基因缩写必须使用斜体。
请系统性地输出上述规则的违反情况。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“符合 Medical Review 所有官方规范要求”。

View File

@@ -1,131 +0,0 @@
# **RVW V4.0 期刊租户配置中心开发需求**
**模块定位:** 依托现有 ADMIN 运营端 TenantDetailPage为每个期刊客户建立独立的配置中枢。
**核心目标:** 实现期刊 SaaS 的“千刊千面”,包括基础品牌信息定制与底层 AI 审稿 Skills 规则的动态编排。
## **🎯 一、 前端 UI 与功能分区要求**
在现有的 ADMIN 租户详情页TenantDetailPage构建两大核心配置区块可作为独立的 Tab 或折叠面板):
### **区块 1期刊基础信息与门户配置 (Basic Info & Portal)**
用于定义该期刊的对外面貌和访问入口。
| 配置项名称 | 字段类型 | UI 组件 | 功能说明 |
| :---- | :---- | :---- | :---- |
| **期刊全称** | String | 文本输入框 | 如:*Journal of Translational Internal Medicine*。显示在登录页及工作台顶部。 |
| **访问路径 (URL Slug)** | String | 文本输入框 (需正则校验) | **极重要**:用于生成 review.xunzhengyixue.com/{slug}。仅允许小写字母、数字和连字符。 |
| **期刊 Logo** | String | 图片上传组件 | 上传至 OSS 后保存 URL。用于替换默认的系统 Logo。 |
| **品牌主色调** | String | 颜色拾取器 (Color Picker) | 支持 HEX 色值(如 \#0284c7。用于动态渲染按钮和高亮文字。 |
| **专属登录页背景图** | String | 图片上传组件 | (可选) 替换登录页的默认背景,增强品牌沉浸感。 |
### **区块 2智能审稿配置 (RVW Skills Config)**
这是该模块的核心,分为 4 个独立的配置面板Panel
#### **Panel A: 稿约规范评估 (Editorial)**
* **规则列表维护**:支持增删改查。每条规则需包含 规则编号 (Code) 和 规则描述 (Description)。
* **致命错误开关 (Fatal Flag)**:每条规则后跟随一个 Switch 开关。开启后,该项缺陷将作为“一票否决”项在前端红底高亮。
#### **Panel B: 方法学评估 (Methodology) \- 【需支持动静分离与预览】**
* **专家评判标准 (Prompt)**:大文本框。供运营人员输入该期刊特有的统计学和方法学要求(大模型读取)。
* **报告展示模板 (Handlebars)**:大文本框。用于配置最终输出给责编的 Markdown 格式。
* ⚠️ **强依赖功能:测试渲染 (Test Render)****必须**在模板编辑区提供一个“预览”按钮。点击后,使用系统内置的假 JSON 数据Mock Data与当前模板进行结合在右侧弹出抽屉实时渲染出 Markdown 结果,以防止语法错误。
#### **Panel C: 数据验证 (Data Forensics)**
* **验证深度 (Level)**提供三个单选按钮Radio
* L1 算术验证:仅核对行列加总、百分比。
* L2 统计验证:包含 L1增加 CI↔P 一致性逆向验证(推荐)。
* L3 双通道核查:包含 L2增加大模型智能深度核查耗时较长
#### **Panel D: 临床专业评估 (Clinical)**
* **FINER 权重占比**:提供 5 个数字输入框(或滑块),分别对应 Feasibility, Innovation, Novelty, Ethical, Relevant。前端需实时校验总和是否等于 100%。
* **专科特色要求**:文本区,填写该期刊的专科倾向性偏好(如:心血管领域的特殊要求)。
## **💾 二、 数据库 Schema 设计 (Prisma)**
本模块涉及两部分数据的存储:基础信息存入已有的 Tenants 表,审稿配置存入新增的 TenantRvwConfig 表。
### **1\. 基础信息存储 (platform\_schema.tenants)**
利用现有的租户表。假设现有表结构已有 code (即 URL Slug) 和 name (期刊名称)。对于 Logo、颜色等品牌视觉资产建议统一存入 config JSON 字段中。
// 无需新建表,在原 Tenants 表中合理利用字段
model Tenant {
id String @id @default(uuid())
code String @unique // 对应 URL Slug如 'jtim'
name String // 期刊全称
// 品牌视觉信息存入此 JSON 字段,避免频繁改表结构
config Json? // 格式:{ logoUrl: "...", brandColor: "\#...", bgImgUrl: "..." }
rvwConfig TenantRvwConfig? // 关联 RVW 配置表
}
### **2\. RVW Skills 配置存储 (新增表)**
执行 prisma migrate dev 创建以下 1对1 扩展表:
model TenantRvwConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
// Panel A: 稿约规范 (存为 JSON 数组)
// 结构: \[{ code: 'ED-01', desc: '摘要需控制在250字内', isFatal: true }\]
editorialRules Json?
// Panel B: 方法学评估
methodologyExpertPrompt String? @db.Text
methodologyHandlebarsTemplate String? @db.Text
// Panel C: 数据验证
dataForensicsLevel String @default("L2") // 'L1', 'L2', 'L3'
// Panel D: 临床评估 (存为 JSON)
// 结构: { f: 20, i: 20, n: 20, e: 20, r: 20 }
finerWeights Json?
clinicalExpertPrompt String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@schema("platform\_schema")
}
## **🔌 三、 后端 API 接口设计**
新增统一的租户管理与 RVW 配置接口:
### **1\. 内部 ADMIN 配置接口 (需要 ops:user-ops 权限)**
* PUT /api/admin/tenants/:id/basic-info更新期刊名称、Slug、品牌 Logo 等信息。
* GET /api/admin/tenants/:id/rvw-config获取指定期刊的 Skills 配置。
* PUT /api/admin/tenants/:id/rvw-configUPSERT (更新或创建) 期刊 Skills 配置。
### **2\. 公开业务接口 (无 Auth用于动态登录页渲染)**
* GET /api/v1/tenants/public-info/:slug
* **参数**slug (例如 'jtim')
* **返回**{ name, logoUrl, brandColor, bgImgUrl }
* **限流要求**:必须添加 Rate Limiting (防刷防爬)。
## **👥 四、 实施 SOP用户与期刊绑定无需开发**
**给开发团队的提示**:业务上,客户(责编)登录 /jtim 后能看到 JTIM 的稿件,依赖于正确的账号绑定。这部分**不需要开发新功能**,但需要通知实施团队遵循以下 SOP
1. 客户在主站注册账号(或由超管分配账号)。
2. 内部超管在 ADMIN 端的 **【用户管理 \-\> 租户成员管理】** 页面,将该用户的账号添加到目标期刊租户(如 JTIM下。
3. 确保该用户拥有 RVW 模块的使用权限。
4. **验证**:此后该用户访问 review.xunzhengyixue.com/jtim 时JWT 中会自动带上该租户身份,接口方可放行。
## **✅ 五、 验收标准 (Definition of Done)**
1. 运营人员可在后台顺滑配置 JTIM 和 CMJ 两套截然不同的方法学 Prompt 和 Handlebars 模板。
2. 在 Handlebars 编辑器中点击“预览”,能立刻看到正确渲染的 Markdown 格式报告,不报错。
3. 未登录用户访问 review.xunzhengyixue.com/jtim 时,登录页自动变更为 JTIM 的名称、Logo 和主色调。

View File

@@ -0,0 +1,161 @@
# **RVW V4.0 期刊租户配置中心开发需求**
**模块定位:** 依托现有 ADMIN 运营端 TenantDetailPage为每个期刊客户建立独立的配置中枢。
**核心目标:** 实现期刊 SaaS 的“千刊千面”。
**架构原则:**
1. **配置继承模式 (Config Inheritance)**:系统提供【标准默认配置】。期刊默认继承系统标准;需要个性化时,可开启【自定义覆盖】。
2. **中英双轨基线 (Dual-Baseline)**:针对稿约规范,系统提供“英文期刊”与“中文期刊”两套底层标准,供租户自主选择绑定。
3. **全面 Prompt 化 (All-in-Prompt)**:取消细颗粒度表单,采用“专家评判标准 (Prompt) \+ 报告展示模板 (Handlebars)”的开放式极简架构。
## **🎯 一、 前端 UI 与功能分区要求**
在现有的 ADMIN 租户详情页TenantDetailPage构建两大核心配置区块
### **区块 1期刊基础信息与门户配置 (Basic Info & Portal)**
用于定义该期刊的对外面貌和访问入口。
| 配置项名称 | 阶段 | 字段类型 | UI 组件 | 功能说明 |
| :---- | :---- | :---- | :---- | :---- |
| **期刊全称** | \[P0\] | String | 文本输入框 | 如:*Journal of Translational Internal Medicine*。显示在登录页及工作台顶部。 |
| **访问路径(Slug)** | \[P0\] | String | 文本输入框(需正则) | **极重要**:用于生成 review.xunzhengyixue.com/{slug}。仅允许小写字母、数字和连字符。 |
| **期刊 Logo** | \[P1\] | String | 图片上传组件 | 上传至 OSS 后保存 URL。用于替换默认的系统 Logo。 |
| **品牌主色调** | \[P1\] | String | 颜色拾取器 | 支持 HEX 色值。用于动态渲染按钮和高亮文字。 |
| **登录页背景图** | \[P2\] | String | 图片上传组件 | (暂不开发) 替换登录页的默认背景。 |
### **区块 2智能审稿配置 (RVW Skills Config) —— 【全维度 Prompt 架构】**
这是该模块的核心。对于 4 个配置面板,顶部必须增加一个全局切换组件:
* 🔘 **选项 A: 继承系统默认配置 (Inherit System Default)** \[P0\] —— 选中时,下方大文本框置灰只读。
* 🔘 **选项 B: 启用个性化自定义 (Custom Override)** \[P0\] —— 选中时,解锁下方大文本框,允许大面积修改。
#### **Panel A: 稿约规范评估 (Editorial) —— 【支持中英标准选择】**
作为特殊的规范模块,当选择 **“继承系统默认配置”** 时,必须提供一个**下拉单选框**供运营人员选择:
* 🔘 **标准版英文期刊规范 (English Standard)** \[P0\]:自动绑定英文 SCI 级审查标准(严查伦理声明等)。
* 🔘 **标准版中文稿约规范 (Chinese Standard)** \[P0\]:自动绑定中文核心期刊审查标准(查中英文摘要对照等)。
当选择 **“启用个性化自定义”** 时,展示下方的通用组件。
#### **统一的面板配置项(适用于 Panel B / C / D 及自定义状态下的 Panel A**
* **1\. 专家评判标准 (Expert Prompt)** \[P0\]:大文本框。自由输入该期刊特有的审查要求(大模型读取)。
* **2\. 报告展示模板 (Handlebars Template)** \[P0\]:大文本框。用于配置最终输出给责编的 Markdown 格式。
* ⚠️ **3\. 测试渲染 (Test Render)** \[P1\]:在模板编辑区提供“预览”按钮。使用假 JSON 数据在右侧实时渲染 Markdown 结果,防语法写错导致线上白屏。(MVP 阶段若来不及,可由运营人员去前台真实传 PDF 测试兜底)。
## **💾 二、 数据库 Schema 设计 (Prisma) \[P0\]**
**DB 要求**:即便 UI 是分阶段开发,数据库字段必须在 \[P0\] 阶段一次性建好,避免后续频繁 Migrate。
利用数据库的 null 语义表达“继承”。取消原有的 JSON 数组,统一为 Text 类型的 Prompt 和 Template 字段。
重点新增 editorialBaseStandard 字段,用于持久化期刊的中英文基线选择。
model TenantRvwConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
// Panel A: 稿约规范评估
// 'en' 代表选用标准英文规范,'zh' 代表选用标准中文规范。
editorialBaseStandard String @default("en")
editorialExpertPrompt String? @db.Text
editorialHandlebarsTemplate String? @db.Text
// Panel B: 方法学评估
methodologyExpertPrompt String? @db.Text
methodologyHandlebarsTemplate String? @db.Text
// Panel C: 数据验证评估
dataForensicsExpertPrompt String? @db.Text
dataForensicsHandlebarsTemplate String? @db.Text
// Panel D: 临床专业评估
clinicalExpertPrompt String? @db.Text
clinicalHandlebarsTemplate String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@schema("platform\_schema")
}
## **🔌 三、 后端 API 与底层引擎继承逻辑 (核心)**
### **1\. 内部 ADMIN 配置接口 (需要 ops:user-ops 权限) \[P0\]**
* PUT /api/admin/tenants/:id/basic-info更新期刊名称、Slug (P1阶段再接 Logo 等)。
* GET /api/admin/tenants/:id/rvw-config获取指定期刊的 Skills 配置(若不存在,返回 {})。
* PUT /api/admin/tenants/:id/rvw-configUPSERT 期刊 Skills 配置。**若前端选择“继承系统默认”,对应的 ExpertPrompt 和 Template 传 null但 editorialBaseStandard 必须传选中的值('en'或'zh'**。
### **2\. 底层引擎运行时的“降级合并 (Fallback Merge)”逻辑 \[P0\]**
后端重构 SkillExecutor 时,实施深度合并策略。以 **稿约规范 (EditorialSkill)** 为例:
async function executeEditorialSkill(manuscript, tenantConfig) {
let promptToApply;
// 1\. 如果租户自定义了稿约 Prompt直接使用租户的开放式配置 (优先级最高)
if (tenantConfig && tenantConfig.editorialExpertPrompt) {
promptToApply \= tenantConfig.editorialExpertPrompt;
}
// 2\. 租户未自定义(为 null触发系统默认降级逻辑
else {
// 2.1 读取该期刊在后台配置的“标准语言基线”
const baseStandard \= tenantConfig?.editorialBaseStandard || 'en';
// 2.2 根据基线拉取对应的【系统标准版规范】Prompt
if (baseStandard \=== 'en') {
promptToApply \= await promptService.get('SYSTEM\_DEFAULT\_EDITORIAL\_EN');
} else if (baseStandard \=== 'zh') {
promptToApply \= await promptService.get('SYSTEM\_DEFAULT\_EDITORIAL\_ZH');
}
}
// 3\. 将 promptToApply 喂给 LLM 执行,并使用对应 Handlebars 模板渲染...
}
方法学、数据验证和临床评估的逻辑同理:最终提示词 \= 租户自定义 Prompt ?? 系统默认 Prompt。
## **👥 四、 实施 SOP用户与期刊绑定无需开发**
1. 客户在主站注册账号(或由超管分配账号)。
2. 内部超管在 ADMIN 端的 **【用户管理 \-\> 租户成员管理】** 页面,将该用户的账号添加到目标期刊租户(如 JTIM下。
3. 确保该用户拥有 RVW 模块的使用权限。
4. **验证**:此后该用户访问 review.xunzhengyixue.com/jtim 时JWT 中会自动带上该租户身份,接口方可放行。
## **✅ 五、 验收标准 (Definition of Done \- P0 阶段)**
1. ADMIN 后台成功渲染出只包含大文本框Textarea的极简配置界面。
2. **中英基线验证**
* 将 JTIM 的【稿约规范】设置为“继承系统默认”并选择\*\*【标准版英文期刊规范】\*\*,上传稿件时,系统严格核查伦理声明等英文规则。
* 将 CMJ 设置为“继承系统默认”并选择\*\*【标准版中文稿约规范】\*\*,系统重点核查中英文摘要对应等中文规则。
3. 当某期刊在【临床专业评估】开启自定义后,运营人员在大文本框中修改提示词并保存,新上传的稿件严格按该设定执行评估。
## **🚀 六、 分阶段实施与 MVP 裁剪指南 (给 PM 与研发团队)**
为了保证项目快速上线,坚决执行 Managed SaaS (代运营) 和 All-in-Prompt 的裁剪策略。
### **🔴 第一阶段MVP 必做清单 (P0) —— 目标:系统能跑通双语及个性化**
* **数据库**:一次性建好所有 Schema 字段(包含全部 Text 和 BaseStandard 字段)。
* **核心引擎**:必须完成 SkillExecutor 的降级合并逻辑(?? 合并)和中英文 Prompt 的自动路由。
* **ADMIN 前端**:只要最基础、最“丑”的表单输入框。实现“继承/自定义”的单选切换,并暴露 Textarea。不做任何复杂的花哨控件。
* **业务前端**:根据 URL 中的 /:tenantId 成功拦截并获取对应的【期刊名称】(纯文本展示即可)。
### **🟡 第二阶段:视觉与防呆增强 (P1) —— 目标:上线后 1-2 周内快反**
* **模板预览 (Test Render)**:复用底层 PromptService 补齐预览按钮,防止运营在纯文本框写错 Handlebars 语法导致线上白屏。
* **品牌视觉提升**:补齐期刊 Logo、主色调配置并在客户端动态渲染提升 SaaS 专属感。
* **接口安全**:为公开的品牌信息拉取接口增加 Redis 缓存与 Rate Limiting 限流防刷。
### **🟢 第三阶段:暂缓或砍掉的伪需求 (P2) —— 目标:避免过度工程**
* **细颗粒度表单控件**:既然已经全面 Prompt 化,坚决不再开发滑块(如权重分配)、下拉枚举(如验证级别)等细分 UI 控件。全部用 Prompt 解决。
* **客户自助配置后台**:坚决不做。坚持代运营模式,期刊主编提需求,内部运营人员在 ADMIN 端修改,规避复杂的 B 端权限开发。
* **登录页背景图定制**:暂不开发,统一使用系统默认干净纯色底即可。

View File

@@ -0,0 +1,44 @@
# **中国医学期刊2025新国标稿约审查提示词 (Prompt)**
**使用场景:** 在 ADMIN 配置中心,当运营人员为某中文期刊租户选择 **“继承系统默认配置 \-\> 标准版中文稿约规范”** 时,系统底层默认调用的 Prompt。
**标准依据:** 深度融合 2025 年发布的最新三大国家标准GB/T 7714-2025 (参考文献)、GB/T 6447-2025 (文献摘要)、GB/T 7713.1-2025 (学术体例)。
**设计逻辑:** 由于 LLM 缺乏 2025 年新规的先验知识,本 Prompt 采取“规则穷举显式化”设计,直接将判罚红线硬编码在提示词中。
请将以下内容直接复制到系统“中文版标准规范”的底层 Prompt 库中:
【2025最新版中文医学期刊全维稿约核查规则】
作为严谨的中国医学核心期刊审稿人请对稿件的结构、学术规范及排版进行全面深度核查。你必须严格依据中国最新的三大国家标准GB/T 6447-2025、GB/T 7714-2025、GB/T 7713.1)执行检查。请牢记并严格套用以下规则池:
一、 🔴 摘要与关键词规范(依据 GB/T 6447-2025若违反请标记为 major\_issue
1\. 结构式摘要要求原创论著的摘要必须为“结构式摘要”强制包含【目的】、【方法】、【结果】、【结论】4个明确要素或类似等效标识
2\. 摘要内容禁区:摘要中严禁出现参考文献的引用(即不能带有 \[1\]、\[2\] 等引用角标);应尽量避免采用插图、表格、非公知公用的符号和缩写词。
3\. 关键词数量:每篇论文必须提供 3个\~8个 关键词,多个关键词之间通常用分号 (;) 分隔。
4\. 中英文一致性必须同时提供中文摘要与英文摘要Abstract且英文摘要的要素和含义应与中文摘要保持对应一致。
二、 🟡 参考文献著录规范(依据 GB/T 7714-2025重点核查项若违反请标记为 major\_issue
5\. 责任者(作者)著录规则:
\- 欧美作者名必须“姓在前名在后”Einstein A不能写 A Einstein
\- 中外文作者若不超过 3 个,必须全部照录;若超过 3 个,只著录前 3 个责任者,其后必须加“, 等”或“, et al.”(注意前置逗号)。
6\. 文献类型与载体标识代码(新规重点,检查代码是否规范):
\- 必须准确标注类型:普通期刊 \[J\]、专著图书 \[M\]、学位论文 \[D\]、报告 \[R\]、会议录 \[C\]。
\- \*\*2025新规必须支持并识别\*\*:预印本 \[PP\]、数据集 \[DS\]、网站/网页 \[EB/OL\] 或 \[EB\]。
\- 电子资源必须标明载体,如 \[J/OL\]、\[M/OL\]、\[DS/OL\]。
7\. 年卷期与页码格式:
\- 连续出版物(期刊)的标准格式必须为:\`年, 卷(期): 起讫页码.\`(例如:\`2024, 46(8): 102-111.\`)。
8\. 日期格式:引用的电子资源、网站网页的发布日期、更新日期和引用日期,必须采用 \`YYYY-MM-DD\` 格式(如:\[2025-07-15\])。
9\. DOI 永久标识符:强烈建议电子文献末尾带有持久标识符(如 DOI 或 CSTR。格式示例\`DOI:10.1038/nature13308.\`
三、 🟢 学术体例与语言规范(依据 GB/T 7713.1-2025常规核查项若违反请标记为 minor\_issue
10\. 图表规范:
\- 表格:应具有“自明性”,必须采用国际通行的“三线表”格式(避免使用复杂的内部网格线和垂直线)。
\- 图表编号与图题图题置于图的下方表题置于表的上方。必须按出现顺采用阿拉伯数字连续编号图1、表1
11\. 计量单位与科学名词:
\- 必须严格采用国家法定的国际单位制SI units
\- 首次出现外文专业术语、缩略词时,必须用圆括号注明原词语全称。
12\. 学术诚信与伦理(一票否决项,若违反请标记为 fatal
\- 涉及人类受试者或动物的临床/实验研究正文中必须明确提及“伦理委员会批准”以及“知情同意Informed Consent”。
\- 严禁将 AI如 ChatGPT 等大模型)列为共同作者。
请系统性地输出上述规则的违反情况(包含具体的缺陷位置和修改建议)。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“完全符合 2025 版中文医学期刊国家标准”。

View File

@@ -0,0 +1,32 @@
# **通用英文医学期刊标准稿约审查提示词 (Prompt)**
**使用场景:** 在 ADMIN 配置中心,当运营人员为某租户选择 **“继承系统默认配置 \-\> 标准版英文期刊规范”** 时,底层 SkillExecutor 默认调用的 Prompt。
**设计逻辑:** 覆盖主流 SCI 期刊及 ICMJE 规范的通用要求,作为英文期刊的“最大公约数”基线。
请将以下内容直接复制到系统的默认 Prompt 库中:
【标准版英文医学期刊全维稿约核查规则】
作为严谨的国际医学期刊审稿人请对稿件的结构、伦理及学术排版进行全面深度核查。必须严格遵守以下通用国际规则池ICMJE 规范):
一、 🔴 致命错误与伦理规范(一票否决项,若违反请标记为 fatal
1\. 伦理与知情同意:涉及人类或动物实验必须有 IRB/IACUC 伦理委员会批准说明涉及人类受试者含病例报告必须有知情同意书Informed Consent获取声明。
2\. 声明完整性:正文或结尾必须包含利益冲突声明 (Conflict of Interest / Competing Interests) 和资金资助声明 (Funding / Support)。即使没有,也必须声明 "None declared"。若是临床干预试验必须提供注册号。
3\. AI 与署名:严禁 AI 工具(如 ChatGPT, LLM作为作者署名。若声明使用了 AI 辅助写作或数据分析,必须予以清晰记录。
4\. 盲审风险提示 (Blinded Article Check):请排查正文 (Main Text) 中是否遗留了作者姓名、具体机构名称、致谢或可溯源的资助编号(若期刊采取单盲则忽略,但通常建议分离至 Title Page
二、 🟡 核心体例与排版要求(重点核查项,若违反请标记为 major\_issue
5\. 摘要合规性:
\- 论著 (Original Research) 必须为结构化摘要 (Structured Abstract通常包含 Background/Objectives, Methods, Results, Conclusions),且字数一般在 200-250 字以内。
\- 综述或病例报告通常为非结构化摘要,字数一般在 150-200 字以内。
6\. 统计学表述规范:必须提供 P 值的精确数值(如 P \= 0.048),严禁简单表述为 "p \< 0.05"(极小值除外,如 P \< 0.001)。统计学参数字母通常需斜体。
7\. 参考文献与引文一致性:检查正文引用(如上标方括号或作者-年份)是否全篇保持高度一致,并检查参考文献列表是否存在明显的格式错乱。
8\. 图表独立性表格严禁为不可编辑的图片格式应为文本表格多采用三线表所有图表必须在正文中被按顺序交叉引用Cross-referenced
三、 🟢 通用学术写作规范(常规核查项,若违反请标记为 minor\_issue
9\. 语言一致性:全篇必须严格统一使用美式英语 (American English) 或英式英语 (British English),绝不可混用。
10\. 数字拼写规则:句首的数字必须拼写成英文单词(如 "Fifteen patients...",严禁写 "15 patients");正文中 1-10 的数字通常要求使用英文拼写(带有度量衡单位的除外,如 5 mg
11\. 学术专有名词:物种名称(如 \*E. coli\*)、基因符号等非英语的外来拉丁词汇必须使用斜体。
12\. 度量单位:必须使用标准的国际单位制 (SI Units),且数字与单位之间通常需要保留一个空格(% 和 °C 除外)。
请系统性地输出上述规则的违反情况。对于没有明确违反规则的项,无需列出。如果通篇没有任何违反,请回复“符合所有国际通用规范要求”。

View File

@@ -0,0 +1,326 @@
# RVW V4.0 期刊配置中心 MVP 开发计划
> 文档版本: v1.0
> 创建日期: 2026-03-15
> 适用范围: RVW 多租户期刊配置中心ADMIN 运营管理端)
> 约束前提: 严格遵循 `docs/04-开发规范/09-数据库开发规范.md`
---
## 1. 目标与范围
本计划用于落地 RVW V4.0 的「期刊配置中心」MVP满足以下目标
1. 在运营管理端新增独立一级模块「期刊配置中心」(类似 IIT 项目管理入口形态)。
2. 系统默认配置支持区分:
- 中文-稿约规范性
- 英文-稿约规范性
- 方法学、数据验证、临床专业评估(通用默认)
3. 期刊(如 AAA、BBB可选择
- 继承系统默认配置
- 在默认基础上进行覆盖
4. 期刊基础信息新增字段:`期刊语言`(中文/英文/其他)。
5. MVP 阶段功能可精简,但数据库字段一次性补齐(覆盖 P0/P1/P2 + 期刊语言)。
---
## 2. 关键输入文档
1. 需求基线
`docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/RVW V4.0 期刊租户配置中心开发需求0315.md`
2. 英文稿约默认规则(系统默认 EN 基线)
`docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/通用英文期刊稿约审查提示词.md`
3. 数据库规范(强制约束)
`docs/04-开发规范/09-数据库开发规范.md`
4. 既有总计划参考
`docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 期刊SaaS版开发计划.md`
---
## 3. 架构策略MVP 版)
### 3.1 配置分层
- 第 1 层(系统默认层)
- Editorial: `ZH` / `EN` 双基线
- Methodology / DataForensics / Clinical: 通用基线
- 第 2 层(期刊继承层)
- 默认继承系统配置
- 第 3 层(期刊覆盖层)
- 租户填入自定义 Prompt/Template 后覆盖系统默认
### 3.2 运行时合并规则
对任一技能维度,统一采用:
`最终配置 = 期刊自定义值 ?? 系统默认值`
Editorial 额外规则:
-`editorialExpertPrompt` 为空,则按 `editorialBaseStandard` 路由:
- `zh` -> 中文稿约默认 Prompt
- `en` -> 英文稿约默认 Prompt
- 若期刊未显式设置 `editorialBaseStandard`,按 `期刊语言` 映射默认值:
- 中文 -> `zh`
- 英文/其他 -> `en`
---
## 4. 数据模型设计(一次性补齐)
## 4.1 设计原则
- MVP 功能可分阶段启用,但字段一次性建完,避免重复迁移。
- 使用 `null` 表达“继承系统默认”。
- 严格使用 Prisma migration不允许手工 DDL 改库。
## 4.2 表结构调整
### A. `platform_schema.tenants`(期刊基础信息)
新增/补齐字段(若已有同义字段,统一命名后保留兼容映射):
- `journal_language` (`ZH` | `EN` | `OTHER`) // P0
- `journal_full_name` (String) // P0
- `logo_url` (String?) // P1
- `brand_color` (String?) // P1
- `login_background_url` (String?) // P2
### B. `platform_schema.tenant_rvw_configs`(期刊审稿配置)
一次性补齐四维 Prompt + Template
- `editorial_base_standard` (`zh` | `en`) default `en`
- `editorial_expert_prompt` (Text?)
- `editorial_handlebars_template` (Text?)
- `methodology_expert_prompt` (Text?)
- `methodology_handlebars_template` (Text?)
- `data_forensics_expert_prompt` (Text?)
- `data_forensics_handlebars_template` (Text?)
- `clinical_expert_prompt` (Text?)
- `clinical_handlebars_template` (Text?)
> 兼容说明:历史结构化字段(如 `editorialRules`/`dataForensicsLevel`/`finerWeights`如需保留先保留读兼容MVP 写入统一走 Prompt/Template 字段。
---
## 5. 数据库实施规范(必须执行)
严格按 `09-数据库开发规范.md` 三条铁律执行:
1. 所有变更通过 `npx prisma migrate dev --name xxx` 生成迁移。
2. 禁止 `prisma db push`(含任何变体)。
3. 每次迁移后,立即更新 `docs/05-部署文档/03-待部署变更清单.md`
推荐本次迁移命名:
- `20260315_add_journal_fields_to_tenants`
- `20260315_expand_tenant_rvw_configs_for_all_prompt_templates`
Shadow DB 失败时,按规范第 10 章降级流程处理并 `migrate resolve --applied` 标记。
---
## 6. 可执行任务清单
## 6.1 后端任务
### BE-1 Schema 与迁移
- 修改 `backend/prisma/schema.prisma`
- 生成并审查迁移 SQL
- 更新 Prisma Client
交付物:
- `backend/prisma/migrations/*/migration.sql`
- `backend/prisma/schema.prisma`
### BE-2 系统默认配置路由
- 在 Prompt 默认库中明确:
- `SYSTEM_DEFAULT_EDITORIAL_EN` 使用英文默认规则(来源于指定文档)
- `SYSTEM_DEFAULT_EDITORIAL_ZH` 使用中文默认规则
- 其余 3 个技能保留通用默认
### BE-3 管理端 API期刊配置中心
- 新增/扩展 API
- `GET /api/admin/journal-configs`
- `GET /api/admin/journal-configs/:tenantId`
- `PUT /api/admin/journal-configs/:tenantId/basic-info`
- `PUT /api/admin/journal-configs/:tenantId/rvw-config`
- 权限:`authenticate + requirePermission('ops:user-ops')`
### BE-4 RVW 执行链路接线
- 在 Worker/Skill 装配处接入期刊配置
-`??` 规则完成 4 维配置合并
- Editorial 增加 `zh/en` 基线路由
### BE-5 安全校验
- `slug` 正则校验(小写字母/数字/连字符)
- `journalLanguage``editorialBaseStandard` 枚举校验
- 日志记录配置来源system/custom
---
## 6.2 前端任务
### FE-1 新增左侧一级模块
- ADMIN 左侧导航新增 `期刊配置中心`
- 路由挂载与权限控制
### FE-2 列表页
- 期刊列表名称、slug、语言、状态、更新时间、操作
- 支持搜索/筛选/分页
### FE-3 详情配置页
- 基础信息区:
- 期刊全称
- 访问路径 slug
- 期刊语言(中文/英文/其他)
- Logo/主色/背景图字段先存储MVP 可不渲染)
- 审稿配置区4 Skills
- 继承/自定义切换
- Editorial 继承态支持 `zh/en` 基线选择
- 每个维度 Prompt + Handlebars Template 文本框
### FE-4 API 类型对齐
- 更新 TypeScript DTO 与后端一致
- `null` 语义统一为“继承默认”
---
## 6.3 联调任务
### INT-1 中英文默认稿约验证
- JTIM 配置为 `en` 继承,触发英文稿约规则
- CMJ 配置为 `zh` 继承,触发中文稿约规则
### INT-2 继承与覆盖验证
- AAA 继承默认
- BBB 在临床维度自定义 Prompt
- 对比两者结果差异应符合预期
### INT-3 兼容性验证
- 历史任务查询不受影响
- 现有租户登录/访问链路不回归
---
## 7. 验收标准DoD
1. ADMIN 出现独立一级菜单「期刊配置中心」。
2. 可在期刊维度配置并保存 `期刊语言`
3. 系统可明确区分中英文稿约默认规则。
4. 期刊支持“继承默认 + 局部覆盖”两种模式。
5. 数据库字段一次性补齐P0/P1/P2 + 期刊语言)。
6. 所有迁移已入库并追加到待部署变更清单。
---
## 8. 排期建议MVP
- Day 1: BE-1 + BE-2Schema/迁移/默认配置)
- Day 2: BE-3 + FE-1/FE-2管理端骨架
- Day 3: FE-3 + FE-4配置页面联通
- Day 4: BE-4 + 联调INT-1/2/3
- Day 5: 验收、文档补充、部署清单复核
---
## 9. 风险与规避
1. **风险:数据库 drift**
规避:禁止 db push迁移前后执行 status 与 SQL 审查。
2. **风险:字段多但 UI 尚未全部启用**
规避:字段先入库,前端按阶段开放;未启用字段保持可空。
3. **风险:中英文规则路由错误**
规避加运行时日志language/baseStandard/promptSource与双租户回归用例。
---
## 10. 审查报告对齐结论(含 TenantType 现状)
针对以下两份审查报告:
- `docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/期刊配置中心MVP计划审查报告.md`
- `docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/ADMIN多业态租户架构演进评估 (1).md`
本计划采用如下对齐结论:
### 10.1 认可并吸收的观点
1. **前端独立模块方向正确**:在 ADMIN 左侧建立独立一级菜单“期刊配置中心”是正确演进方向。
2. **后端数据层保持统一**:继续复用 `platform_schema.tenants`,不拆分新的“期刊主租户表”。
3. **配置中心必须可运营**Handlebars 模板能力应保留,避免后续“千刊千面”报告格式无法落地。
4. **跨表一致性要求**:涉及 tenants 与 tenant_rvw_configs 同步修改的场景,应使用事务保证原子性。
### 10.2 需要修正的审查结论(以代码现状为准)
两份报告中“系统当前没有 TenantType”的表述不准确。当前系统**已存在 TenantType**
- 后端 `backend/prisma/schema.prisma` 中已有 `enum TenantType``tenants.type`
- 当前枚举值:`HOSPITAL` / `PHARMA` / `INTERNAL` / `PUBLIC`
因此,本项目不需要“从 0 引入 TenantType 机制”,而是需要在现有机制上评估扩展:
- 方案 A推荐新增 `JOURNAL` 枚举值,用于期刊配置中心过滤与治理
- 方案 B兼容暂不新增枚举短期用 `INTERNAL` 或约定标签过渡(不推荐长期使用)
本计划采用:**方案 A新增 JOURNAL**。
---
## 11. 最低成本实施策略(先跑通,不做大重构)
### 11.1 决策原则
针对“当前 Prompt 是否硬编码、是否立刻大重构”的问题,本计划明确:
1. **不做大重构**(避免阻塞交付)
2. **必须做最小配置适配**(保证配置中心不是“假配置”)
3. **Handlebars 保留并最小启用**(保留未来扩展能力,当前成本可控)
### 11.2 最小配置适配(必须)
在现有 RVW 链路上增加一层“薄适配”即可,不推翻当前实现:
- 统一规则:`finalPrompt = tenantCustomPrompt ?? systemDefaultPrompt`
- Editorial 特殊规则:
- 租户未自定义时,按 `editorialBaseStandard` 选择 `zh/en` 默认 Prompt
- 若未显式设置 `editorialBaseStandard`,则按 `journalLanguage` 映射(`ZH -> zh`,其他 -> `en`
- 目标:让“期刊配置中心”的保存结果立即影响审稿行为
### 11.3 Handlebars 最小启用策略
当前阶段不做复杂重构,但必须保留模板层:
1. 字段保留:四维模板字段全部入库(已在第 4 章定义)
2. 运行时策略:模板为空时走系统默认模板
3. 失败兜底:模板渲染失败时降级返回原始报告内容,防止线上中断
4. MVP 可选项:测试渲染可先后置到 P1但预留接口与校验能力
### 11.4 为什么不建议“完全按写死先跑”
若只做 UI 不接运行时,配置中心会变成“可编辑但不生效”,风险更高:
- 运营以为已生效,实际审稿仍走旧规则
- 期刊 AAA/BBB 差异化配置无法验证
- 后续回补执行链路时会引入二次返工和信任成本
因此本计划采用“**小步接线,先真生效**”策略,而非“先写死跑通”。
---
## 12. 本计划执行前检查清单
- [ ] 已确认英文默认稿约规则文本(指定文档)
- [ ] 已确认中文默认稿约规则文本(对应文档)
- [ ] 已同步研发团队数据库铁律09 规范)
- [ ] 已确认 TenantType 扩展策略(新增 JOURNAL
- [ ] 已确认“先最小适配,不做大重构”的实施口径
- [ ] 已创建本次迁移命名并分配负责人
- [ ] 已约定联调样例租户(至少 JTIM + CMJ

View File

@@ -0,0 +1,117 @@
# **ADMIN 运营管理端:多业态租户架构演进评估与设计指南**
**文档定位:** 针对平台未来面临“医院、药企、期刊”三大业态分化的战略级架构指导文件。
**核心主旨:** 摒弃“上帝级”通用租户配置页,走向\*\*“数据底层大一统,前端展现分而治之”\*\*的 SaaS 演进路线。
**评估结论:** 强烈建议将“期刊配置中心”在前端作为独立一级菜单和页面独立出来。
## **🔍 一、 业务形态分化趋势评估**
平台目前服务的三类客户,其核心诉求与配置维度已经发生根本性分化:
| 业态类型 | 核心业务模块 | ADMIN 端的配置侧重点 | 运营人员视角 |
| :---- | :---- | :---- | :---- |
| **医院 / 科研机构** | IIT (研究项目), DC (数据清洗) | REDCap 数据源打通、CRA 质控规则、伦理审查合规性、患者隐私脱敏级别。 | 偏向于“项目制”与“数据安全”管理。 |
| **药企 / 申办方** | PKB (企业知识库), AIA (智能体) | 专属知识库的向量化参数、RAG 检索阈值、多智能体协作 Prompt、合规审查界限。 | 偏向于“知识资产”与“AI 能力”调优。 |
| **医学期刊编辑部** | RVW (智能审稿) | 稿约规范双语基线、方法学/临床评估 Prompt 编排、Handlebars 退修信模板定制。 | 偏向于“审稿流水线”与“千刊千面”排版。 |
**评估意见**:如果未来继续在同一个 TenantDetailPage.tsx 里通过 if-else 来堆砌这些表单,前端代码将迅速腐化,且运营人员在配置一家期刊时,可能会被医院的 REDCap 配置项干扰。**前端页面的物理拆分势在必行。**
## **🏛️ 二、 核心架构建议:底层统一,表层分治**
为了兼顾“开发成本”与“运营体验”,建议采用以下架构模式:
### **1\. 底层大一统 (Unified Data Model)**
**不能把租户表拆散**(不能建 journal\_tenants, hospital\_tenants
* **原因**:全平台的统一登录 (SSO)、统一计费 (Quotas)、基础信息 (Logo、名称) 必须保持全局唯一,复用现有的 platform\_schema.tenants。
* **改造动作**:在 tenants 表中新增一个核心的枚举分类字段 tenant\_type。
### **2\. 1对1 扩展表 (1-to-1 Extension Tables)**
针对不同业态的独特配置,采用垂直分表的模式,保持主表整洁。
* 期刊配置存入 tenant\_rvw\_configs
* 药企配置存入 tenant\_pkb\_configs (未来)
* 医院配置存入 tenant\_iit\_configs (未来)
### **3\. 前端分治 (Decoupled Frontend Views)**
在 ADMIN 端的左侧菜单,明确区分业务管理入口,为特定业态打造沉浸式的配置体验。
## **💾 三、 数据库 Schema 调整落地 (Prisma)**
基于上述思路,在 MVP 阶段的数据库改造应如下进行:
// 1\. 定义租户业态枚举
enum TenantType {
CLINICAL // 医院/科研机构 (默认)
PHARMA // 药企/申办方
JOURNAL // 医学期刊
INTERNAL // 内部平台测试
}
// 2\. 改造基础租户表
model Tenant {
id String @id @default(uuid())
code String @unique // slug
name String
tenantType TenantType @default(CLINICAL) // 新增:业态分类
journalLanguage String? // 'ZH' | 'EN' (仅当 type=JOURNAL 时有效)
// 关联扩展表
rvwConfig TenantRvwConfig?
// pkbConfig TenantPkbConfig? (未来扩展)
@@schema("platform\_schema")
}
// 3\. 期刊专属扩展表 (保持不变)
model TenantRvwConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: \[tenantId\], references: \[id\])
// ... Prompt 和 Template 字段
}
## **🖥️ 四、 前端展现独立化设计方案 (ADMIN UI)**
在之前的审查中,我曾建议“砍掉独立的期刊配置中心,回归租户详情页”。**在此,基于您的战略预判,我正式推翻该建议,支持您最初的构想:在前端建立独立的《期刊配置中心》。**
### **1\. 左侧导航重构**
ADMIN 端的导航栏应当按“业态垂直管理”和“平台横向管理”进行划分:
▼ 平台横向管理 (横向能力)
\- 🏢 基础租户池 (所有租户的大盘,仅管理基础信息和计费)
\- 👥 全局用户与权限
\- 🎨 Prompt 基础设施
▼ 垂直业态中心 (深度定制)
\- 🏥 临床研究中心配置 (过滤 tenantType=CLINICAL)
\- 💊 药企知识库配置 (过滤 tenantType=PHARMA)
\- 📖 期刊 SaaS 配置中心 (过滤 tenantType=JOURNAL)
### **2\. “期刊配置中心”的专属交互设计**
点击【期刊 SaaS 配置中心】后,前端交互应与普通租户管理区分开:
* **列表页 (JournalConfigList)**
* 后端 API 依然调用 GET /api/admin/tenants但前端强制带上参数 ?type=JOURNAL。
* 列表直接展示期刊专属的列:期刊名称、访问路径(Slug)、语言基线、AI 审查模块开关状态。
* **新建期刊向导 (Creation Wizard)**
* 点击“新增期刊”,不要弹出普通租户那种几十项配置的抽屉。
* 弹出专属向导:第一步填名称和 Slug第二步选“中文核心/英文 SCI”第三步自动为其绑定 RVW 模块权限,一气呵成。
* **沉浸式配置页 (JournalConfigDetail)**
* 左侧是“稿约、方法学、数据、临床” 4 个锚点导航,右侧是您要求的“大文本框 Prompt \+ Handlebars 预览”,完全为期刊实施人员打造,没有任何多余的干扰信息。
## **🚀 五、 给 MVP 开发计划的修正建议**
您可以直接把这份文档同步给开发团队并对之前的《MVP 开发计划》做如下调整指示:
1. **DB 调整**:必须在 tenants 表新增 tenant\_type (Enum) 字段,以此作为划分业态的唯一真理。
2. **前端放行****恢复执行 FE-1 (新增左侧一级模块) 和 FE-2 (独立的期刊列表页) 任务!** 3\. **接口复用**:前端虽然拆了页面,但后端**不需要**写两套 CRUD。列表查询依然用 /api/admin/tenants?type=JOURNAL配置更新依然用 /api/admin/tenants/:id/rvw-config。
**总结:** 您的这个决定避免了系统在未来半年内演变成一个庞大而混乱的“怪物”。通过**数据层的强内聚**(共享 Tenants 表和 SSO与**前端页面的强解耦**(独立业务配置中心),平台将具备极强的横向扩展能力!

View File

@@ -0,0 +1,76 @@
# **RVW V4.0 期刊配置中心 MVP 开发计划 \- 架构与工程审查报告 (V2.0 战略更新版)**
**审查目标:** 评估《RVW V4.0 期刊配置中心 MVP 开发计划 v1.0》的技术可行性、与平台多业态(临床/药企/期刊)演进架构的对齐度,以及潜在的生产环境风险。
**审查结论:** 计划整体逻辑清晰,**前端独立拆分模块FE-1, FE-2的战略方向极具前瞻性**。但在 **底层业态标识、数据库平滑迁移、防呆设计缺失** 等方面存在 5 个关键问题,需在进入开发前紧急修正。
## **🟢 核心架构亮点 (Architecture Gold Standard)**
### **1\. 优秀的前端解耦:独立的期刊配置中心**
* **计划现状**:任务 FE-1 和 FE-2 提出要“新增左侧一级模块(期刊配置中心)”和“新增列表页”。
* **审查意见****极度赞同!** 随着平台演进,医院、药企、期刊的配置诉求将天差地别。将期刊配置从拥挤的通用 TenantDetailPage 中剥离出来,建立独立的沉浸式工作台,是极其健康的 SaaS 演进路线。
* **⚠️ 后端防坑提醒**:虽然前端页面拆分了,但**后端绝对不能拆分新建独立的期刊租户表**。接口必须深度复用 GET /api/admin/tenants通过下文提到的 tenant\_type 参数进行动态过滤。
## **🔴 高风险项 (P0级 \- 影响系统稳定与架构基线)**
### **2\. 业态类型缺失:如何从底层区分“期刊”与“医院”?**
* **计划现状**:将所有配置加在了全局的 tenants 表关联中,但没有明确区分租户种类的字段。
* **冲突点**前端要实现独立的“期刊列表页FE-2后端数据库中现有的临床租户和期刊租户目前混在一起无法高效过滤。
* **修正建议**
在 platform\_schema.tenants 表中,**必须**新增一个业态分类枚举字段:
enum TenantType {
CLINICAL
PHARMA
JOURNAL
}
// 在 tenants 表中新增:
tenantType TenantType @default(CLINICAL)
只有 tenantType \=== 'JOURNAL' 的租户才会出现在期刊配置中心的列表中。
### **3\. 数据库迁移雷区:向已有表强加非空字段导致 Migrate 崩溃**
* **计划现状**:计划 4.2 A 节要求向已有的 platform\_schema.tenants 表新增 journal\_language 和 journal\_full\_name且要求非空。
* **冲突点**:线上数据库中 tenants 表已经存在历史数据主站租户、IIT租户等。如果直接执行 ALTER TABLE ADD COLUMN journal\_language TEXT NOT NULLPrisma 会因为历史记录缺少该字段的默认值而直接报错中断。
* **修正建议**
在 BE-1 的迁移策略中必须指明平滑过渡方案,建议二选一:
* **方案 A推荐**:在 Schema 中将该字段设为必填并给定默认值 @default("ZH"),或对于非期刊租户允许其具有特定默认值。
* **方案 B**:设为可选字段 String?在业务代码Controller/Zod层强制要求对于 tenantType \=== JOURNAL 的记录非空拦截。
## **🟡 中风险项 (P1级 \- 影响开发体验与业务闭环)**
### **4\. 致命防呆设计丢失Handlebars“测试渲染”预览功能被遗漏**
* **计划现状**:任务 FE-3 仅规划了“每个维度 Prompt \+ Handlebars Template 文本框”。
* **冲突点**:让运营人员在干巴巴的文本框里盲写 Handlebars 代码是灾难性的。一旦写错括号 {{ 导致线上渲染白屏,客诉极大。
* **修正建议**
必须在 FE-3 中加回 **“测试渲染 (Test Render)”** 按钮。要求前端复用 ADMIN 现有的 PromptEditorPage 中的 Mock 预览机制,在右侧提供实时 Markdown 预览面板。(此功能是 All-in-Prompt 架构的风控底线)。
### **5\. API 事务一致性风险:跨表更新未强调 Transaction**
* **计划现状**:任务 BE-3 设计了 PUT /.../basic-info 和 PUT /.../rvw-config。
* **冲突点**:在“保存并发布”时,如果前端提交了一个大表单,同时修改了主表 tenants期刊名称和关联表 tenant\_rvw\_configs方法学 Prompt。若分开调用接口或在 Service 层不使用事务,极易造成数据不一致。
* **修正建议**
在 BE-3 任务描述中增加硬性约束:**涉及跨 tenants 与 tenant\_rvw\_configs 表的同时写入操作,必须使用 Prisma $transaction 包裹,确保原子性。**
## **🟢 低风险项 (P2级 \- 细节补齐)**
### **6\. OTHER 语言的枚举映射与回退机制**
* **计划现状**:计划 3.2 提到“英文/其他 \-\> en”。
* **建议补充**:在后端 SkillExecutor 的合并逻辑中BE-4明确将这一点写进代码注释
// 当 journalLanguage 为 OTHER 时,强制 fallback 到 EN 基线,以通用 SCI 标准兜底
const langBase \= tenantConfig.editorialBaseStandard ??
(tenant.journalLanguage \=== 'ZH' ? 'zh' : 'en');
## **🚀 最终行动建议 (Next Steps)**
请开发团队在实施前对计划文档作如下微调:
1. **推进独立前端**:落实 FE-1 和 FE-2确保期刊业务拥有纯净的管理体验。
2. **底层数据扩充**:在 Prisma Schema 中补齐 tenant\_type 枚举,并为 journal\_language 设定 @default 兜底(或允许为空)以解决数据迁移报错风险。
3. **安全防线**:务必补上前端的 **“Handlebars 实时预览”** 按钮和后端的 **$transaction 事务一致性** 约束。
完成以上 3 点修正后,该 MVP 计划可立即进入研发阶段。

View File

@@ -19,6 +19,7 @@
| DB-1 | SSA Agent 执行记录新增分步执行与种子审计字段(`step_results/current_step/seed_audit` | `20260311_add_ssa_agent_step_seed_fields` | 高 | 按数据库规范生成Shadow DB 失败后采用降级流程产出 SQL并已人工收敛为仅本次字段变更 |
| DB-2 | RVW V4.0:新增 `platform_schema.tenant_rvw_configs`每期刊独立审稿配置含4维提示词+Handlebars模板 | `20260314_add_tenant_rvw_configs` | 高 | ⚠️ 部署前无需前置条件使用降级流程手动创建迁移SQL需执行 `prisma migrate resolve --applied 20260314_add_tenant_rvw_configs` 标记为已应用 |
| DB-3 | RVW V4.0`rvw_schema.review_tasks` 新增 `tenant_id` 字段 + 索引(历史数据平滑回填两步走) | `20260314_add_tenant_id_to_review_tasks` | 高 | ⚠️ **部署前必须先确认** `platform_schema.tenants` 中存在 `code='yanjiu'` 的主站默认租户迁移会自动将历史记录回填为该租户ID需执行 `prisma migrate resolve --applied 20260314_add_tenant_id_to_review_tasks` 标记为已应用 |
| DB-4 | RVW V4.0:期刊配置中心 MVP 一次性补齐字段(`tenants` 新增期刊字段;`tenant_rvw_configs` 升级为 4 维 Prompt+Template`TenantType` 新增 `JOURNAL` | `20260315_journal_config_center_mvp` | 高 | ⚠️ `prisma migrate dev` 因历史 shadow DB 迁移失败触发降级流程,已按规范收敛手工 SQL部署后执行 `prisma migrate resolve --applied 20260315_journal_config_center_mvp` |
### 后端变更 (Node.js)
@@ -30,7 +31,9 @@
| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 |
| BE-5 | RVW 审稿通道改造4 通道 Prompt 动静分离(业务提示词可编辑 + 系统协议固化)+ 方法学/稿约 JSON 结构化修复兜底 + DataForensics 默认切换为 LLM-only规则验证默认关闭 | `backend/src/modules/rvw/services/promptProtocols.ts`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/methodologyService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/modules/rvw/skills/library/DataForensicsSkill.ts`, `backend/src/modules/rvw/skills/core/types.ts`, `backend/src/common/document/ExtractionClient.ts`, `backend/src/common/prompt/prompt.fallbacks.ts` | 重新构建镜像 | 解决运营端改 Prompt 导致 JSON 解析失败;数据侦探默认仅“表格提取+LLM判断”规则代码保留可回切 |
| BE-6 | RVW V4.0 Phase 1Prisma Schema 新增 TenantRvwConfig 模型 + ReviewTask.tenantId 字段 + RVW租户中间件rvwTenantMiddlewareslug到UUID解析+tenant_members校验+缓存)+ FastifyRequest 扩展 tenantId/tenant 字段 | `backend/prisma/schema.prisma`, `backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts`, `backend/src/common/auth/auth.middleware.ts` | 重新构建镜像 + 执行 DB-2/DB-3 迁移 | 与 DB-2/DB-3 配套上线Prisma Client 已重新生成 |
|| BE-7 | RVW V4.0 Phase 2RVW Config CRUD APIGET/PUT `/api/admin/tenants/:id/rvw-config`+ Handlebars 渲染引擎Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
| BE-7 | RVW V4.0 Phase 2RVW Config CRUD APIGET/PUT `/api/admin/tenants/:id/rvw-config`+ Handlebars 渲染引擎Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
| BE-8 | RVW V4.0 Phase 3后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id整个多租户功能失效 |
| BE-9 | RVW V4.0 Phase 4期刊配置中心 MVP新增 `/api/admin/journal-configs` 专用 API租户模型扩展期刊字段RVW 执行链路接入最小配置适配(`tenantCustom ?? systemDefault`Editorial 支持 `zh/en` 基线路由;创建/更新为 `JOURNAL` 时自动兜底开通 `RVW` 模块 | `backend/src/modules/admin/journal-config/*`, `backend/src/modules/admin/services/tenantService.ts`, `backend/src/modules/admin/types/tenant.types.ts`, `backend/src/modules/admin/rvw-config/*`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/modules/rvw/skills/library/*`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/common/prompt/prompt.fallbacks.ts`, `backend/src/index.ts` | 重新构建镜像 + 执行 DB-4 迁移 | 与 FE-7 联动上线,满足「独立一级菜单 + 中英文基线 + 继承/覆盖」MVP 范围,并避免新期刊用户登录后无 RVW 模块权限 |
### 前端变更
@@ -39,10 +42,11 @@
| FE-1 | Agent 通道接入 step_* SSE 事件并展示分步执行状态(兼容旧 code_* 事件) | `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts`, `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/types/index.ts`, `frontend-v2/src/modules/ssa/stores/ssaStore.ts` | 重新构建镜像 | 右侧工作区可见每步状态/错误/耗时,便于排障 |
| FE-2 | Agent 计划阶段复用 QPER 变量编辑控件(单变量/多变量)并接入保存、确认前自动保存 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx`, `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx` | 重新构建镜像 | 对接 `PATCH /agent-executions/:executionId/plan-params`,实现 5A.5 前后端闭环 |
| FE-3 | Agent 工作区增强:在分步状态下可展开查看每步已生成结果(`reportBlocks`),并兼容严格分步模式下的 `code_pending` 空代码预览 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` | 重新构建镜像 | 修复“有结果但代码下方不可见”与状态显示误导问题 |
|| FE-4 | RVW V4.0 Phase 2TenantDetailPage 新增「智能审稿配置」Tab4 Panel稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
|| BE-8 | RVW V4.0 Phase 3后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id整个多租户功能失效 |
|| FE-5 | RVW V4.0 Phase 3新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/t/:tenant/login -> /:tenant/rvw` 主流程 |
| FE-4 | RVW V4.0 Phase 2TenantDetailPage 新增「智能审稿配置」Tab4 Panel稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
| FE-5 | RVW V4.0 Phase 3新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/:tenant/login -> /:tenant/rvw` 主流程 |
| FE-7 | RVW V4.0 期刊配置中心 MVPADMIN 新增独立一级菜单“期刊配置中心”,新增列表页/详情页(基础信息 + 4 维审稿配置),并对齐新 API DTO`journalLanguage``editorialBaseStandard`、Prompt/Handlebars 字段) | `frontend-v2/src/framework/layout/AdminLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/admin/journal-configs/*`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts`, `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx` | 重新构建镜像 | 与 BE-9/DB-4 配套上线MVP 阶段先跑通配置闭环,品牌视觉字段先存储后逐步渲染 |
| FE-8 | 登录路径策略统一为单一路径:仅保留 `/:tenantCode/login`,未登录重定向与退出登录统一 `/:tenant/login`,开发/生产同路径仅域名不同 | `frontend-v2/src/App.tsx`, `frontend-v2/src/framework/router/RouteGuard.tsx`, `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/pages/TenantLoginPage.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 对齐生产目标链接 `review.xunzhengyixue.com/test-qikan01`;不再保留 `/t/:tenantCode/login` 路径,需同步更新测试文档/书签 |
### Python 微服务变更
@@ -66,7 +70,8 @@
| # | 变更内容 | 范围 | 备注 |
|---|---------|------|------|
| — | *暂无* | | |
| INF-1 | 前端 Nginx 增加 SPA 深链回退(`try_files $uri /index.html`),并确保 `/api/` 路由优先反代后端(不能被 index.html 吞掉) | frontend-nginx-service / ingress | ⚠️ 否则直接访问 `review.xunzhengyixue.com/test-qikan01``.../test-qikan01/login` 会 404部署后需在无缓存浏览器验证 |
| INF-2 | 前端 Docker 构建与发布注意:确认镜像内 Nginx 配置已包含深链规则、并清理旧静态资源缓存CDN/浏览器)后再灰度 | frontend-v2 build/deploy pipeline | 建议发布后先执行硬刷新Ctrl+F5与隐身窗口验证避免旧 bundle 缓存导致仍跳旧路径 |
---

View File

@@ -39,6 +39,8 @@ import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
import IitMemberManagePage from './modules/admin/pages/IitMemberManagePage'
// 运营日志
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
import JournalConfigListPage from './pages/admin/journal-configs/JournalConfigListPage'
import JournalConfigDetailPage from './pages/admin/journal-configs/JournalConfigDetailPage'
// 个人中心页面
import ProfilePage from './pages/user/ProfilePage'
@@ -55,7 +57,7 @@ import ProfilePage from './pages/user/ProfilePage'
*
* 路由结构:
* - /login - 通用登录页(个人用户)
* - /t/{tenantCode}/login - 租户专属登录页
* - /{tenantCode}/login - 租户专属登录页
* - / - 首页(需要认证)
* - /{module}/* - 业务模块(需要认证+权限)
*/
@@ -113,8 +115,8 @@ function App() {
<Routes>
{/* 登录页面(无需认证) */}
<Route path="/login" element={<LoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout*/}
<Route path="/t/:tenantCode/login" element={<TenantLoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout */}
<Route path="/:tenantCode/login" element={<TenantLoginPage />} />
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
@@ -168,6 +170,9 @@ function App() {
<Route path="iit-members" element={<IitMemberManagePage />} />
{/* 运营日志 */}
<Route path="activity-logs" element={<ActivityLogsPage />} />
{/* 期刊配置中心 */}
<Route path="journal-configs" element={<JournalConfigListPage />} />
<Route path="journal-configs/:id" element={<JournalConfigDetailPage />} />
{/* 系统配置 */}
<Route path="system" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>

View File

@@ -101,6 +101,7 @@ const AdminLayout = () => {
type: 'group' as const,
label: '商务运营',
children: [
{ key: '/admin/journal-configs', icon: <BookOutlined />, label: '期刊配置中心' },
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/activity-logs', icon: <FileTextOutlined />, label: '运营日志' },

View File

@@ -63,7 +63,7 @@ export default function TenantPortalLayout() {
const handleLogout = async () => {
await logout();
window.location.href = `/t/${tenantSlug}/login`;
window.location.href = `/${tenantSlug}/login`;
};
return (

View File

@@ -14,7 +14,7 @@ import { LockOutlined } from '@ant-design/icons'
* 3. 有权限→渲染子组件
*
* @version 2026-03-14 V4.0:租户感知重定向
* - 期刊租户路径(/jtim/*)→ /t/jtim/login?redirect=/jtim/dashboard
* - 期刊租户路径(/jtim/*)→ /jtim/login?redirect=/jtim/dashboard
* - 主平台路径(/rvw/*, /ai-qa/* 等)→ /login行为不变
* - extractTenantSlug 由 useTenantObserver 模块统一维护,自动排除所有注册模块路径
*/
@@ -22,16 +22,16 @@ import { LockOutlined } from '@ant-design/icons'
/**
* 构造未登录时的登录跳转目标。
*
* 对齐 App.tsx 中已定义的路由:<Route path="/t/:tenantCode/login" />
* 对齐 App.tsx 中已定义的路由:<Route path="/:tenantCode/login" />
*
* - 期刊租户下:/t/jtim/login?redirect=%2Fjtim%2Fdashboard
* - 期刊租户下:/jtim/login?redirect=%2Fjtim%2Fdashboard
* - 主站下:/login保持原有行为向后兼容
*/
function buildLoginRedirect(pathname: string): string {
const tenantSlug = extractTenantSlug(pathname)
if (!tenantSlug) return '/login'
const redirectParam = encodeURIComponent(pathname)
return `/t/${tenantSlug}/login?redirect=${redirectParam}`
return `/${tenantSlug}/login?redirect=${redirectParam}`
}
interface RouteGuardProps {

View File

@@ -7,7 +7,7 @@
*
* 路由:
* - /login - 通用登录(个人用户)
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
* - /{tenantCode}/login - 租户专属登录(机构用户)
*/
import { useState, useEffect, useCallback } from 'react';
@@ -106,7 +106,7 @@ export default function LoginPage() {
const userRole = user?.role;
const userModules = user?.modules || [];
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /t/jtim/login?redirect=%2Fjtim%2Frvw
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /jtim/login?redirect=%2Fjtim%2Frvw
const searchParams = new URLSearchParams(location.search);
const redirectParam = searchParams.get('redirect');
if (redirectParam) {
@@ -132,7 +132,7 @@ export default function LoginPage() {
return from;
}
// 3. 期刊租户专属登录页(/t/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
// 3. 期刊租户专属登录页(/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
if (tenantCode) {
return `/${tenantCode}/rvw`;
}

View File

@@ -3,7 +3,7 @@
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
* ─ 设计 100% 还原 AI审稿V1.html 原型图
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
* ─ 路由: /t/:tenantCode/login
* ─ 路由: /:tenantCode/login
*/
import { useState, useEffect, useCallback } from 'react';

View File

@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Button, Card, Divider, Form, Input, Select, Space, Spin, Tabs, message } from 'antd';
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
import type { JournalLanguage, UpdateRvwConfigRequest } from '../tenants/api/tenantApi';
import {
fetchJournalDetail,
saveJournalBasicInfo,
saveJournalRvwConfig,
} from './api/journalConfigApi';
const { TextArea } = Input;
const LANGUAGE_OPTIONS: Array<{ value: JournalLanguage; label: string }> = [
{ value: 'ZH', label: '中文' },
{ value: 'EN', label: '英文' },
{ value: 'OTHER', label: '其他' },
];
export default function JournalConfigDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('basic');
const [basicForm] = Form.useForm();
const [rvwForm] = Form.useForm();
const load = async () => {
if (!id) return;
setLoading(true);
try {
const detail = await fetchJournalDetail(id);
basicForm.setFieldsValue({
name: detail.tenant.name,
journalFullName: detail.tenant.journalFullName || detail.tenant.name,
code: detail.tenant.code,
journalLanguage: detail.tenant.journalLanguage || 'ZH',
logoUrl: detail.tenant.logoUrl || '',
brandColor: detail.tenant.brandColor || '',
loginBackgroundUrl: detail.tenant.loginBackgroundUrl || '',
});
rvwForm.setFieldsValue({
editorialBaseStandard: detail.rvwConfig?.editorialBaseStandard || 'en',
editorialExpertPrompt: detail.rvwConfig?.editorialExpertPrompt || '',
editorialHandlebarsTemplate: detail.rvwConfig?.editorialHandlebarsTemplate || '',
methodologyExpertPrompt: detail.rvwConfig?.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: detail.rvwConfig?.methodologyHandlebarsTemplate || '',
dataForensicsExpertPrompt: detail.rvwConfig?.dataForensicsExpertPrompt || '',
dataForensicsHandlebarsTemplate: detail.rvwConfig?.dataForensicsHandlebarsTemplate || '',
clinicalExpertPrompt: detail.rvwConfig?.clinicalExpertPrompt || '',
clinicalHandlebarsTemplate: detail.rvwConfig?.clinicalHandlebarsTemplate || '',
});
} catch (e: any) {
message.error(e.message || '加载期刊配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [id]);
const handleSaveBasic = async () => {
if (!id) return;
try {
const values = await basicForm.validateFields();
setSaving(true);
await saveJournalBasicInfo(id, values);
message.success('基础信息已保存');
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleSaveRvw = async () => {
if (!id) return;
try {
const values = await rvwForm.validateFields();
const payload: UpdateRvwConfigRequest = {
editorialBaseStandard: values.editorialBaseStandard,
editorialExpertPrompt: values.editorialExpertPrompt || null,
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
};
setSaving(true);
await saveJournalRvwConfig(id, payload);
message.success('审稿配置已保存');
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e.message || '保存失败');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/journal-configs')} style={{ marginBottom: 16 }}>
</Button>
<Card title="期刊配置中心">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'basic',
label: '基础信息与门户',
children: (
<Form form={basicForm} layout="vertical" style={{ maxWidth: 760 }}>
<Form.Item name="name" label="租户名称" rules={[{ required: true, message: '请输入租户名称' }]}>
<Input />
</Form.Item>
<Form.Item name="journalFullName" label="期刊全称" rules={[{ required: true, message: '请输入期刊全称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="访问路径 Slug">
<Input disabled />
</Form.Item>
<Form.Item name="journalLanguage" label="期刊语言" rules={[{ required: true, message: '请选择期刊语言' }]}>
<Select>
{LANGUAGE_OPTIONS.map(item => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="logoUrl" label="期刊 Logo URLP1 预留)">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="brandColor" label="品牌主色P1 预留)">
<Input placeholder="#0284c7" />
</Form.Item>
<Form.Item name="loginBackgroundUrl" label="登录背景图 URLP2 预留)">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveBasic}>
</Button>
</Form.Item>
</Form>
),
},
{
key: 'rvw',
label: '智能审稿配置',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="留空即继承系统默认配置;填写后即覆盖该期刊默认行为。"
/>
<Form form={rvwForm} layout="vertical">
<Divider>A. 稿线</Divider>
<Form.Item name="editorialBaseStandard" label="继承基线">
<Select style={{ width: 280 }}>
<Select.Option value="en">EN</Select.Option>
<Select.Option value="zh">ZH</Select.Option>
</Select>
</Form.Item>
<Form.Item name="editorialExpertPrompt" label="Editorial Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="editorialHandlebarsTemplate" label="Editorial Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>B. </Divider>
<Form.Item name="methodologyExpertPrompt" label="Methodology Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="methodologyHandlebarsTemplate" label="Methodology Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>C. </Divider>
<Form.Item name="dataForensicsExpertPrompt" label="Data Forensics Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="dataForensicsHandlebarsTemplate" label="Data Forensics Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Divider>D. </Divider>
<Form.Item name="clinicalExpertPrompt" label="Clinical Prompt留空继承默认">
<TextArea rows={6} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="clinicalHandlebarsTemplate" label="Clinical Handlebars 模板(留空继承默认)">
<TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSaveRvw}>
稿
</Button>
</Space>
</Form.Item>
</Form>
</>
),
},
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Table, Tag, Input, Select, Button, Space, message } from 'antd';
import { SearchOutlined, SettingOutlined, BookOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { fetchJournalList, type JournalItem } from './api/journalConfigApi';
import type { TenantStatus } from '../tenants/api/tenantApi';
const { Search } = Input;
const { Option } = Select;
const STATUS_LABELS: Record<TenantStatus, { label: string; color: string }> = {
ACTIVE: { label: '运营中', color: 'success' },
SUSPENDED: { label: '已停用', color: 'error' },
EXPIRED: { label: '已过期', color: 'warning' },
};
const LANGUAGE_LABELS: Record<string, string> = {
ZH: '中文',
EN: '英文',
OTHER: '其他',
};
export default function JournalConfigListPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<JournalItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [status, setStatus] = useState<TenantStatus | ''>('');
const load = async () => {
setLoading(true);
try {
const result = await fetchJournalList({
page,
limit: 20,
search: search || undefined,
status: status || undefined,
});
setList(result.data);
setTotal(result.total);
} catch (e: any) {
message.error(e.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [page, status]);
const columns: ColumnsType<JournalItem> = [
{
title: '期刊名称',
dataIndex: 'journalFullName',
key: 'journalFullName',
render: (_, r) => r.journalFullName || r.name,
},
{
title: 'Slug',
dataIndex: 'code',
key: 'code',
width: 140,
render: (v: string) => <span style={{ fontFamily: 'monospace' }}>{v}</span>,
},
{
title: '语言',
dataIndex: 'journalLanguage',
key: 'journalLanguage',
width: 90,
render: (v: string | null | undefined) => <Tag>{LANGUAGE_LABELS[v || 'ZH'] || '中文'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: TenantStatus) => <Tag color={STATUS_LABELS[v].color}>{STATUS_LABELS[v].label}</Tag>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 140,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<Button
type="link"
icon={<SettingOutlined />}
onClick={() => navigate(`/admin/journal-configs/${record.id}`)}
>
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<BookOutlined />
<span></span>
</Space>
}
>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Search
placeholder="搜索期刊名称/Slug"
style={{ width: 280 }}
prefix={<SearchOutlined />}
allowClear
onSearch={(v) => {
setSearch(v);
setPage(1);
setTimeout(load, 0);
}}
/>
<Select
placeholder="状态"
allowClear
style={{ width: 140 }}
value={status || undefined}
onChange={(v) => {
setStatus(v || '');
setPage(1);
}}
>
{Object.entries(STATUS_LABELS).map(([k, cfg]) => (
<Option key={k} value={k}>
{cfg.label}
</Option>
))}
</Select>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
pagination={{
current: page,
pageSize: 20,
total,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 本期刊`,
}}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { getAccessToken } from '../../../../framework/auth/api';
import type { JournalLanguage, TenantRvwConfig, TenantStatus, TenantType, UpdateRvwConfigRequest } from '../../tenants/api/tenantApi';
const API_BASE = '/api/admin/journal-configs';
function getAuthHeaders(): HeadersInit {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
export interface JournalItem {
id: string;
code: string;
name: string;
type: TenantType;
status: TenantStatus;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
updatedAt: string;
createdAt: string;
}
export interface JournalListResponse {
success: boolean;
data: JournalItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface JournalBasicInfoRequest {
name?: string;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
expiresAt?: string | null;
}
export interface JournalDetailResponse {
tenant: JournalItem & {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
expiresAt?: string | null;
};
rvwConfig: TenantRvwConfig | null;
}
export async function fetchJournalList(params?: {
status?: TenantStatus;
search?: string;
page?: number;
limit?: number;
}): Promise<JournalListResponse> {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const response = await fetch(`${API_BASE}?${searchParams.toString()}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取期刊列表失败');
}
return response.json();
}
export async function fetchJournalDetail(id: string): Promise<JournalDetailResponse> {
const response = await fetch(`${API_BASE}/${id}`, { headers: getAuthHeaders() });
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取期刊详情失败');
}
const result = await response.json();
return result.data;
}
export async function saveJournalBasicInfo(id: string, payload: JournalBasicInfoRequest) {
const response = await fetch(`${API_BASE}/${id}/basic-info`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存基础信息失败');
}
return (await response.json()).data;
}
export async function saveJournalRvwConfig(id: string, payload: UpdateRvwConfigRequest) {
const response = await fetch(`${API_BASE}/${id}/rvw-config`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存审稿配置失败');
}
return (await response.json()).data as TenantRvwConfig;
}

View File

@@ -18,19 +18,17 @@ import {
Table,
message,
Spin,
InputNumber,
Divider,
Alert,
Typography,
} from 'antd';
const { TextArea } = Input;
const { Text } = Typography;
import {
ArrowLeftOutlined,
EditOutlined,
SaveOutlined,
BankOutlined,
BookOutlined,
MedicineBoxOutlined,
HomeOutlined,
UserOutlined,
@@ -64,6 +62,7 @@ const PRIMARY_COLOR = '#10b981';
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
};
@@ -140,15 +139,15 @@ const TenantDetailPage = () => {
setRvwConfig(data);
if (data) {
rvwForm.setFieldsValue({
editorialBaseStandard: data.editorialBaseStandard || 'en',
editorialExpertPrompt: data.editorialExpertPrompt || '',
editorialHandlebarsTemplate: data.editorialHandlebarsTemplate || '',
methodologyExpertPrompt: data.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: data.methodologyHandlebarsTemplate || '',
dataForensicsLevel: data.dataForensicsLevel || 'L2',
dataForensicsExpertPrompt: data.dataForensicsExpertPrompt || '',
dataForensicsHandlebarsTemplate: data.dataForensicsHandlebarsTemplate || '',
clinicalExpertPrompt: data.clinicalExpertPrompt || '',
finer_feasibility: data.finerWeights?.feasibility ?? 20,
finer_innovation: data.finerWeights?.innovation ?? 20,
finer_ethics: data.finerWeights?.ethics ?? 20,
finer_relevance: data.finerWeights?.relevance ?? 20,
finer_novelty: data.finerWeights?.novelty ?? 20,
clinicalHandlebarsTemplate: data.clinicalHandlebarsTemplate || '',
});
}
} catch (err: any) {
@@ -165,25 +164,16 @@ const TenantDetailPage = () => {
const values = await rvwForm.validateFields();
setRvwConfigSaving(true);
const finerWeights = {
feasibility: values.finer_feasibility,
innovation: values.finer_innovation,
ethics: values.finer_ethics,
relevance: values.finer_relevance,
novelty: values.finer_novelty,
};
const total = Object.values(finerWeights).reduce((a, b) => a + Number(b), 0);
if (Math.abs(total - 100) > 1) {
message.error(`FINER 权重之和应为 100当前为 ${total}`);
return;
}
const payload: UpdateRvwConfigRequest = {
editorialBaseStandard: values.editorialBaseStandard || 'en',
editorialExpertPrompt: values.editorialExpertPrompt || null,
editorialHandlebarsTemplate: values.editorialHandlebarsTemplate || null,
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsLevel: values.dataForensicsLevel,
dataForensicsExpertPrompt: values.dataForensicsExpertPrompt || null,
dataForensicsHandlebarsTemplate: values.dataForensicsHandlebarsTemplate || null,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
finerWeights,
clinicalHandlebarsTemplate: values.clinicalHandlebarsTemplate || null,
};
const saved = await saveRvwConfig(id, payload);
@@ -527,16 +517,40 @@ const TenantDetailPage = () => {
/>
<Form form={rvwForm} layout="vertical">
{/* Panel A稿约规范 */}
<Divider orientation="left">A. 稿</Divider>
<Alert
type="warning"
showIcon
message="稿约规则编辑器将在 P1 版本提供可视化配置界面,当前暂不支持自定义。"
style={{ marginBottom: 16 }}
/>
<Divider>A. 稿</Divider>
<Form.Item
name="editorialBaseStandard"
label="继承模式的基线标准"
extra="当未填写稿约自定义 Prompt 时,系统按此基线选择默认规则。"
>
<Select style={{ width: 280 }}>
<Select.Option value="en">EN</Select.Option>
<Select.Option value="zh">ZH</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="editorialExpertPrompt"
label="稿约规范评估 Prompt留空继承系统默认"
>
<TextArea
rows={8}
placeholder="自定义该租户的稿约规则描述..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="editorialHandlebarsTemplate"
label="稿约报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 稿约规范报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel B方法学评估 */}
<Divider orientation="left">B. </Divider>
<Divider>B. </Divider>
<Form.Item
name="methodologyExpertPrompt"
label="方法学专家评判标准(业务 Prompt"
@@ -561,21 +575,30 @@ const TenantDetailPage = () => {
</Form.Item>
{/* Panel C数据验证 */}
<Divider orientation="left">C. </Divider>
<Divider>C. </Divider>
<Form.Item
name="dataForensicsLevel"
label="数据验证深度"
rules={[{ required: true }]}
name="dataForensicsExpertPrompt"
label="数据验证评估 Prompt留空继承系统默认"
>
<Select style={{ width: 240 }}>
<Select.Option value="L1">L1 + </Select.Option>
<Select.Option value="L2">L2 </Select.Option>
<Select.Option value="L3">L3 </Select.Option>
</Select>
<TextArea
rows={8}
placeholder="自定义该租户的数据验证评估要求..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="dataForensicsHandlebarsTemplate"
label="数据验证报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 数据验证报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel D临床专业评估 */}
<Divider orientation="left">D. </Divider>
<Divider>D. </Divider>
<Form.Item
name="clinicalExpertPrompt"
label="临床首席科学家评判标准(业务 Prompt"
@@ -588,25 +611,15 @@ const TenantDetailPage = () => {
/>
</Form.Item>
<Form.Item label="FINER 五维权重(总计应为 100">
<Space size="large" wrap>
{[
{ key: 'finer_feasibility', label: '可行性 F' },
{ key: 'finer_innovation', label: '创新性 I' },
{ key: 'finer_ethics', label: '伦理性 E' },
{ key: 'finer_relevance', label: '相关性 R' },
{ key: 'finer_novelty', label: '新颖性 N' },
].map(({ key, label }) => (
<Form.Item key={key} name={key} label={label} style={{ marginBottom: 0 }}>
<InputNumber min={0} max={100} step={5} addonAfter="%" />
</Form.Item>
))}
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
100 20
</Text>
</div>
<Form.Item
name="clinicalHandlebarsTemplate"
label="临床评估报告模板Handlebars留空使用系统默认"
>
<TextArea
rows={6}
placeholder="# 临床评估报告..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
<Form.Item>

View File

@@ -25,6 +25,7 @@ import {
CheckCircleOutlined,
DeleteOutlined,
BankOutlined,
BookOutlined,
MedicineBoxOutlined,
HomeOutlined,
UserOutlined,
@@ -49,6 +50,7 @@ const PRIMARY_COLOR = '#10b981';
const TENANT_TYPES: Record<TenantType, { label: string; icon: React.ReactNode; color: string }> = {
HOSPITAL: { label: '医院', icon: <BankOutlined />, color: 'blue' },
PHARMA: { label: '药企', icon: <MedicineBoxOutlined />, color: 'purple' },
JOURNAL: { label: '期刊', icon: <BookOutlined />, color: 'geekblue' },
INTERNAL: { label: '内部', icon: <HomeOutlined />, color: 'cyan' },
PUBLIC: { label: '公共', icon: <UserOutlined />, color: 'green' },
};

View File

@@ -20,14 +20,20 @@ function getAuthHeaders(): HeadersInit {
// ==================== 类型定义 ====================
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';
export interface TenantInfo {
id: string;
code: string;
name: string;
type: TenantType;
journalLanguage?: JournalLanguage | null;
journalFullName?: string | null;
logoUrl?: string | null;
brandColor?: string | null;
loginBackgroundUrl?: string | null;
status: TenantStatus;
contactName?: string | null;
contactPhone?: string | null;
@@ -60,6 +66,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;
@@ -69,6 +80,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;
@@ -250,24 +267,30 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
export interface TenantRvwConfig {
id: string;
tenantId: string;
editorialRules: unknown | null;
editorialBaseStandard: 'zh' | 'en';
editorialExpertPrompt: string | null;
editorialHandlebarsTemplate: string | null;
methodologyExpertPrompt: string | null;
methodologyHandlebarsTemplate: string | null;
dataForensicsLevel: string;
finerWeights: Record<string, number> | null;
dataForensicsExpertPrompt: string | null;
dataForensicsHandlebarsTemplate: string | null;
clinicalExpertPrompt: string | null;
clinicalHandlebarsTemplate: string | null;
createdAt: string;
updatedAt: string;
}
/** 更新审稿配置请求 */
export interface UpdateRvwConfigRequest {
editorialRules?: unknown | null;
editorialBaseStandard?: 'zh' | 'en';
editorialExpertPrompt?: string | null;
editorialHandlebarsTemplate?: string | null;
methodologyExpertPrompt?: string | null;
methodologyHandlebarsTemplate?: string | null;
dataForensicsLevel?: string;
finerWeights?: Record<string, number> | null;
dataForensicsExpertPrompt?: string | null;
dataForensicsHandlebarsTemplate?: string | null;
clinicalExpertPrompt?: string | null;
clinicalHandlebarsTemplate?: string | null;
}
/**