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:
@@ -0,0 +1,40 @@
|
||||
-- RVW V4.0:给 review_tasks 表添加 tenant_id 字段(历史数据平滑迁移两步走)
|
||||
-- ⚠️ 部署前检查:执行前请确保 platform_schema.tenants 中已存在 code='yanjiu' 的默认租户
|
||||
-- Migration: 20260314_add_tenant_id_to_review_tasks
|
||||
|
||||
-- ============================================================
|
||||
-- Step 1: 新增 tenant_id 列(先设为 NULL,允许历史数据缺失)
|
||||
-- ============================================================
|
||||
ALTER TABLE "rvw_schema"."review_tasks"
|
||||
ADD COLUMN "tenant_id" TEXT;
|
||||
|
||||
-- ============================================================
|
||||
-- Step 2: 将历史数据批量回填为默认主站租户
|
||||
-- 查询 code='yanjiu' 的租户ID,回填到所有历史记录
|
||||
-- 若无 yanjiu 租户,历史记录 tenant_id 保持 NULL(不影响运行)
|
||||
-- ============================================================
|
||||
UPDATE "rvw_schema"."review_tasks"
|
||||
SET "tenant_id" = (
|
||||
SELECT "id" FROM "platform_schema"."tenants"
|
||||
WHERE "code" = 'yanjiu'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE "tenant_id" IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- 添加索引(无论是否有值,索引先建好供 V4.0 查询使用)
|
||||
-- ============================================================
|
||||
CREATE INDEX "idx_review_tasks_tenant_id"
|
||||
ON "rvw_schema"."review_tasks"("tenant_id");
|
||||
|
||||
-- ============================================================
|
||||
-- ⚠️ Step 3(可选,延后执行):确认历史数据已全部回填后,再加 NOT NULL 约束
|
||||
-- 在生产环境手动执行以下 SQL,或在后续迁移中加入:
|
||||
--
|
||||
-- ALTER TABLE "rvw_schema"."review_tasks"
|
||||
-- ALTER COLUMN "tenant_id" SET NOT NULL;
|
||||
--
|
||||
-- ALTER TABLE "rvw_schema"."review_tasks"
|
||||
-- ADD CONSTRAINT "review_tasks_tenant_id_fkey"
|
||||
-- FOREIGN KEY ("tenant_id") REFERENCES "platform_schema"."tenants"("id");
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,28 @@
|
||||
-- RVW V4.0:新增期刊租户级审稿配置表
|
||||
-- 每个期刊租户独立配置稿约规则、方法学/临床专家提示词、Handlebars 展示模板
|
||||
-- Migration: 20260314_add_tenant_rvw_configs
|
||||
|
||||
CREATE TABLE "platform_schema"."tenant_rvw_configs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"editorial_rules" JSONB,
|
||||
"methodology_expert_prompt" TEXT,
|
||||
"methodology_handlebars_template" TEXT,
|
||||
"data_forensics_level" TEXT NOT NULL DEFAULT 'L2',
|
||||
"finer_weights" JSONB,
|
||||
"clinical_expert_prompt" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tenant_rvw_configs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Unique constraint: 每个租户只有一份配置
|
||||
CREATE UNIQUE INDEX "tenant_rvw_configs_tenant_id_key"
|
||||
ON "platform_schema"."tenant_rvw_configs"("tenant_id");
|
||||
|
||||
-- Foreign key to tenants(同 schema 内,可用标准外键)
|
||||
ALTER TABLE "platform_schema"."tenant_rvw_configs"
|
||||
ADD CONSTRAINT "tenant_rvw_configs_tenant_id_fkey"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "platform_schema"."tenants"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -325,10 +325,14 @@ model ReviewTask {
|
||||
// 注意:userId 暂不添加外键约束,因为用户来自不同 schema (platform_schema.users)
|
||||
// 跨 schema 外键在 PostgreSQL 中需要特殊处理
|
||||
|
||||
/// RVW V4.0 多租户:期刊租户 ID(nullable,历史数据通过迁移补填)
|
||||
tenantId String? @map("tenant_id")
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@index([isArchived])
|
||||
@@index([tenantId], map: "idx_review_tasks_tenant_id")
|
||||
@@map("review_tasks")
|
||||
@@schema("rvw_schema")
|
||||
}
|
||||
@@ -1929,6 +1933,7 @@ model tenants {
|
||||
users User[]
|
||||
user_modules user_modules[]
|
||||
iitProjects IitProject[]
|
||||
tenantRvwConfig TenantRvwConfig?
|
||||
|
||||
@@index([code], map: "idx_tenants_code")
|
||||
@@index([status], map: "idx_tenants_status")
|
||||
@@ -1951,6 +1956,39 @@ model verification_codes {
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
/// RVW V4.0 智能审稿 SaaS — 租户级审稿配置
|
||||
/// 每个期刊租户独立一份,控制4个审查维度的评估标准与展示模板
|
||||
/// 关联 platform_schema.tenants
|
||||
model TenantRvwConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
tenant tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// 稿约规范评估:规则数组,每条规则含 code/description/fatal
|
||||
editorialRules Json? @map("editorial_rules")
|
||||
|
||||
/// 方法学评估:专家业务评判标准(纯文本,可自由编辑,不需懂JSON)
|
||||
methodologyExpertPrompt String? @map("methodology_expert_prompt") @db.Text
|
||||
|
||||
/// 方法学评估:Handlebars 报告展示模板(可覆盖系统默认模板)
|
||||
methodologyHandlebarsTemplate String? @map("methodology_handlebars_template") @db.Text
|
||||
|
||||
/// 数据验证:验证深度 L1/L2/L3
|
||||
dataForensicsLevel String @default("L2") @map("data_forensics_level")
|
||||
|
||||
/// 临床评估:FINER 五维权重 {feasibility, innovation, ethics, relevance, novelty}
|
||||
finerWeights Json? @map("finer_weights")
|
||||
|
||||
/// 临床评估:专科特色补充要求(纯文本)
|
||||
clinicalExpertPrompt String? @map("clinical_expert_prompt") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("tenant_rvw_configs")
|
||||
@@schema("platform_schema")
|
||||
}
|
||||
|
||||
enum job_state {
|
||||
created
|
||||
retry
|
||||
|
||||
99
backend/scripts/test-data/01_seed_jtim_tenant.sql
Normal file
99
backend/scripts/test-data/01_seed_jtim_tenant.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- =============================================================
|
||||
-- RVW V4.0 本地测试数据:创建 jtim 期刊租户及测试账号
|
||||
-- 执行方式:docker cp 此文件 到容器后 psql -f 执行
|
||||
-- =============================================================
|
||||
|
||||
-- 1. 创建 jtim 期刊租户
|
||||
INSERT INTO platform_schema.tenants (id, name, code, type, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'tenant-jtim-001',
|
||||
'中华内科杂志',
|
||||
'jtim',
|
||||
'HOSPITAL', -- 暂用 HOSPITAL 类型(期刊类型未来可扩展枚举值 JOURNAL)
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2. 创建 jtim 专属测试用户(密码 bcrypt hash = "Test@1234")
|
||||
-- 使用 bcrypt hash: $2b$10$hashed_value... 实际用已存在用户代替
|
||||
-- 注意:users.tenant_id 是主租户(用于主站),tenant_members 表决定跨租户访问权限
|
||||
INSERT INTO platform_schema.users (
|
||||
id, phone, password, email, name, tenant_id,
|
||||
role, status, is_default_password, is_trial, kb_quota, kb_used, token_version,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-jtim-reviewer-001',
|
||||
'13900139001',
|
||||
'$2a$10$OzlvWo.N5FKeU9BgBVuZ1e1DPonG7bF/WVO6PVW5d5di.fK7oxUnW', -- Test@1234
|
||||
'jtim-reviewer@test.com',
|
||||
'张审稿人',
|
||||
'tenant-jtim-001', -- 主租户 = jtim
|
||||
'USER',
|
||||
'active',
|
||||
false,
|
||||
false,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 3. 绑定用户到 jtim 租户(tenant_members 表 - 用于 rvwTenantMiddleware 权限校验)
|
||||
INSERT INTO platform_schema.tenant_members (id, tenant_id, user_id, role, joined_at)
|
||||
VALUES (
|
||||
'member-jtim-reviewer-001',
|
||||
'tenant-jtim-001',
|
||||
'user-jtim-reviewer-001',
|
||||
'USER',
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
||||
|
||||
-- 4. 赋予 RVW 模块权限(user_modules 表)
|
||||
INSERT INTO platform_schema.user_modules (id, user_id, tenant_id, module_code, is_enabled, created_at, updated_at)
|
||||
VALUES (
|
||||
'umod-jtim-rvw-001',
|
||||
'user-jtim-reviewer-001',
|
||||
'tenant-jtim-001',
|
||||
'RVW',
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (user_id, tenant_id, module_code) DO NOTHING;
|
||||
|
||||
-- 5. 创建 jtim 租户的智能审稿配置(使用系统默认,可后续在 ADMIN 修改)
|
||||
INSERT INTO platform_schema.tenant_rvw_configs (
|
||||
id, tenant_id, data_forensics_level,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'rvwcfg-jtim-001',
|
||||
'tenant-jtim-001',
|
||||
'L2',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 6. 验证数据
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
t.name AS tenant_name,
|
||||
t.code AS tenant_code,
|
||||
u.id AS user_id,
|
||||
u.name AS user_name,
|
||||
u.phone,
|
||||
u.email,
|
||||
tm.role AS member_role,
|
||||
rc.data_forensics_level
|
||||
FROM platform_schema.tenants t
|
||||
JOIN platform_schema.users u ON u.tenant_id = t.id
|
||||
JOIN platform_schema.tenant_members tm ON tm.tenant_id = t.id AND tm.user_id = u.id
|
||||
LEFT JOIN platform_schema.tenant_rvw_configs rc ON rc.tenant_id = t.id
|
||||
WHERE t.code = 'jtim';
|
||||
161
backend/scripts/test-data/02_test_rvw_v4_api.ps1
Normal file
161
backend/scripts/test-data/02_test_rvw_v4_api.ps1
Normal file
@@ -0,0 +1,161 @@
|
||||
# =============================================================
|
||||
# RVW V4.0 多租户 API 测试脚本(PowerShell)
|
||||
# 测试目标:验证 x-tenant-id 多租户隔离全链路
|
||||
# 运行前提:仅需启动后端(npm run dev),无需启动前端
|
||||
# 运行方式:在项目根目录执行:.\backend\scripts\test-data\02_test_rvw_v4_api.ps1
|
||||
# =============================================================
|
||||
|
||||
$BASE_URL = "http://localhost:3001"
|
||||
$TENANT_SLUG = "jtim"
|
||||
$TEST_USER_PHONE = "13900139001"
|
||||
$TEST_USER_PASSWORD = "Test@1234"
|
||||
|
||||
# 输出工具函数
|
||||
function Print-Step($step, $msg) { Write-Host "`n[$step] $msg" -ForegroundColor Cyan }
|
||||
function Print-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Print-FAIL($msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||
function Print-INFO($msg) { Write-Host " [INFO] $msg" -ForegroundColor Yellow }
|
||||
|
||||
# =====================================================================
|
||||
# STEP 0:检查后端是否已启动
|
||||
# =====================================================================
|
||||
Print-Step "0" "检查后端服务可用性 $BASE_URL/health"
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "$BASE_URL/health" -Method GET -ErrorAction Stop
|
||||
Print-OK "后端健康检查通过: $($health | ConvertTo-Json -Compress)"
|
||||
} catch {
|
||||
Print-FAIL "后端未启动或不可达!请先运行:cd backend && npm run dev"
|
||||
Print-INFO "错误:$($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# STEP 1:登录获取 JWT Token
|
||||
# =====================================================================
|
||||
Print-Step "1" "登录测试账号 (phone: $TEST_USER_PHONE)"
|
||||
$loginBody = @{
|
||||
phone = $TEST_USER_PHONE
|
||||
password = $TEST_USER_PASSWORD
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$loginResp = Invoke-RestMethod -Uri "$BASE_URL/api/v1/auth/login/password" `
|
||||
-Method POST `
|
||||
-ContentType "application/json" `
|
||||
-Body $loginBody `
|
||||
-ErrorAction Stop
|
||||
|
||||
# 响应结构:data.tokens.accessToken
|
||||
if ($loginResp.success -and $loginResp.data.tokens.accessToken) {
|
||||
$TOKEN = $loginResp.data.tokens.accessToken
|
||||
$USER_ID = $loginResp.data.user.id
|
||||
Print-OK "登录成功!userId=$USER_ID tenantCode=$($loginResp.data.user.tenantCode)"
|
||||
Print-INFO "Token (前50字符): $($TOKEN.Substring(0, [Math]::Min(50, $TOKEN.Length)))..."
|
||||
} else {
|
||||
Print-FAIL "登录响应异常: $($loginResp | ConvertTo-Json)"
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Print-FAIL "登录失败:$($_.Exception.Message)"
|
||||
$errorBody = $_.ErrorDetails.Message
|
||||
if ($errorBody) { Print-INFO "响应体:$errorBody" }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# STEP 2:不带 x-tenant-id,获取任务列表(主站单租户模式,应正常返回)
|
||||
# =====================================================================
|
||||
Print-Step "2" "不带 x-tenant-id Header - 获取任务列表(主站模式)"
|
||||
try {
|
||||
$listNoTenant = Invoke-RestMethod -Uri "$BASE_URL/api/v1/rvw/tasks" `
|
||||
-Method GET `
|
||||
-Headers @{ "Authorization" = "Bearer $TOKEN" } `
|
||||
-ErrorAction Stop
|
||||
|
||||
$total1 = if ($listNoTenant.data.total) { $listNoTenant.data.total } else { 0 }
|
||||
Print-OK "主站模式任务列表获取成功,共 $total1 条"
|
||||
} catch {
|
||||
$status = $_.Exception.Response.StatusCode.value__
|
||||
Print-FAIL "请求失败(HTTP $status):$($_.ErrorDetails.Message)"
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# STEP 3:携带 x-tenant-id: jtim,获取任务列表(期刊租户模式)
|
||||
# =====================================================================
|
||||
Print-Step "3" "携带 x-tenant-id: $TENANT_SLUG - 获取任务列表(期刊租户模式)"
|
||||
try {
|
||||
$listWithTenant = Invoke-RestMethod -Uri "$BASE_URL/api/v1/rvw/tasks" `
|
||||
-Method GET `
|
||||
-Headers @{
|
||||
"Authorization" = "Bearer $TOKEN"
|
||||
"x-tenant-id" = $TENANT_SLUG
|
||||
} `
|
||||
-ErrorAction Stop
|
||||
|
||||
$total2 = if ($listWithTenant.data.total) { $listWithTenant.data.total } else { 0 }
|
||||
Print-OK "期刊租户模式任务列表获取成功,共 $total2 条"
|
||||
} catch {
|
||||
$status = $_.Exception.Response.StatusCode.value__
|
||||
$body = $_.ErrorDetails.Message
|
||||
Print-FAIL "请求失败(HTTP $status):$body"
|
||||
if ($status -eq 401) { Print-INFO "401 说明 rvwTenantMiddleware 拦截成功(用户不在该租户,或中间件未注册)" }
|
||||
if ($status -eq 403) { Print-INFO "403 说明用户无 RVW 模块权限" }
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# STEP 4:携带不存在的租户 slug,应返回 404 或 401
|
||||
# =====================================================================
|
||||
Print-Step "4" "携带无效 x-tenant-id: nonexistent-journal - 应被拦截"
|
||||
try {
|
||||
$listBadTenant = Invoke-RestMethod -Uri "$BASE_URL/api/v1/rvw/tasks" `
|
||||
-Method GET `
|
||||
-Headers @{
|
||||
"Authorization" = "Bearer $TOKEN"
|
||||
"x-tenant-id" = "nonexistent-journal"
|
||||
} `
|
||||
-ErrorAction Stop
|
||||
|
||||
Print-FAIL "期望被拦截但未拦截!响应:$($listBadTenant | ConvertTo-Json -Compress)"
|
||||
} catch {
|
||||
$status = $_.Exception.Response.StatusCode.value__
|
||||
if ($status -eq 404 -or $status -eq 401 -or $status -eq 403) {
|
||||
Print-OK "已正确拦截无效租户!HTTP $status(符合预期)"
|
||||
} else {
|
||||
Print-FAIL "拦截状态码异常:HTTP $status,期望 401/403/404"
|
||||
}
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# STEP 5:用另一个用户 Token 访问 jtim 租户(非成员),应被拦截
|
||||
# =====================================================================
|
||||
Print-Step "5" "用其他用户(非 jtim 成员)访问 jtim 租户 - 应返回 401/403"
|
||||
Print-INFO "此步骤需手动提供另一个用户的 Token,跳过自动化测试"
|
||||
|
||||
# =====================================================================
|
||||
# STEP 6:查询 DB 验证本地数据一致性
|
||||
# =====================================================================
|
||||
Print-Step "6" "数据库验证 - 查询 jtim 租户配置"
|
||||
$sqlCheck = "SELECT t.code, rc.data_forensics_level, tm.role FROM platform_schema.tenants t JOIN platform_schema.tenant_rvw_configs rc ON rc.tenant_id = t.id JOIN platform_schema.tenant_members tm ON tm.tenant_id = t.id WHERE t.code = 'jtim';"
|
||||
$dbResult = docker exec -i ai-clinical-postgres psql -U postgres -d ai_clinical_research -c $sqlCheck 2>&1
|
||||
Print-INFO "DB 查询结果:`n$dbResult"
|
||||
|
||||
# =====================================================================
|
||||
# 测试总结
|
||||
# =====================================================================
|
||||
Write-Host "`n" -NoNewline
|
||||
Write-Host "======================================================" -ForegroundColor Magenta
|
||||
Write-Host " RVW V4.0 多租户 API 测试完成" -ForegroundColor Magenta
|
||||
Write-Host "======================================================" -ForegroundColor Magenta
|
||||
Write-Host ""
|
||||
Write-Host "测试账号信息(用于前端手动验证):" -ForegroundColor White
|
||||
Write-Host " 手机号:$TEST_USER_PHONE"
|
||||
Write-Host " 密码: $TEST_USER_PASSWORD"
|
||||
Write-Host " 租户: $TENANT_SLUG → http://localhost:5173/$TENANT_SLUG"
|
||||
Write-Host ""
|
||||
Write-Host "全流程前端测试步骤:" -ForegroundColor White
|
||||
Write-Host " 1. 启动后端:cd backend && npm run dev"
|
||||
Write-Host " 2. 启动前端:cd frontend-v2 && npm run dev"
|
||||
Write-Host " 3. 浏览器访问:http://localhost:5173/$TENANT_SLUG"
|
||||
Write-Host " 4. 期望重定向到:http://localhost:5173/t/$TENANT_SLUG/login"
|
||||
Write-Host " 5. 用上方账号登录,登录后应进入期刊审稿页面"
|
||||
Write-Host ""
|
||||
10
backend/scripts/test-data/03_fix_jtim_user_password.sql
Normal file
10
backend/scripts/test-data/03_fix_jtim_user_password.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 修复 jtim 测试用户密码(Test@1234 的 bcrypt $2a$10$ hash)
|
||||
UPDATE platform_schema.users
|
||||
SET password = '$2a$10$OzlvWo.N5FKeU9BgBVuZ1e1DPonG7bF/WVO6PVW5d5di.fK7oxUnW',
|
||||
is_default_password = false
|
||||
WHERE id = 'user-jtim-reviewer-001';
|
||||
|
||||
-- 验证
|
||||
SELECT id, name, phone, LEFT(password, 7) AS hash_prefix, is_default_password
|
||||
FROM platform_schema.users
|
||||
WHERE id = 'user-jtim-reviewer-001';
|
||||
@@ -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