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:
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
108
backend/src/modules/admin/rvw-config/rvwConfigController.ts
Normal file
108
backend/src/modules/admin/rvw-config/rvwConfigController.ts
Normal 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: '保存审稿配置失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
31
backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts
Normal file
31
backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
102
backend/src/modules/admin/rvw-config/rvwConfigService.ts
Normal file
102
backend/src/modules/admin/rvw-config/rvwConfigService.ts
Normal 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;
|
||||
}
|
||||
@@ -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.0:middleware 注入的期刊租户 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,
|
||||
|
||||
128
backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts
Normal file
128
backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts
Normal 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 缓存 TTL:5 分钟 */
|
||||
const TENANT_CACHE_TTL = 60 * 5;
|
||||
|
||||
/**
|
||||
* 根据 slug(tenants.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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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] 开始分治并行评估', {
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function createTask(
|
||||
file: Buffer,
|
||||
filename: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
tenantId: string | null, // RVW V4.0:null 表示单租户主站用户,兼容历史调用
|
||||
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'] };
|
||||
|
||||
178
backend/src/modules/rvw/services/rvwReportRenderer.ts
Normal file
178
backend/src/modules/rvw/services/rvwReportRenderer.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* RVW V4.0 报告渲染引擎
|
||||
*
|
||||
* 设计原则(遵循开发计划 §10.2 复用通用能力层):
|
||||
* - Handlebars:复用平台已安装的 handlebars 包(common/prompt 也在用)
|
||||
* - Zod:LLM 结构化输出校验 + 缺失字段兜底
|
||||
* - 默认模板:硬编码在代码中,P0 优先上线;租户可在 ADMIN 页面覆盖
|
||||
* - 沙箱安全:使用 Handlebars.create() 独立实例,防模板注入
|
||||
*
|
||||
* @module rvw/services/rvwReportRenderer
|
||||
*/
|
||||
|
||||
import Handlebars from 'handlebars';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Zod Schema:LLM 方法学输出结构校验
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user