feat(rvw): deliver tenant portal v4 flow and config foundation

Implement RVW V4.0 tenant-aware backend/frontend flow with tenant routing, config APIs, and full portal UX updates. Sync system/RVW/deployment docs to capture verified upload-review-report workflow and next-step admin configuration work.

Made-with: Cursor
This commit is contained in:
2026-03-14 22:29:40 +08:00
parent ba464082cb
commit 16179e16ca
45 changed files with 4753 additions and 93 deletions

View File

@@ -22,6 +22,15 @@ import { prisma } from '../../config/database.js';
declare module 'fastify' {
interface FastifyRequest {
user?: DecodedToken;
/** RVW V4.0:由 rvwTenantMiddleware 解析后挂载的租户 UUID */
tenantId?: string;
/** RVW V4.0:由 rvwTenantMiddleware 解析后挂载的完整租户对象 */
tenant?: {
id: string;
code: string;
name: string;
status: string;
};
}
}

View File

@@ -60,7 +60,7 @@ await fastify.register(cors, {
origin: true, // 开发环境允许所有来源
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'], // 明确允许的HTTP方法
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin'], // 允许的请求头
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'x-tenant-id'], // 允许的请求头(含 RVW V4.0 多租户 Header
exposedHeaders: ['Content-Range', 'X-Content-Range'], // 暴露的响应头
maxAge: 600, // preflight请求缓存时间
preflightContinue: false, // Fastify处理preflight请求
@@ -111,8 +111,10 @@ import { userRoutes } from './modules/admin/routes/userRoutes.js';
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
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 { 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(moduleRoutes, { prefix: '/api/admin/modules' });
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });

View File

@@ -0,0 +1,108 @@
/**
* RVW V4.0 租户审稿配置 Controller
*
* 提供运营管理端的审稿配置 CRUD 接口。
* 所有接口需要 ops:user-ops 权限(仅内部运营人员可访问)。
*
* @module admin/rvw-config/rvwConfigController
*/
import type { FastifyRequest, FastifyReply } from 'fastify';
import * as rvwConfigService from './rvwConfigService.js';
import { logger } from '../../../common/logging/index.js';
interface TenantIdParams {
id: string; // tenants.id (UUID)
}
/**
* GET /api/admin/tenants/:id/rvw-config
* 获取租户审稿配置
*/
export async function getRvwConfig(
request: FastifyRequest<{ Params: TenantIdParams }>,
reply: FastifyReply
) {
try {
const { id: tenantId } = request.params;
const config = await rvwConfigService.getRvwConfig(tenantId);
if (!config) {
return reply.status(200).send({
data: null,
message: '该租户尚未配置审稿参数,将使用系统默认值',
});
}
return reply.status(200).send({ data: config });
} catch (error) {
logger.error('[RvwConfig] 获取审稿配置失败', { error });
return reply.status(500).send({
error: 'InternalServerError',
message: '获取审稿配置失败',
});
}
}
/**
* PUT /api/admin/tenants/:id/rvw-config
* 创建或更新UPSERT租户审稿配置
*
* Body 示例:
* {
* "methodologyExpertPrompt": "...",
* "dataForensicsLevel": "L2",
* "finerWeights": { "feasibility": 20, "innovation": 20, "ethics": 20, "relevance": 20, "novelty": 20 }
* }
*/
export async function upsertRvwConfig(
request: FastifyRequest<{
Params: TenantIdParams;
Body: rvwConfigService.UpdateRvwConfigDto;
}>,
reply: FastifyReply
) {
try {
const { id: tenantId } = request.params;
const dto = request.body;
// 验证 dataForensicsLevel若提供
if (dto.dataForensicsLevel && !['L1', 'L2', 'L3'].includes(dto.dataForensicsLevel)) {
return reply.status(400).send({
error: 'BadRequest',
message: 'dataForensicsLevel 必须为 L1 / L2 / L3',
});
}
// 验证 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({
data: config,
message: '审稿配置已保存',
});
} catch (error) {
if (error instanceof Error && error.message.includes('不存在')) {
return reply.status(404).send({
error: 'NotFound',
message: error.message,
});
}
logger.error('[RvwConfig] 保存审稿配置失败', { error });
return reply.status(500).send({
error: 'InternalServerError',
message: '保存审稿配置失败',
});
}
}

