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

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