View File

@@ -0,0 +1,31 @@
/**
* RVW V4.0 租户审稿配置路由
*
* 挂载在 /api/admin/tenants/:id 前缀下:
* - GET /api/admin/tenants/:id/rvw-config
* - PUT /api/admin/tenants/:id/rvw-config
*
* 权限ops:user-ops仅内部运营人员
*
* @module admin/rvw-config/rvwConfigRoutes
*/
import type { FastifyInstance } from 'fastify';
import { authenticate, requireAnyPermission } from '../../../common/auth/auth.middleware.js';
import * as rvwConfigController from './rvwConfigController.js';
export async function rvwConfigRoutes(fastify: FastifyInstance) {
// 获取租户审稿配置
// GET /api/admin/tenants/:id/rvw-config
fastify.get('/:id/rvw-config', {
preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')],
handler: rvwConfigController.getRvwConfig,
});
// 创建/更新租户审稿配置UPSERT
// PUT /api/admin/tenants/:id/rvw-config
fastify.put('/:id/rvw-config', {
preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')],
handler: rvwConfigController.upsertRvwConfig,
});
}

View File

@@ -0,0 +1,102 @@
/**
* RVW V4.0 租户审稿配置服务
*
* 提供 tenant_rvw_configs 表的 CRUD 操作。
* 每个期刊租户对应一条配置记录1:1使用 UPSERT 保证幂等。
*
* @module admin/rvw-config/rvwConfigService
*/
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;
methodologyExpertPrompt: string | null;
methodologyHandlebarsTemplate: string | null;
dataForensicsLevel: string;
finerWeights: Prisma.JsonValue | null;
clinicalExpertPrompt: string | null;
createdAt: Date;
updatedAt: Date;
}
/** 审稿配置更新 DTO所有字段可选UPSERT 语义) */
export interface UpdateRvwConfigDto {
editorialRules?: Prisma.InputJsonValue | null;
methodologyExpertPrompt?: string | null;
methodologyHandlebarsTemplate?: string | null;
dataForensicsLevel?: string;
finerWeights?: Prisma.InputJsonValue | null;
clinicalExpertPrompt?: string | null;
}
/**
* 获取租户审稿配置
*
* @param tenantId - platform_schema.tenants.id
* @returns 配置对象,若未配置则返回 null
*/
export async function getRvwConfig(tenantId: string): Promise<RvwConfigDto | null> {
const config = await prisma.tenantRvwConfig.findUnique({
where: { tenantId },
});
return config;
}
/**
* 创建或更新UPSERT租户审稿配置
*
* @param tenantId - platform_schema.tenants.id
* @param dto - 要更新的字段(未提供的字段保持不变)
* @returns 更新后的配置对象
*/
export async function upsertRvwConfig(
tenantId: string,
dto: UpdateRvwConfigDto
): Promise<RvwConfigDto> {
// 确认租户存在
const tenant = await prisma.tenants.findUnique({
where: { id: tenantId },
select: { id: true, name: true },
});
if (!tenant) {
throw new Error(`租户 ${tenantId} 不存在`);
}
const now = new Date();
const config = await prisma.tenantRvwConfig.upsert({
where: { tenantId },
create: {
tenantId,
editorialRules: dto.editorialRules ?? undefined,
methodologyExpertPrompt: dto.methodologyExpertPrompt ?? null,
methodologyHandlebarsTemplate: dto.methodologyHandlebarsTemplate ?? null,
dataForensicsLevel: dto.dataForensicsLevel ?? 'L2',
finerWeights: dto.finerWeights ?? undefined,
clinicalExpertPrompt: dto.clinicalExpertPrompt ?? null,
updatedAt: now,
},
update: {
...(dto.editorialRules !== undefined && { editorialRules: dto.editorialRules }),
...(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.clinicalExpertPrompt !== undefined && { clinicalExpertPrompt: dto.clinicalExpertPrompt }),
updatedAt: now,
},
});
logger.info('[RvwConfig] 租户审稿配置已更新', {
tenantId,
tenantName: tenant.name,
});
return config;
}

View File

@@ -30,14 +30,18 @@ function getUserId(request: FastifyRequest): string {
}
/**
* 获取租户ID从JWT Token中获取
* 获取租户ID
*
* RVW V4.0 优先级:
* 1. request.tenantId — 由 rvwTenantMiddleware 解析 x-tenant-id Header 注入(期刊租户)
* 2. request.user.tenantId — JWT Payload 中的租户 ID主站单租户向后兼容
* 3. null — 两者都没有(老数据或无租户上下文,不报错,不影响主流程)
*/
function getTenantId(request: FastifyRequest): string {
const tenantId = (request as any).user?.tenantId;
if (!tenantId) {
throw new Error('Tenant not found');
}
return tenantId;
function getTenantId(request: FastifyRequest): string | null {
// V4.0middleware 注入的期刊租户 UUID 优先
if (request.tenantId) return request.tenantId;
// 向后兼容:从 JWT 读取(单租户用户)
return (request as any).user?.tenantId ?? null;
}
// ==================== 任务创建 ====================
@@ -56,7 +60,7 @@ export async function createTask(
) {
try {
const userId = getUserId(request);
const tenantId = getTenantId(request);
const tenantId = getTenantId(request); // null 对单租户用户无影响
logger.info('[RVW:Controller] 上传稿件', { userId, tenantId });
// 获取上传的文件
@@ -118,8 +122,8 @@ export async function createTask(
});
}
// 创建任务
const task = await reviewService.createTask(file, filename, userId, tenantId, modelType);
// 创建任务tenantId 为 null 时 reviewService 会跳过 DB 写入,单租户向后兼容)
const task = await reviewService.createTask(file, filename, userId, tenantId ?? null, modelType);
// 埋点:稿件上传
try {
@@ -280,7 +284,8 @@ export async function getTaskList(
logger.info('[RVW:Controller] 获取任务列表', { status, page, limit });
const result = await reviewService.getTaskList({ userId, status, page, limit });
const tenantId = getTenantId(request);
const result = await reviewService.getTaskList({ userId, tenantId, status, page, limit });
return reply.send({
success: true,

View File

@@ -0,0 +1,128 @@
/**
* RVW V4.0 租户解析中间件
*
* 职责:
* 1. 从 `x-tenant-id` Header 中提取期刊租户 slug如 'jtim'
* 2. 通过 common/cache 做 slug → tenants.id (UUID) 的翻译TTL 5 分钟)
* 3. 验证当前登录用户 (userId) 是否为该租户的成员(查 tenant_members 表)
* 4. 将解析后的 tenantId 和 tenant 对象挂载到 request供后续 handler 使用
*
* 使用方式(在 RVW V4.0 路由上配置):
* ```typescript
* fastify.get('/tasks', {
* preHandler: [authenticate, requireModule('RVW'), rvwTenantMiddleware]
* }, handler)
* ```
*
* 注意:
* - 仅当 `x-tenant-id` Header 存在时才执行租户校验
* - Header 不存在时主平台用户透明放行request.tenantId = undefined
* - SUPER_ADMIN 拥有所有租户访问权,跳过 tenant_members 检查
*
* @module rvw/middleware/rvwTenantMiddleware
*/
import { FastifyRequest, FastifyReply, preHandlerHookHandler } from 'fastify';
import { prisma } from '../../../config/database.js';
import { cache } from '../../../common/cache/index.js';
import { logger } from '../../../common/logging/index.js';
/** slug → tenant 缓存 TTL5 分钟 */
const TENANT_CACHE_TTL = 60 * 5;
/**
* 根据 slugtenants.code查询租户并缓存结果
*/
async function resolveTenantBySlug(slug: string): Promise<{
id: string;
code: string;
name: string;
status: string;
} | null> {
const cacheKey = `rvw:tenant_slug:${slug}`;
// 先查缓存
const cached = await cache.get<{ id: string; code: string; name: string; status: string }>(cacheKey);
if (cached) return cached;
// 查数据库
const tenant = await prisma.tenants.findUnique({
where: { code: slug },
select: { id: true, code: true, name: true, status: true },
});
if (!tenant) return null;
// 写缓存
await cache.set(cacheKey, tenant, TENANT_CACHE_TTL);
return tenant;
}
/**
* RVW V4.0 租户解析中间件
*/
export const rvwTenantMiddleware: preHandlerHookHandler = async (
request: FastifyRequest,
reply: FastifyReply
) => {
const tenantSlug = request.headers['x-tenant-id'] as string | undefined;
// 无 x-tenant-id Header → 主平台用户,透明放行
if (!tenantSlug) {
return;
}
// ── Step 1: slug → tenant UUID 翻译 ──────────────────────────────────────
const tenant = await resolveTenantBySlug(tenantSlug);
if (!tenant) {
logger.warn('[RvwTenant] 未知租户 slug', { tenantSlug });
return reply.status(404).send({
error: 'NotFound',
message: `租户 '${tenantSlug}' 不存在`,
});
}
if (tenant.status !== 'ACTIVE') {
logger.warn('[RvwTenant] 租户已停用', { tenantSlug, status: tenant.status });
return reply.status(403).send({
error: 'Forbidden',
message: `租户 '${tenantSlug}' 已被停用`,
});
}
// ── Step 2: 验证用户归属tenant_members 表)──────────────────────────────
// SUPER_ADMIN 可访问所有租户,跳过成员检查
if (request.user && request.user.role !== 'SUPER_ADMIN') {
const userId = request.user.userId;
const isMember = await prisma.tenant_members.findFirst({
where: {
tenant_id: tenant.id,
user_id: userId,
},
select: { user_id: true },
});
if (!isMember) {
logger.warn('[RvwTenant] 用户不是该租户成员', {
userId,
tenantSlug,
tenantId: tenant.id,
});
return reply.status(403).send({
error: 'Forbidden',
message: '您没有访问此期刊的权限',
});
}
}
// ── Step 3: 挂载到 request ─────────────────────────────────────────────────
request.tenantId = tenant.id;
request.tenant = tenant;
logger.debug('[RvwTenant] 租户上下文已注入', {
tenantSlug,
tenantId: tenant.id,
tenantName: tenant.name,
userId: request.user?.userId,
});
};

View File

@@ -8,41 +8,51 @@
import type { FastifyInstance } from 'fastify';
import * as reviewController from '../controllers/reviewController.js';
import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js';
import { rvwTenantMiddleware } from '../middleware/rvwTenantMiddleware.js';
/**
* RVW V4.0:标准 preHandler 链
* 1. authenticate — JWT 身份校验
* 2. requireModule — RVW 模块权限校验
* 3. rvwTenantMiddleware — 解析 x-tenant-id Header → request.tenantId
* (无 Header 时透明跳过,保持单租户路径向后兼容)
*/
const rvwPreHandlers = [authenticate, requireModule('RVW'), rvwTenantMiddleware];
export default async function rvwRoutes(fastify: FastifyInstance) {
// ==================== 任务管理 ====================
// 创建任务(上传稿件)
// POST /api/v1/rvw/tasks
fastify.post('/tasks', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.createTask);
fastify.post('/tasks', { preHandler: rvwPreHandlers }, reviewController.createTask);
// 获取任务列表
// GET /api/v1/rvw/tasks?status=all|pending|completed&page=1&limit=20
fastify.get('/tasks', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.getTaskList);
fastify.get('/tasks', { preHandler: rvwPreHandlers }, reviewController.getTaskList);
// 获取任务详情
// GET /api/v1/rvw/tasks/:taskId
fastify.get('/tasks/:taskId', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.getTaskDetail);
fastify.get('/tasks/:taskId', { preHandler: rvwPreHandlers }, reviewController.getTaskDetail);
// 获取审查报告
// GET /api/v1/rvw/tasks/:taskId/report
fastify.get('/tasks/:taskId/report', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.getTaskReport);
fastify.get('/tasks/:taskId/report', { preHandler: rvwPreHandlers }, reviewController.getTaskReport);
// 删除任务
// DELETE /api/v1/rvw/tasks/:taskId
fastify.delete('/tasks/:taskId', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.deleteTask);
fastify.delete('/tasks/:taskId', { preHandler: rvwPreHandlers }, reviewController.deleteTask);
// ==================== 运行审查 ====================
// 运行审查(选择智能体)
// POST /api/v1/rvw/tasks/:taskId/run
// Body: { agents: ['editorial', 'methodology'] }
fastify.post('/tasks/:taskId/run', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.runReview);
fastify.post('/tasks/:taskId/run', { preHandler: rvwPreHandlers }, reviewController.runReview);
// 批量运行审查
// POST /api/v1/rvw/tasks/batch/run
// Body: { taskIds: [...], agents: ['editorial', 'methodology'] }
fastify.post('/tasks/batch/run', { preHandler: [authenticate, requireModule('RVW')] }, reviewController.batchRunReview);
fastify.post('/tasks/batch/run', { preHandler: rvwPreHandlers }, reviewController.batchRunReview);
}

View File

@@ -425,29 +425,45 @@ async function repairMethodologyToJson(
* @param text 稿件文本
* @param modelType 模型类型
* @param userId 用户ID用于灰度预览判断
* @param overrideBusinessPrompt RVW V4.0:租户专属业务提示词(覆盖 PromptService 默认值)
* @returns 评估结果
*/
export async function reviewMethodology(
text: string,
modelType: ModelType = 'deepseek-v3',
userId?: string
userId?: string,
overrideBusinessPrompt?: string | null
): Promise<MethodologyReview> {
try {
// 1. 从 PromptService 获取系统Prompt支持灰度预览
const promptService = getPromptService(prisma);
const { content: businessPrompt, isDraft, version } = await promptService.get(
'RVW_METHODOLOGY',
{},
{ userId }
);
const promptFingerprint = createHash('sha1').update(businessPrompt).digest('hex').slice(0, 12);
logger.info('[RVW:Methodology] Prompt 已加载', {
userId,
isDraft,
version,
promptFingerprint,
});
let businessPrompt: string;
if (overrideBusinessPrompt) {
// V4.0使用租户专属业务提示词Hybrid Prompt 动态层)
businessPrompt = overrideBusinessPrompt;
const promptFingerprint = createHash('sha1').update(businessPrompt).digest('hex').slice(0, 12);
logger.info('[RVW:Methodology] 使用租户专属业务 Prompt', {
userId,
promptFingerprint,
source: 'tenant_rvw_config',
});
} else {
// 默认:从 PromptService 获取系统Prompt支持灰度预览
const promptService = getPromptService(prisma);
const { content: promptContent, isDraft, version } = await promptService.get(
'RVW_METHODOLOGY',
{},
{ userId }
);
businessPrompt = promptContent;
const promptFingerprint = createHash('sha1').update(businessPrompt).digest('hex').slice(0, 12);
logger.info('[RVW:Methodology] Prompt 已加载PromptService', {
userId,
isDraft,
version,
promptFingerprint,
source: 'prompt_service',
});
}
const llmAdapter = LLMFactory.getAdapter(modelType);
logger.info('[RVW:Methodology] 开始分治并行评估', {

View File

@@ -70,7 +70,7 @@ export async function createTask(
file: Buffer,
filename: string,
userId: string,
tenantId: string,
tenantId: string | null, // RVW V4.0null 表示单租户主站用户,兼容历史调用
modelType: ModelType = 'deepseek-v3'
) {
logger.info('[RVW] 创建审查任务', { filename, userId, tenantId, modelType });
@@ -79,6 +79,8 @@ export async function createTask(
const task = await prisma.reviewTask.create({
data: {
userId,
// RVW V4.0:写入期刊租户 ID实现数据隔离单租户用户传 null 不影响)
...(tenantId ? { tenantId } : {}),
fileName: filename,
fileSize: file.length,
extractedText: '', // 初始为空,运行时提取
@@ -311,11 +313,14 @@ export async function batchRunReview(params: BatchRunParams): Promise<{
* 获取任务列表
*/
export async function getTaskList(params: TaskListParams): Promise<TaskListResponse> {
const { userId, status = 'all', page = 1, limit = 20 } = params;
const { userId, tenantId, status = 'all', page = 1, limit = 20 } = params;
const skip = (page - 1) * limit;
// 构建查询条件
const where: Prisma.ReviewTaskWhereInput = { userId };
// RVW V4.0:有 tenantId 时按租户隔离;单租户模式仍按 userId 过滤(向后兼容)
const where: Prisma.ReviewTaskWhereInput = tenantId
? { tenantId, userId } // 期刊租户:只返回该租户 + 该用户的任务
: { userId }; // 主站单租户:仍按 userId 过滤
if (status === 'pending') {
where.status = { in: ['pending', 'extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'] };

View File

@@ -0,0 +1,178 @@
/**
* RVW V4.0 报告渲染引擎
*
* 设计原则(遵循开发计划 §10.2 复用通用能力层):
* - Handlebars复用平台已安装的 handlebars 包common/prompt 也在用)
* - ZodLLM 结构化输出校验 + 缺失字段兜底
* - 默认模板硬编码在代码中P0 优先上线;租户可在 ADMIN 页面覆盖
* - 沙箱安全:使用 Handlebars.create() 独立实例,防模板注入
*
* @module rvw/services/rvwReportRenderer
*/
import Handlebars from 'handlebars';
import { z } from 'zod';
import { logger } from '../../../common/logging/index.js';
// ─────────────────────────────────────────────────────
// Zod SchemaLLM 方法学输出结构校验
// ─────────────────────────────────────────────────────
const CheckpointSchema = z.object({
id: z.number().int().min(1).max(20),
item: z.string().default('未知检查点'),
status: z.enum(['pass', 'minor_issue', 'major_issue', 'not_mentioned']).default('not_mentioned'),
finding: z.string().default('未被模型明确评估'),
suggestion: z.string().optional(),
});
const MethodologyOutputSchema = z.object({
overall_score: z.number().min(0).max(100).default(60),
summary: z.string().default(''),
conclusion: z.enum(['直接接收', '小修', '大修', '拒稿']).optional(),
expert_report_markdown: z.string().default(''),
checkpoints: z.array(CheckpointSchema).default([]),
parts: z.array(z.object({
part: z.string(),
score: z.number().min(0).max(100).default(60),
issues: z.array(z.any()).default([]),
})).default([]),
});
export type MethodologyLLMOutput = z.infer<typeof MethodologyOutputSchema>;
// ─────────────────────────────────────────────────────
// 默认 Handlebars 模板(硬编码,可被租户覆盖)
// ─────────────────────────────────────────────────────
/**
* 方法学报告默认模板
* 结构:总体评价 → 专家意见正文expert_report_markdown → 20项检查点覆盖情况
*/
const DEFAULT_METHODOLOGY_TEMPLATE = `# 方法学评估报告
**综合评分:** {{overall_score}} 分{{#if conclusion}} **审稿结论:** {{conclusion}}{{/if}}
---
## 总体评价
{{#if summary}}{{summary}}{{else}}(无总体评价){{/if}}
---
## 详细审查意见
{{#if expert_report_markdown}}
{{expert_report_markdown}}
{{else}}
(方法学评估正文未生成)
{{/if}}
---
## 20 项检查点覆盖情况
{{#each checkpoints}}
- [{{statusIcon status}}] **{{id}}. {{item}}**{{finding}}{{#if suggestion}}
> 💡 建议:{{suggestion}}{{/if}}
{{/each}}
`;
/** status 图标 helper */
function statusIcon(status: string): string {
switch (status) {
case 'pass': return '✅';
case 'minor_issue': return '🟡';
case 'major_issue': return '🔴';
default: return '⬜';
}
}
// ─────────────────────────────────────────────────────
// 沙箱 Handlebars 实例(防模板注入攻击)
// ─────────────────────────────────────────────────────
function createSandboxedHandlebars(): typeof Handlebars {
const hbs = Handlebars.create();
// 注册安全 helpers
hbs.registerHelper('statusIcon', statusIcon);
hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);
return hbs;
}
// ─────────────────────────────────────────────────────
// 公开 API
// ─────────────────────────────────────────────────────
/**
* 渲染方法学评估报告
*
* @param rawData - LLM 返回的原始 JSON 数据(可能包含缺失字段)
* @param customTemplate - 租户配置的自定义 Handlebars 模板(可选)
* @returns 渲染后的 Markdown 报告字符串;若渲染失败,降级返回 expert_report_markdown 或 summary
*/
export function renderMethodologyReport(
rawData: unknown,
customTemplate?: string | null
): string {
// Step 1: Zod 校验 + 缺失字段兜底
const parseResult = MethodologyOutputSchema.safeParse(rawData);
if (!parseResult.success) {
logger.warn('[RvwRenderer] LLM 输出 Zod 校验失败,使用原始数据兜底', {
errors: parseResult.error.issues.map(i => i.message),
});
}
const safeData = parseResult.success ? parseResult.data : {
overall_score: 60,
summary: '',
conclusion: undefined,
expert_report_markdown: typeof rawData === 'object' && rawData !== null
? String((rawData as Record<string, unknown>).expert_report_markdown ?? (rawData as Record<string, unknown>).summary ?? '')
: '',
checkpoints: [],
parts: [],
};
const template = customTemplate?.trim() || DEFAULT_METHODOLOGY_TEMPLATE;
// Step 2: Handlebars 渲染(沙箱实例)
try {
const hbs = createSandboxedHandlebars();
const compiledTemplate = hbs.compile(template, { strict: false });
return compiledTemplate(safeData);
} catch (err) {
logger.error('[RvwRenderer] Handlebars 渲染失败,降级返回原始报告', {
error: err instanceof Error ? err.message : String(err),
usingCustomTemplate: !!customTemplate,
});
// 降级:直接返回 expert_report_markdown 或 summary
return safeData.expert_report_markdown || safeData.summary || '报告渲染失败,请查看原始 JSON 数据';
}
}
/**
* 验证 Handlebars 模板语法(用于 ADMIN 端预览前校验)
*
* @param template - 待验证的 Handlebars 模板字符串
* @returns { valid: boolean; error?: string }
*/
export function validateHandlebarsTemplate(template: string): { valid: boolean; error?: string } {
try {
const hbs = createSandboxedHandlebars();
hbs.precompile(template);
return { valid: true };
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/** 导出默认模板(供 ADMIN 端"重置为默认"按钮使用) */
export const DEFAULT_TEMPLATES = {
methodology: DEFAULT_METHODOLOGY_TEMPLATE,
} as const;

View File

@@ -109,6 +109,23 @@ export interface ForensicsResult {
llmTableReports?: Record<string, string>;
}
/**
* RVW V4.0:租户审稿配置快照(注入到 SkillContext避免每个 Skill 单独查 DB
* 字段与 TenantRvwConfig 对应,仅携带运行时所需的轻量子集
*/
export interface TenantRvwConfigSnapshot {
/** 方法学评估:专家业务评判标准(覆盖 PromptService 默认值) */
methodologyExpertPrompt?: string | null;
/** 临床评估:专科特色补充要求 */
clinicalExpertPrompt?: string | null;
/** 稿约规范评估:规则数组 */
editorialRules?: unknown;
/** 数据验证:深度级别 L1/L2/L3 */
dataForensicsLevel?: string;
/** 临床评估FINER 权重 */
finerWeights?: unknown;
}
/**
* RVW 模块扩展字段
*/
@@ -119,6 +136,8 @@ export interface RvwContextExtras {
tables?: TableData[];
methods?: string[];
forensicsResult?: ForensicsResult;
/** RVW V4.0:租户专属配置快照(为 null 时各 Skill 使用系统默认值) */
tenantRvwConfig?: TenantRvwConfigSnapshot | null;
}
/**

View File

@@ -100,8 +100,7 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
});
}
// 如果 DataForensicsSkill 提取了统计方法,可以添加到 prompt 中
// 目前 reviewMethodology 不支持此参数,留作未来扩展
// 如果 DataForensicsSkill 提取了统计方法,可以添加到 prompt 中(留作未来扩展)
const methodsHint = context.methods?.join(', ') || '';
if (methodsHint) {
logger.debug('[MethodologySkill] Using detected methods as hint', {
@@ -110,8 +109,17 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
});
}
// 调用现有 methodologyService
const result = await reviewMethodology(content, 'deepseek-v3', context.userId);
// V4.0 Hybrid Prompt优先使用租户专属业务提示词无则回落到 PromptService 默认值
const tenantExpertPrompt = context.tenantRvwConfig?.methodologyExpertPrompt ?? null;
if (tenantExpertPrompt) {
logger.info('[MethodologySkill] 使用租户专属方法学业务 Prompt', {
taskId: context.taskId,
tenantPromptLength: tenantExpertPrompt.length,
});
}
// 调用 methodologyService传入租户覆盖 Prompt
const result = await reviewMethodology(content, 'deepseek-v3', context.userId, tenantExpertPrompt);
// 转换为 SkillResult 格式
const issues = this.convertToIssues(result);

View File

@@ -142,6 +142,8 @@ export interface BatchRunParams {
*/
export interface TaskListParams {
userId: string;
/** RVW V4.0:期刊租户 UUID有值时仅返回该租户的任务数据隔离 */
tenantId?: string | null;
status?: 'all' | 'pending' | 'completed';
page?: number;
limit?: number;

View File

@@ -485,20 +485,54 @@ async function executeWithSkills(
console.log(` Profile: ${profile.name}`);
console.log(` Pipeline: ${profile.pipeline.map(p => p.skillId).join(' → ')}`);
// 构建上下文
const partialContext = createPartialContextFromTask({
id: taskId,
userId,
filePath,
content: extractedText,
fileName,
fileSize,
});
const currentTask = await prisma.reviewTask.findUnique({
// V4.0:加载租户专属审稿配置,注入到 SkillContext 实现 Hybrid Prompt
const taskWithTenant = await prisma.reviewTask.findUnique({
where: { id: taskId },
select: { contextData: true },
select: { tenantId: true, contextData: true },
});
let tenantRvwConfig: {
methodologyExpertPrompt?: string | null;
clinicalExpertPrompt?: string | null;
editorialRules?: unknown;
dataForensicsLevel?: string;
finerWeights?: unknown;
} | null = null;
if (taskWithTenant?.tenantId) {
const cfg = await prisma.tenantRvwConfig.findUnique({
where: { tenantId: taskWithTenant.tenantId },
select: {
methodologyExpertPrompt: true,
clinicalExpertPrompt: true,
editorialRules: true,
dataForensicsLevel: true,
finerWeights: true,
},
});
if (cfg) {
tenantRvwConfig = cfg;
logger.info('[reviewWorker] 已加载租户审稿配置', {
taskId,
tenantId: taskWithTenant.tenantId,
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
});
}
}
// 构建上下文V4.0:注入租户配置实现 Hybrid Prompt
const partialContext = {
...createPartialContextFromTask({
id: taskId,
userId,
filePath,
content: extractedText,
fileName,
fileSize,
}),
tenantRvwConfig: tenantRvwConfig ?? null,
};
const currentTask = taskWithTenant;
const incrementalContext =
((currentTask?.contextData as Record<string, unknown> | null) || {});
const runningContext: Record<string, unknown> = { ...incrementalContext };