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

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

View File

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

View File

@@ -325,10 +325,14 @@ model ReviewTask {
// 注意userId 暂不添加外键约束,因为用户来自不同 schema (platform_schema.users)
// 跨 schema 外键在 PostgreSQL 中需要特殊处理
/// RVW V4.0 多租户:期刊租户 IDnullable历史数据通过迁移补填
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

View 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';

View 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 ""

View 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';

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);
let businessPrompt: string;
logger.info('[RVW:Methodology] Prompt 已加载', {
userId,
isDraft,
version,
promptFingerprint,
});
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 };

View File

@@ -1,10 +1,11 @@
# AIclinicalresearch 系统当前状态与开发指南
> **文档版本:** v7.0
> **文档版本:** v7.1
> **创建日期:** 2025-11-28
> **维护者:** 开发团队
> **最后更新:** 2026-03-13
> **最后更新:** 2026-03-14
> **🎉 重大里程碑:**
> - **🆕 2026-03-14RVW V4.0 租户门户 MVP 链路打通!** 期刊专属登录页 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看闭环可用;租户列表状态图标展示修正(完成态不再误显示审稿中)
> - **🆕 2026-03-13RVW 方法学稳定性增强V3.0.2** 方法学 20 检查点结构化增强 + A/B/C 分治并行评估 + 规则汇总器统一结论 + 前端展示口径收敛(按三大项分组展示检查点)
> - **🆕 2026-03-09认证模块接入阿里云短信验证码** 登录验证码链路支持 `mock/aliyun` 双模式 + 后端短信服务封装 + 独立联调脚本(`npm run test:sms`+ 实机发送验证通过
> - **🆕 2026-03-08SSA 智能统计分析 Agent 模式 MVP 完成!** Agent 核心 Prompt 接入运营管理端PlannerAgent + CoderAgent 动态化 + 三级容灾)+ Phase 5A 防错护栏 + Prompt 全景盘点Agent 仅用 2 个 PromptQPER 11 个已归档)
@@ -39,6 +40,7 @@
> - **2026-01-22OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
>
> **🆕 最新进展(含认证短信集成 2026-03-09**
> - ✅ **🆕 RVW V4.0 租户门户 MVP 联调通过** — `/:tenantSlug/rvw` 链路可用,执行审稿后回归旧版过程页交互(动态进度 + 动态Tab上传按钮与状态图标问题修复
> - ✅ **🆕 RVW 方法学稳定性增强V3.0.2** — `checkpoints` 结构化输出20项+ 方法学 A/B/C 分治并行评估1-9/10-14/15-20+ 规则汇总器统一 `summary/conclusion` + 前端展示按三大项分组
> - ✅ **🆕 认证短信验证码接入完成** — `sendVerificationCode` 接入阿里云短信网关(保留 `mock`+ 发送成功后再落库验证码 + 环境变量校验 + 联调脚本 `test:sms` + 实机发送验证通过
> - ✅ **🆕 SSA Agent 模式 MVP 完成** — Agent 核心 Prompt 接入运营管理端(`SSA_AGENT_PLANNER` + `SSA_AGENT_CODER` 动态化)+ 三级容灾DB→缓存→fallback+ 种子脚本幂等写入 + Prompt 全景盘点Agent 2 个 / QPER 11 个归档)
@@ -98,7 +100,7 @@
| **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.1完成 + GCP报表 + Bug修复** 质控引擎升级 + 4张GCP业务报表 + AI时间线增强 + 一键全量质控 | **P1-2** |
| **SSA** | 智能统计分析 | **Agent 模式PlannerAgent + CoderAgent + R Docker** + QPER 备用 | ⭐⭐⭐⭐⭐ | 🎉 **Agent 模式 MVP 完成** — Prompt 运营管理化 + Phase 5A 护栏 + 体验优化 + Plan-and-Execute 架构设计,仅用 2 个核心 Prompt | **P1** |
| **ST** | 统计分析工具 | 126 个轻量化统计工具(旧系统 iframe 嵌入) | ⭐⭐⭐⭐ | ✅ **旧系统集成完成** — Token 注入 + Wrapper Bridge + E2E 验证通过 | P2 |
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V3.0.2 进行中90%** - 方法学分治并行+20检查点结构化+展示收敛 | P1 |
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V4.0 租户门户 MVP 联调通过92%** - 期刊专属登录+上传+审稿过程页闭环 | P1 |
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成88%** - Prompt知识库集成+动态注入 | **P0** |
---
@@ -1349,6 +1351,7 @@ AIclinicalresearch/
| **2026-02-26** | **ASL 工具 4+5 完成** 🎉 | ✅ SR图表生成器+Meta分析引擎+R Docker meta包+E2E 36/36 |
| **2026-03-01** | **IIT GCP报表+Bug修复** 🎉 | ✅ 4张GCP标准报表+AI时间线增强+一键全量质控+6项Bug修复 |
| **2026-02-27** | **DB文档+部署体系** 📚 | ✅ 6篇数据库文档+部署归档+统一操作手册+开发规范v3.0+Schema对齐 |
| **2026-03-14** | **RVW V4.0 租户门户联调通过** 🎉 | ✅ 上传/审稿/过程页/报告闭环可用,状态图标与交互体验修正 |
| **当前** | **PKB模块生产可用** | ✅ 核心功能全部实现95%自研RAG+OSS存储上线 |
| **2026-01-07 晚** | **RVW模块开发完成** 🎉 | ✅ Phase 1-3完成后端迁移+数据库扩展+前端重构) |
@@ -1686,9 +1689,9 @@ if (items.length >= 50) {
---
**文档版本**v6.6
**最后更新**2026-03-01
**本次更新**IIT 业务端 GCP 标准报表 4 张完成D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)+ AI 工作流水时间线增强(真实规则数/中文事件名/可展开详情)+ 业务端一键全量质控 + 6 项关键 Bug 修复dimension_code/D1 数据源/时区/通过率/severity 映射/报告缓存刷新
**文档版本**v7.1
**最后更新**2026-03-14
**本次更新**RVW V4.0 租户门户 MVP 联调通过(期刊专属登录 + 上传 + 执行审稿 + 过程页动态进度 + 报告查看)并修复租户列表维度状态显示误判问题;明日进入运营管理端期刊配置中心完善(中英期刊 Skills 匹配 + 租户登录风格 + URL 配置
---

View File

@@ -1,10 +1,10 @@
# RVW稿件审查模块 - 当前状态与开发指南
> **文档版本:** v6.2
> **文档版本:** v6.3
> **创建日期:** 2026-01-07
> **最后更新:** 2026-03-13
> **最后更新:** 2026-03-14
> **维护者:** 开发团队
> **当前状态:** 🚀 **V3.0.2 "方法学稳定性增强" 进行中(分治并行 + 20检查点覆盖 + 展示口径收敛**
> **当前状态:** 🚀 **V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告全链路可用**
> **文档目的:** 快速了解RVW模块状态为新AI助手提供上下文
>
> **🎉 V3.0 进展2026-03-07**
@@ -28,6 +28,13 @@
> - ✅ **规则汇总器合并结果**:统一生成 `overall_score/summary/conclusion/checkpoints/parts`,并保留降级兜底
> - ✅ **前端展示口径统一**:方法学报告按“三大项->检查点”展示去除重复占位文案并显示真实LLM内容
>
> **🆕 V4.0 租户门户联调收敛2026-03-14**
> - ✅ **期刊专属登录页可用**`/t/:tenantCode/login` 支持密码登录 + 验证码登录与主站能力对齐UI为期刊门户风格
> - ✅ **上传链路修复**:租户工作台上传按钮稳定触发,`POST /api/v1/rvw/tasks` 创建任务成功
> - ✅ **执行审稿体验回归旧流程**:点击“开始审稿”后直接进入过程页(复用 `TaskDetail` 动态进度 + 动态Tab
> - ✅ **租户详情页全屏化复用**:租户路由下适配旧版过程页交互,无 Sidebar 干扰
> - ✅ **列表状态图标修复**:审稿完成后维度状态正确展示(绿色对钩/警告/错误),不再误显示“审稿中”
>
> **V2.0 进展回顾:**
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
> - ✅ Skills 核心框架types, registry, executor, profile
@@ -47,7 +54,7 @@
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
| **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) |
| **目标用户** | 期刊初审编辑 |
| **开发状态** | 🚀 **V3.0.2 进行中:方法学分治并行 + 20检查点覆盖展示 + 超时优化** |
| **开发状态** | 🚀 **V4.0 租户门户 MVP 已联调通过:上传→审稿→过程页→报告闭环可用** |
### 核心目标
@@ -481,23 +488,18 @@ Content-Type: multipart/form-data
| 方法学超时窗口扩展 | ✅ 已完成 | MethodologySkill 超时从 5min 调整到 8min |
| 快速模式开关(后续) | ⏳ 规划中 | 长文档自动降耗与更短输出预算 |
### 后续版本V3.1+
### 后续版本V4.0 后续
- [ ] 全面移除评分机制(只列问题,不打分
- [ ] 运营管理端「期刊配置中心」完善(中/英期刊匹配不同 Skills 组合
- [ ] 期刊登录页主题体系(按租户品牌配置登录视觉风格)
- [ ] 期刊 URL 与品牌配置联动(路径租户 + 公共配置 API 扩展)
- [ ] 单模块重试机制partial_completed → 重试失败模块)
- [ ] ANOVA 验证(多组比较)
- [ ] 配对 T 检验验证
- [ ] 非参数检验Mann-Whitney、Wilcoxon
- [ ] .doc 格式支持Pandoc 方案评估)
- [ ] Profile 管理 UI期刊配置界面
- [ ] PDF报告导出优化
- [ ] PICO卡片UI实现
- [ ] 历史归档UI实现
- [ ] 独立产品打包
- [ ] PDF 报告导出优化
- [ ] 历史归档 UI 实现
---
**文档版本:** v6.2
**最后更新:** 2026-03-13
**当前状态:** 🚀 V3.0.2 "方法学稳定性增强" 进行中(分治并行 + 20检查点覆盖 + 展示口径收敛
**下一步:** V3.0.2 收尾验证(超时率/覆盖率) + V3.1 单模块重试与评分策略优化
**文档版本:** v6.3
**最后更新:** 2026-03-14
**当前状态:** 🚀 V4.0 租户门户 MVP 联调通过(上传/审稿/过程页/报告闭环
**下一步:** 完善运营管理端期刊配置中心(中英期刊 Skills 匹配 + 租户登录风格 + URL/品牌配置联动)

View File

@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JTIM - 智能审稿工作台</title>
<!-- 引入 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 引入 FontAwesome 图标 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
}
}
}
}
</script>
<style>
/* 隐藏滚动条但保留滚动功能 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.pdf-mockup {
background-image: repeating-linear-gradient(0deg, transparent, transparent 27px, #e5e7eb 27px, #e5e7eb 28px);
background-size: 100% 28px;
}
</style>
</head>
<body class="bg-gray-50 text-gray-800 font-sans antialiased">
<div id="app" class="h-screen w-full flex flex-col overflow-hidden">
<!-- ==================== 视图 1: 独立登录页 ==================== -->
<div v-if="currentView === 'login'" class="flex h-screen w-full">
<!-- 左侧品牌展示 -->
<div class="w-1/2 bg-brand-900 flex flex-col justify-center items-center text-white p-12 relative overflow-hidden">
<div class="absolute inset-0 opacity-10 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div>
<div class="z-10 text-center">
<div class="w-24 h-24 bg-white rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i class="fas fa-book-medical text-brand-700 text-4xl"></i>
</div>
<h1 class="text-4xl font-bold mb-4">Journal of Translational Internal Medicine</h1>
<p class="text-xl text-brand-100 mb-8">AI 智能审稿系统 (Editor Portal)</p>
<div class="inline-block px-6 py-2 border border-brand-500 rounded-full text-sm">
专属租户入口
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="w-1/2 flex items-center justify-center bg-white">
<div class="w-96">
<h2 class="text-3xl font-bold mb-2 text-gray-800">编辑登录</h2>
<p class="text-gray-500 mb-8">欢迎回来,请登录您的责编工作台。</p>
<form @submit.prevent="login" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">工作邮箱</label>
<input type="email" value="editor@jtim.com" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input type="password" value="password123" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition" required>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" class="rounded text-brand-600 focus:ring-brand-500">
<span class="ml-2 text-sm text-gray-600">记住我</span>
</label>
<a href="#" class="text-sm text-brand-600 hover:text-brand-500">忘记密码?</a>
</div>
<button type="submit" class="w-full bg-brand-600 text-white font-medium py-2.5 rounded-lg hover:bg-brand-700 transition shadow-lg shadow-brand-500/30">
进入工作台 <i class="fas fa-arrow-right ml-2"></i>
</button>
</form>
</div>
</div>
</div>
<!-- ==================== 登录后的公共 Header ==================== -->
<header v-if="currentView !== 'login'" class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0 z-10">
<div class="flex items-center space-x-4">
<div class="w-8 h-8 bg-brand-700 rounded text-white flex items-center justify-center font-bold">JT</div>
<h1 class="text-lg font-bold text-gray-800">JTIM 审稿工作台</h1>
</div>
<div class="flex items-center space-x-6">
<button class="text-gray-500 hover:text-gray-700"><i class="fas fa-bell"></i></button>
<div class="flex items-center space-x-2 border-l pl-6 border-gray-200 cursor-pointer" @click="currentView = 'login'">
<img src="https://i.pravatar.cc/150?u=a042581f4e29026704d" alt="User" class="w-8 h-8 rounded-full">
<span class="text-sm font-medium text-gray-700">责编 李明</span>
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
</div>
</div>
</header>
<!-- ==================== 视图 2: 稿件管理池 (Dashboard) ==================== -->
<div v-if="currentView === 'dashboard'" class="flex-1 overflow-auto p-6 bg-gray-50">
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-6 mb-6">
<div class="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div class="w-12 h-12 bg-blue-50 text-blue-600 rounded-lg flex items-center justify-center text-xl mr-4"><i class="fas fa-inbox"></i></div>
<div><p class="text-sm text-gray-500">待预审稿件</p><p class="text-2xl font-bold">12</p></div>
</div>
<div class="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div class="w-12 h-12 bg-amber-50 text-amber-600 rounded-lg flex items-center justify-center text-xl mr-4"><i class="fas fa-robot"></i></div>
<div><p class="text-sm text-gray-500">AI 审查中</p><p class="text-2xl font-bold">3</p></div>
</div>
<div class="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div class="w-12 h-12 bg-green-50 text-green-600 rounded-lg flex items-center justify-center text-xl mr-4"><i class="fas fa-clipboard-check"></i></div>
<div><p class="text-sm text-gray-500">需人工复核</p><p class="text-2xl font-bold">8</p></div>
</div>
<div class="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div class="w-12 h-12 bg-purple-50 text-purple-600 rounded-lg flex items-center justify-center text-xl mr-4"><i class="fas fa-paper-plane"></i></div>
<div><p class="text-sm text-gray-500">今日已退修</p><p class="text-2xl font-bold">5</p></div>
</div>
</div>
<!-- 宽表 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50/50">
<h2 class="text-lg font-bold text-gray-800">最新收稿池 (Manuscripts Pool)</h2>
<div class="flex space-x-2">
<input type="text" placeholder="搜索稿件编号/作者..." class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-brand-500 focus:border-brand-500 outline-none">
<button class="bg-white border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg text-sm hover:bg-gray-50"><i class="fas fa-filter mr-1"></i> 筛选</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-gray-500 border-b border-gray-200">
<tr>
<th class="px-6 py-3 font-medium">稿件编号</th>
<th class="px-6 py-3 font-medium">标题</th>
<th class="px-6 py-3 font-medium">通讯作者</th>
<th class="px-6 py-3 font-medium">综合评分</th>
<th class="px-6 py-3 font-medium text-center">规范性</th>
<th class="px-6 py-3 font-medium text-center">方法学</th>
<th class="px-6 py-3 font-medium text-center">数据验证</th>
<th class="px-6 py-3 font-medium text-center">临床评估</th>
<th class="px-6 py-3 font-medium">状态</th>
<th class="px-6 py-3 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="doc in manuscripts" :key="doc.id" class="hover:bg-blue-50/30 transition">
<td class="px-6 py-4 font-mono text-brand-600 font-medium">{{ doc.id }}</td>
<td class="px-6 py-4 text-gray-800 max-w-xs truncate" :title="doc.title">{{ doc.title }}</td>
<td class="px-6 py-4 text-gray-600">{{ doc.author }}</td>
<td class="px-6 py-4">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full border-4 flex items-center justify-center font-bold text-xs"
:class="doc.score >= 80 ? 'border-green-100 text-green-600 bg-green-50' : (doc.score >= 60 ? 'border-amber-100 text-amber-600 bg-amber-50' : 'border-red-100 text-red-600 bg-red-50')">
{{ doc.score }}
</div>
</div>
</td>
<!-- 4维状态 -->
<td class="px-6 py-4 text-center"><StatusIcon :status="doc.dims.editorial" /></td>
<td class="px-6 py-4 text-center"><StatusIcon :status="doc.dims.method" /></td>
<td class="px-6 py-4 text-center"><StatusIcon :status="doc.dims.data" /></td>
<td class="px-6 py-4 text-center"><StatusIcon :status="doc.dims.clinical" /></td>
<td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs rounded-full font-medium"
:class="doc.status === 'AI审查完毕' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'">
{{ doc.status }}
</span>
</td>
<td class="px-6 py-4 text-right">
<button v-if="doc.status === 'AI审查完毕'" @click="openDetail(doc)" class="text-brand-600 hover:text-brand-800 font-medium bg-brand-50 px-3 py-1 rounded-md transition">
人工复核
</button>
<button v-else class="text-gray-400 font-medium px-3 py-1 cursor-not-allowed">
审查中...
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ==================== 视图 3: 沉浸式 4-Tab 审稿详情页 ==================== -->
<div v-if="currentView === 'detail'" class="flex-1 flex flex-col overflow-hidden bg-gray-100">
<!-- 详情页工具栏 -->
<div class="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 shrink-0 shadow-sm z-10">
<div class="flex items-center">
<button @click="currentView = 'dashboard'" class="text-gray-500 hover:text-gray-800 mr-4 w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center transition">
<i class="fas fa-arrow-left"></i>
</button>
<div>
<span class="text-xs text-gray-400 font-mono">{{ activeDoc.id }}</span>
<h2 class="text-sm font-bold text-gray-800 truncate max-w-lg">{{ activeDoc.title }}</h2>
</div>
</div>
<div class="flex items-center space-x-3">
<button class="px-3 py-1.5 border border-gray-300 text-gray-700 text-sm font-medium rounded hover:bg-gray-50 transition">
<i class="fas fa-file-word mr-1 text-blue-600"></i> 导出审稿报告
</button>
<button class="px-4 py-1.5 bg-red-600 text-white text-sm font-medium rounded hover:bg-red-700 transition shadow-sm">
<i class="fas fa-reply mr-1"></i> 发送退修意见函
</button>
</div>
</div>
<!-- 分屏内容区 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧: 原文预览 (模拟) -->
<div class="w-3/5 border-r border-gray-300 bg-gray-100 p-4 overflow-auto relative">
<!-- 浮动工具栏 -->
<div class="absolute top-6 left-1/2 transform -translate-x-1/2 bg-gray-800/80 text-white px-4 py-2 rounded-lg flex space-x-4 text-sm backdrop-blur-sm shadow-lg z-10">
<button class="hover:text-brand-300"><i class="fas fa-search-plus"></i></button>
<button class="hover:text-brand-300"><i class="fas fa-search-minus"></i></button>
<span>第 1 / 15 页</span>
</div>
<!-- 模拟 PDF 纸张 -->
<div class="bg-white shadow-lg mx-auto w-full max-w-3xl min-h-[1000px] p-12 pdf-mockup relative">
<h1 class="text-2xl font-bold text-center mb-6">{{ activeDoc.title }}</h1>
<p class="text-center italic mb-8">Author: {{ activeDoc.author }}, et al.</p>
<h3 class="font-bold text-lg mt-8 mb-2">Abstract</h3>
<p class="text-sm leading-relaxed text-gray-700 mb-6 relative group">
<span class="bg-red-200/50 border-b-2 border-red-400 cursor-pointer">Background: Translational medicine aims to... (Here is a very long abstract that exceeds the 200 words limit specified by JTIM guidelines. It goes on to describe the historical context, which is unnecessary for a structured abstract. The methodology is briefly mentioned but lacks statistical details.)</span>
<span class="absolute left-full ml-2 top-0 w-48 bg-red-50 border border-red-200 text-red-700 p-2 text-xs rounded shadow-lg hidden group-hover:block z-20">
<strong>AI 批注:</strong> 摘要未采用 Structured 格式,且字数可能超标。
</span>
</p>
<h3 class="font-bold text-lg mt-8 mb-2">Materials and Methods</h3>
<p class="text-sm leading-relaxed text-gray-700 mb-6 relative group">
Data were analyzed using SPSS. <span class="bg-amber-200/50 border-b-2 border-amber-400 cursor-pointer">A p-value < 0.05 was considered significant.</span>
<span class="absolute left-full ml-2 top-0 w-48 bg-amber-50 border border-amber-200 text-amber-700 p-2 text-xs rounded shadow-lg hidden group-hover:block z-20">
<strong>AI 批注:</strong> JTIM 稿约要求 P 值报告具体数值,不可简单写 p < 0.05
</span>
</p>
<p class="text-sm text-gray-400 mt-20 text-center">... 模拟文档内容结束 ...</p>
</div>
</div>
<!-- 右侧: 4-Tab 审查工作台 -->
<div class="w-2/5 bg-white flex flex-col overflow-hidden shadow-xl z-20">
<!-- Tabs -->
<div class="flex border-b border-gray-200 bg-gray-50/80 shrink-0 px-2 pt-2">
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id"
class="flex-1 py-2.5 text-sm font-medium border-b-2 transition-colors duration-200 rounded-t-lg mx-1 flex items-center justify-center space-x-1"
:class="activeTab === tab.id ? 'border-brand-600 text-brand-700 bg-white shadow-sm' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-100'">
<i :class="tab.icon"></i>
<span>{{ tab.name }}</span>
<!-- 红点提示 -->
<span v-if="getIssueCount(tab.id) > 0" class="ml-1 w-4 h-4 rounded-full bg-red-100 text-red-600 text-[10px] flex items-center justify-center font-bold">
{{ getIssueCount(tab.id) }}
</span>
</button>
</div>
<!-- Tab 内容区 -->
<div class="flex-1 overflow-y-auto bg-gray-50 p-4">
<!-- 统计摘要栏 -->
<div class="bg-white rounded-lg p-3 mb-4 shadow-sm border border-gray-100 flex justify-between items-center">
<div>
<h3 class="text-sm font-bold text-gray-800">{{ currentTabName }}评估结果</h3>
<p class="text-xs text-gray-500 mt-0.5">AI 已完成初步筛查,请责编确认。</p>
</div>
<div class="text-right">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">共发现 {{ currentIssues.length }} 个问题</span>
</div>
</div>
<!-- 问题列表 -->
<div class="space-y-3">
<div v-for="issue in currentIssues" :key="issue.id"
class="bg-white rounded-lg border shadow-sm p-4 transition-all duration-200 relative overflow-hidden"
:class="issue.ignored ? 'border-gray-200 opacity-60' : (issue.severity === 'fatal' ? 'border-red-200' : 'border-amber-200')">
<!-- 侧边装饰条 -->
<div class="absolute left-0 top-0 bottom-0 w-1" :class="getSeverityColor(issue.severity, issue.ignored)"></div>
<div class="flex justify-between items-start pl-2">
<div class="flex-1 pr-4">
<div class="flex items-center space-x-2 mb-1.5">
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded uppercase" :class="getSeverityBadge(issue.severity)">
{{ issue.severity === 'fatal' ? '致命错误' : (issue.severity === 'major' ? '主要缺陷' : '一般建议') }}
</span>
<span class="text-xs text-gray-500 font-mono">{{ issue.code }}</span>
</div>
<h4 class="text-sm font-bold text-gray-800 mb-1" :class="{'line-through text-gray-500': issue.ignored}">{{ issue.title }}</h4>
<p class="text-xs text-gray-600 leading-relaxed mb-3" :class="{'line-through': issue.ignored}">{{ issue.desc }}</p>
<div v-if="issue.suggestion" class="bg-blue-50/50 rounded p-2 text-xs text-blue-800 border border-blue-100">
<i class="fas fa-lightbulb text-amber-500 mr-1"></i> <strong>建议修改:</strong>{{ issue.suggestion }}
</div>
</div>
</div>
<!-- Human-in-the-loop 操作区 -->
<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between pl-2">
<span class="text-xs text-gray-400">人工复核确认:</span>
<div class="flex bg-gray-100 p-0.5 rounded-lg">
<button @click="issue.ignored = false"
class="px-3 py-1 text-xs font-medium rounded-md transition-all"
:class="!issue.ignored ? 'bg-white shadow-sm text-brand-700' : 'text-gray-500 hover:text-gray-700'">
<i class="fas fa-check-circle mr-1" :class="!issue.ignored ? 'text-green-500' : ''"></i> 采纳此问题
</button>
<button @click="issue.ignored = true"
class="px-3 py-1 text-xs font-medium rounded-md transition-all"
:class="issue.ignored ? 'bg-white shadow-sm text-gray-800' : 'text-gray-500 hover:text-gray-700'">
<i class="fas fa-times-circle mr-1 text-gray-400"></i> 误报/忽略
</button>
</div>
</div>
</div>
<!-- 状态为空时 -->
<div v-if="currentIssues.length === 0" class="text-center py-12 bg-white rounded-lg border border-gray-200 border-dashed">
<div class="w-16 h-16 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto mb-3 text-2xl">
<i class="fas fa-check"></i>
</div>
<h3 class="text-sm font-bold text-gray-700">未发现明显缺陷</h3>
<p class="text-xs text-gray-500 mt-1">该维度通过了 AI 的自动化审查。</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 独立组件模板 -->
<script>
const { createApp } = Vue;
// 状态图标组件
const StatusIcon = {
props: ['status'],
template: `
<div class="flex justify-center">
<i v-if="status === 'pass'" class="fas fa-check-circle text-green-500 text-lg"></i>
<i v-else-if="status === 'warn'" class="fas fa-exclamation-triangle text-amber-500 text-lg"></i>
<i v-else-if="status === 'error'" class="fas fa-times-circle text-red-500 text-lg"></i>
<i v-else class="fas fa-spinner fa-spin text-gray-300 text-lg"></i>
</div>
`
};
createApp({
components: { StatusIcon },
data() {
return {
currentView: 'login', // login, dashboard, detail
activeTab: 'editorial',
activeDoc: null,
tabs: [
{ id: 'editorial', name: '稿约规范', icon: 'fas fa-clipboard-list' },
{ id: 'method', name: '方法学', icon: 'fas fa-microscope' },
{ id: 'data', name: '数据验证', icon: 'fas fa-database' },
{ id: 'clinical', name: '临床评估', icon: 'fas fa-stethoscope' }
],
// 模拟稿件列表数据
manuscripts: [
{ id: 'JTIM-2026-0881', title: 'Efficacy of novel inhibitor in advanced NSCLC: A retrospective cohort study', author: 'Dr. Zhang Wei', date: '2026-03-13', score: 65, status: 'AI审查完毕', dims: { editorial: 'error', method: 'warn', data: 'pass', clinical: 'pass' } },
{ id: 'JTIM-2026-0882', title: 'Translational approaches for targeting tumor microenvironment', author: 'Dr. Sarah Connor', date: '2026-03-12', score: 92, status: 'AI审查完毕', dims: { editorial: 'pass', method: 'pass', data: 'pass', clinical: 'pass' } },
{ id: 'JTIM-2026-0883', title: 'Machine learning prediction models in diabetic nephropathy', author: 'Dr. Li Hua', date: '2026-03-12', score: 78, status: 'AI审查完毕', dims: { editorial: 'pass', method: 'error', data: 'warn', clinical: 'warn' } },
{ id: 'JTIM-2026-0884', title: 'Long-term cardiovascular outcomes after acute COVID-19', author: 'Dr. Michael Chen', date: '2026-03-11', score: 0, status: '待预审', dims: { editorial: '', method: '', data: '', clinical: '' } },
],
// 模拟 AI 找出的缺陷数据
issuesData: {
'JTIM-2026-0881': {
editorial: [
{ id: 101, code: 'ED-001', severity: 'fatal', title: '知情同意声明缺失', desc: '作为回顾性队列研究,正文及 Title Page 均未找到 Informed Consent 相关声明,违反 ICMJE 基本伦理准则。', suggestion: '请作者补充声明已获取知情同意,或说明伦理委员会已豁免。', ignored: false },
{ id: 102, code: 'ED-045', severity: 'major', title: '摘要结构不合规', desc: 'JTIM 规定 Original Research 必须使用 Structured Abstract (包含 Objectives, Methods, Results, Conclusions),当前摘要为纯文本段落。', suggestion: '请按指南要求拆分摘要段落。', ignored: false },
{ id: 103, code: 'ED-012', severity: 'minor', title: '统计结果 P 值表述不规范', desc: '发现多处 "p < 0.05" 表述。稿约规定应报告 P 值的确切数值 (如 P = 0.048)。', suggestion: '修改 Methods 和 Results 中的 P 值表述。', ignored: false }
],
method: [
{ id: 201, code: 'ME-033', severity: 'major', title: '回归模型未说明共线性检验', desc: '作者构建了多因素 Cox 比例风险模型但未提及是否对纳入的自变量进行了多重共线性VIF检验。', suggestion: '补充共线性诊断的统计学方法描述。', ignored: false }
],
data: [], // 模拟通过
clinical: [
{ id: 401, code: 'CL-005', severity: 'minor', title: '创新性一般', desc: '该靶向药在 NSCLC 中的回顾性研究已有较多报道,增量信息(创新性)评分较低。', suggestion: '', ignored: true } // 默认演示一个被忽略的项
]
}
}
}
},
computed: {
currentTabName() {
return this.tabs.find(t => t.id === this.activeTab)?.name || '';
},
currentIssues() {
if (!this.activeDoc || !this.issuesData[this.activeDoc.id]) return [];
return this.issuesData[this.activeDoc.id][this.activeTab] || [];
}
},
methods: {
login() {
this.currentView = 'dashboard';
},
openDetail(doc) {
this.activeDoc = doc;
this.activeTab = 'editorial';
this.currentView = 'detail';
},
getIssueCount(tabId) {
if (!this.activeDoc || !this.issuesData[this.activeDoc.id]) return 0;
const issues = this.issuesData[this.activeDoc.id][tabId] || [];
// 只计算未被忽略的
return issues.filter(i => !i.ignored).length;
},
getSeverityBadge(severity) {
if (severity === 'fatal') return 'bg-red-100 text-red-700 border border-red-200';
if (severity === 'major') return 'bg-amber-100 text-amber-700 border border-amber-200';
return 'bg-blue-100 text-blue-700 border border-blue-200';
},
getSeverityColor(severity, ignored) {
if (ignored) return 'bg-gray-300';
if (severity === 'fatal') return 'bg-red-500';
if (severity === 'major') return 'bg-amber-500';
return 'bg-blue-500';
}
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
# **AI智能审稿系统 (期刊SaaS版) 域名架构与多租户落地技术指南**
**文档受众:** 架构师、前端研发、后端研发、运维工程师
**文档目的:** 明确期刊 SaaS 业务的域名规范规范单点登录SSO及多租户鉴权的开发标准排查并规避泛域名 SSL 证书与第三方回调等运维/安全隐患。
## **🏗️ 一、 核心架构决策:三级子域名隔离方案**
经过商业定位与工程复杂度的综合评估,系统采用**基于功能属性的三级子域名方案**
**\[tenantId\].review.xunzhengyixue.com** (例如jtim.review.xunzhengyixue.com)
### **为什么选择这个方案?**
1. **商业心智精准**:使用 review 明确了我们是“AI 辅助审稿工具”,而非沉重的全流程投审稿系统,降低客户防备心。
2. **品牌与视觉隔离**:彻底抛弃 URL路径隔离为每个期刊提供独立的门户网址。
3. **架构解耦**:与现有的临床研究主站(.yanjiu. 或主域)及临床试验项目(.iit.)在命名空间上完美平行,为未来的 SaaS 矩阵拓展留足空间。
## **🛠️ 二、 运维团队 (DevOps) 执行清单**
运维团队需在阿里云环境中完成以下配置,**该方案无需为每个新增期刊重新发布或修改配置**。
### **1\. DNS 云解析配置 (阿里云)**
无需每次新增期刊都修改 DNS。请添加一条**泛解析**记录:
* **主机记录**\*.review
* **记录类型**A 或 CNAME
* **记录值**:指向 SAE 现有的前端 CLB 负载均衡公网 IP目前为 8.140.53.236)。
### **2\. ⚠️ 致命避坑SSL 证书采购与挂载**
* **红线提醒**:现有的 \*.xunzhengyixue.com 泛域名证书**绝对无法覆盖**四级域名(即无法覆盖 jtim.review.xunzhengyixue.com直接使用会导致浏览器报红。
* **执行动作**:必须重新申请/购买一张专门针对 **\*.review.xunzhengyixue.com** 的泛域名 SSL 证书,并挂载到网关或 CLB 上。
### **3\. Nginx 路由代理配置**
前端 frontend-v2 镜像的 nginx.conf 需要配置匹配规则,将所有子域名的请求导向同一个 SPA单页应用入口API 请求依然打向 Node.js 后端。
server {
listen 80;
\# 匹配所有的 review 子域名
server\_name \~^(?\<tenant\>.+)\\.review\\.xunzhengyixue\\.com$;
location / {
root /usr/share/nginx/html;
index index.html;
\# 将所有未匹配静态资源的路由回退给 index.html交由 React Router 处理
try\_files $uri $uri/ /index.html;
}
\# 后端 API 转发保持现有配置不变
location /api/ {
proxy\_pass \[http://172.17.173.73:3001\](http://172.17.173.73:3001);
\# ...
}
}
## **💻 三、 前端研发 (FE) 执行清单**
前端代码库保持同一套,依靠初始化时截取 window.location.hostname 来实现“千刊千面”。
### **1\. 动态截取 TenantID**
在应用挂载层(如 App.tsx 或路由初始化钩子中)注入租户识别逻辑:
const host \= window.location.hostname; // e.g., jtim.review.xunzhengyixue.com
let currentTenantId \= 'default';
// 严格判断当前是否处于期刊审查工作台环境
if (host.endsWith('.review.xunzhengyixue.com')) {
// 提取第一个分段作为 tenantId
currentTenantId \= host.split('.')\[0\];
}
// 拿到 currentTenantId (如 'jtim') 后:
// 1\. 发起请求GET /api/v1/tenants/public-info/jtim 获取 Logo、主题色
// 2\. 存入全局状态 (Zustand/Redux),应用定制化 UI
### **2\. 模块级路由屏蔽**
利用现有的 moduleRegistry.ts如果当前 tenantId 为期刊客户,隐藏顶部导航中的 AIA、DC、PKB 模块,仅暴露 RVW 模块视图。
## **⚙️ 四、 后端研发 (BE) 执行清单**
后端核心挑战在于**跨域资源共享 (CORS)** 以及 **防范多租户架构下的数据越权**
### **1\. CORS 动态白名单**
禁止写死 Origin在 Fastify / Express 的 CORS 插件中启用正则匹配:
// 允许主站以及所有的 review 泛域名跨域访问
cors: {
origin: \[
/^https?:\\/\\/(\[a-zA-Z0-9-\]+\\.)?xunzhengyixue\\.com$/,
/^https?:\\/\\/(\[a-zA-Z0-9-\]+\\.)?review\\.xunzhengyixue\\.com$/
\],
credentials: true
}
### **2\. ⚠️ 致命避坑SSO 单点登录与防越权 (IDOR 防护)**
由于我们采用底层复用 platform\_schema.users 的机制JWT Cookie 会在主域下共享。这带来极大的越权风险。
* **SSO 签发**:登录时,后端校验用户身份后,必须将当前所在的 tenantId 压入 JWT Payload。
* **双重校验防线 (必须写进中间件 auth.middleware.ts)**
当一个请求打到 /api/v2/rvw/tasks 时,中间件必须校验两件事:
1. Token 解析出的 userId 是否有效。
2. **跨域校验**:该用户是否有权限访问当前 HTTP 请求 Header 中声明的 x-tenant-id或者通过请求域名解析出的 tenant。如果张医生是 iit 的用户,但拿着 Token 试图请求 jtim 租户的数据,**必须直接拦截并返回 403 Forbidden**。
* **ORM 级隔离**:所有针对 RVW 模块的 Prisma 查询,必须强制附带 where: { tenantId: request.tenantId },严禁“裸查”。
## **🔮 五、 未来架构扩展预警:第三方登录限制**
**【风险说明】**
如果您未来计划在 jtim.review... 等域名上引入“微信扫码登录”或微信支付。微信开放平台的安全机制通常要求配置**绝对精准的回调域名**,往往不支持通配符(\*.review...),且回调域名数量有严格上限。
**【架构预案:统一认证网关】**
为了应对该问题,平台在未来演进时,应规划统一的 Auth Center
1. 任何期刊页面点击“微信登录”,统一 302 Redirect 跳转至主站网关(如 login.xunzhengyixue.com/wechat
2. 主站统一处理微信回调,生成系统内部的 JWT SSO Token。
3. 主站将 Token 携带在参数或安全 Cookie 中,再次 302 Redirect 飞回 jtim.review.xunzhengyixue.com/callback完成身份同步。
(目前 MVP 阶段账号密码登录不受此限制,仅作未来规划记录)。

View File

@@ -0,0 +1,141 @@
# **智能审稿系统 (期刊SaaS版) V4.0 产品需求文档 (PRD)**
**文档版本:** V1.6 (新增统一通行证 SSO 与动态租户登录架构)
**制定日期:** 2026-03-14
**产品名称:** RVW 智能审稿系统 (Journal AI Review SaaS)
**目标用户:** 医学期刊编辑部(主编/责编)、期刊外审专家、投稿作者
**核心定位:** 为医学期刊量身定制的、支持高度个性化配置的独立 AI 预审稿 SaaS 平台。
**💡 MVP 阶段战略提示:** 为降低初期开发成本并把控质量,系统初期采用 **Managed SaaS代运营/配置)模式**。复杂的底层规则配置由内部运营/实施团队通过现有的 ADMIN 管理端完成。外部期刊客户只需“开箱即用”业务端。
## **1\. 产品背景与目标**
### **1.1 演进背景**
当前 RVW 模块V3.0.1)具备了强大的 4 维审查能力。但与临床科研人员共用平台存在品牌混杂、审查标准缺乏弹性、管理权限缺失等痛点。不同期刊(如英文 SCI 期刊 JTIM vs 中文核心期刊)的稿约规范、重点审查维度截然不同,系统亟需多租户的个性化配置能力。
### **1.2 产品目标**
1. **SaaS 化业务端升级**引入“租户Tenant”隔离每个期刊作为一个独立租户拥有独立的子域名、独立登录页、独立品牌标识的业务端工作台。
2. **建立完整的期刊工作流**:涵盖“作者投递预审 \-\> 责编复核 \-\> 勾选重组 \-\> 导出内部报告/发送作者退修信”。
3. **架构收敛的内部配置中枢 (核心)****不另起炉灶开发独立配置中心**,而是深度扩展现有的 ADMIN 运营管理端。利用现有的租户管理体系Tenant实现跨期刊的审稿流水线无缝配置。
4. **统一通行证 (SSO)**:期刊用户与现有临床研究平台用户底层互通,实现跨域无缝漫游,沉淀统一的医生/专家账号体系。
## **2\. 角色与权限体系**
| 角色类型 | 典型用户 | 核心权限清单 |
| :---- | :---- | :---- |
| **内部实施/超管** | 壹证循运营团队 | **MVP 阶段的核心主导者**。拥有 ops:user-ops 及 SUPER\_ADMIN 权限。在现有的 ADMIN 端 TenantDetailPage 中,为不同期刊租户编排 Skills 规则、配置动态 Prompt 及 Handlebars 模板。 |
| **期刊主编/管理员** | 期刊编辑部主任 | 纯业务视角。查看本刊数据仪表盘、管理责编账号、查阅本刊所有稿件审查进度。**MVP 阶段无需登录底层 ADMIN 端**。 |
| **期刊责编** | 期刊审稿编辑 | 登录期刊专属 URL接收稿件、触发 AI 审查、人工复核确认缺陷项、导出内部报告与给作者的退修信。 |
## **3\. 核心功能需求 (Functional Requirements)**
### **3.1 期刊专属门户与统一通行证 (Business Portal & SSO)**
交付给外部期刊责编的核心业务模块。该模块在底层与临床研究主站共用同一套用户体系platform\_schema.users实现“统一通行证”体验在表层依靠以下机制实现业务硬隔离
* **子域名路由区分 (Domain Routing)**:支持按拼音或缩写生成独立 URLjtim.xunzhengyixue.com。前端应用初始化时通过解析 window.location.hostname 自动切入“期刊工作台模式”。
* **动态定制化登录页 (Dynamic Login UI)**:用户访问子域名时,前端在未登录态下请求该租户的公开配置(公开接口)。据此渲染完全定制化的期刊专属登录页(独立的期刊 Logo、专属品牌色、独立背景图在视觉上打造完全独立的系统心智。
* **沉浸式 4-Tab 审稿详情页**:优先渲染大模型生成的 expert\_report\_markdown (由内部团队在配置中心编写的 Handlebars 模板渲染而成)。
* **退修信自动生成与双轨导出**:基于责编在 AI 找出的“缺陷列表”中的勾选确认项,一键生成语气委婉专业、条理清晰的 Letter to Authors。
### **3.2 租户审查配置扩展 (ADMIN 模块深度集成) ——**
![][image1]**定位说明**直接在现有的运营管理端ADMIN的**租户详情页 (TenantDetailPage)** 中进行能力扩展,实现“千刊千面”。
#### **3.2.1 UI 集成方案:新增“智能审稿配置” Tab**
在现有的 TenantDetailPage目前包含“基础信息”、“模块配置”、“配额管理”等新增一个名为 **【智能审稿配置 (RVW Skills)】** 的 Tab。内部人员在此进行以下 4 大 Skills 的个性化编排:
1. **稿约规范评估 (Editorial Skill) 配置**
* **提取规则维护**列表化维护该期刊的特有规则AI署名检测、摘要字数限制
* **一票否决 (Fatal) 设定**:勾选致命错误项,触发时直接高亮拦截。
2. **方法学评估 (Methodology Skill) 动静分离配置**
* **评判标准配置 (Business Criteria Prompt)**:自然语言文本框,交代该期刊的特殊审查要求(如:“必须提供 P 值的精确值”)。
* **报告展示模板 (Handlebars Template)**:利用 ADMIN 现有的模板渲染引擎,配置该期刊最终审查报告的排版样式(如 1、2、3 点列表,或特定专家语气)。
3. **数据验证 (Data Forensics Skill) 配置**
* 验证深度下拉选项L1 算术验证 / L2 统计学验证 / L3 双通道核查)。
4. **临床专业评估 (Clinical Skill) 配置**
* FINER 权重配置5 个输入框控制总和为 100% 的打分权重)。
* 专科特色补充文本。
## **4\. 技术架构影响与底层升级指南**
此次升级完美契合平台现有的 Postgres-Only 架构与 ADMIN 模块基建。
### **4.1 数据模型扩展 (Database Schema)**
在 platform\_schema 中保持现有的 tenants 表作为核心,新增 1 对 1 扩展表:
* **新增表 tenant\_rvw\_configs**
* tenant\_id (外键,关联 tenants.id)
* editorial\_rules (JSONB: 存储稿约规则数组及 fatal 标记)
* methodology\_expert\_prompt (Text: 专家的业务评判标准)
* methodology\_handlebars\_template (Text: 报告渲染模板)
* data\_forensics\_level (Enum: L1/L2/L3)
* finer\_weights (JSONB: 临床评估的五维权重)
### **4.2 Prompt “动静分离” 与双轨输出落地**
针对 PromptService 进行架构升级,彻底解决 JSON 崩溃问题:
* **静态协议层 (系统级,不可更改)**
* 在 capability\_schema.prompt\_templates 表中,固化一条名为 RVW\_METHODOLOGY\_SYSTEM\_BASE 的记录。
* 内容写死:强制开启 Structured Output (或 JSON Schema),必须返回 system\_metrics (含 20 项检查点状态) 和 expert\_report\_markdown 两个节点。
* **动态业务层 (租户级,内部运营可调)**
* 内容来源于新增的 tenant\_rvw\_configs 表的 methodology\_expert\_prompt 字段。
* **运行时拼装 (Runtime Concat)**
* SkillExecutor 执行时,先通过 promptService.get('RVW\_METHODOLOGY\_SYSTEM\_BASE') 获取底层系统约束。
* 再拉取当前 tenantId 的动态业务评判标准。
* **内存拼接后发送给 LLM**。大模型会根据专家的排版要求,将纯净的格式化文本填充至 expert\_report\_markdown 字段中。
* **展示层渲染 (Handlebars)**
* 结合现有的 Handlebars 渲染器与租户存储的 methodology\_handlebars\_template在前端/导出时直接渲染,实现“专家零感知 JSON”。
### **4.3 接口权限中间件复用**
* **内部配置权限**:复用现有的 ops:user-ops 与 requireModule 中间件,控制内部人员进入 TenantDetailPage 修改审稿配置。
* **外部业务权限**:所有 RVW 业务 API (/api/v2/rvw/\*) 复用租户边界校验,确保稿件数据硬隔离。
### **4.4 统一账号与鉴权体系 (SSO)**
为了实现医生专家在“临床科研平台”与“各期刊 SaaS 门户”之间的无缝漫游,架构需遵守以下设计:
* **绝对底层复用**:禁止为期刊 SaaS 业务新建独立的用户库,全面复用现有的 platform\_schema.users。同一个账号密码既可登录主站做数据清洗DC也可登录 jtim. 子域名进行审稿。
* **JWT 会话隔离 (Session Isolation)**:身份认证层 auth.service.ts 需升级。在签发 JWT 时后端必须根据登录请求携带的租户上下文tenantId将其压入 Payload。系统的 auth.middleware.ts 提取该 tenantId 实现全局请求的行级数据隔离。
* **模块动态过滤 (Module Filtering)**:依托平台现有的 user\_modules 体系,当识别到用户登录在某期刊租户(如 JTIM由于该租户仅订阅了 RVW 模块,系统会自动隐藏左侧/顶部的 AIA、DC、PKB 等所有无关入口,为编辑提供纯粹的沉浸式工作台。
## **5\. 迭代演进路线图 (Roadmap)**
通过复用现有的 ADMIN 模块Phase 2 的前端开发工作量将缩减 50% 以上。
### **Phase 1: 统一账号隔离与扩展表 (预计 1 周)**
* 创建 Prisma 模型 tenant\_rvw\_configs 并执行迁移。
* 开发基于域名的动态路由拦截器,实现子域名加载特定的登录 UI。
* 升级 JWT 签发逻辑,确保 Token Payload 中正确注入租户上下文。
### **Phase 2: ADMIN 运营端深度集成 (预计 1.5 周)**
* **UI 扩展**:在 frontend-v2 的 TenantDetailPage.tsx 中新增 【智能审稿配置】Tab 页及对应的表单组件。
* **后端 API**:新增 /api/admin/tenants/:id/rvw-config 的 CRUD 接口。
* **引擎改造**:重构 SkillExecutor读取扩展表配置并落实 Prompt 的“静态系统层 \+ 动态租户层”拼装逻辑Hybrid Schema
### **Phase 3: 面向外部的业务端重构及双轨导出 (预计 2 周)**
* 沉淀通用英文审稿规范(如 ICJME/COPE 标准)至底层预制规则库。
* 上线 **作者退修信 (Author Reply Letter)** 一键生成功能。
* 全面跑通独立工作台上的全流程,交付给首批期刊客户(如 JTIM, CMJ进行 Beta 测试。
## **6\. 验收标准**
1. **统一账号漫游**:用户在主站注册账号后,无需重新注册即可用相同账密登录专属的期刊入口(如 JTIM子域名
2. **架构收敛**:没有开发新的独立配置后台,所有配置均在现有的 ADMIN \-\> TenantDetailPage 中顺滑完成权限体系ops:user-ops复用良好。
3. **极速生效与不冲突**:内部实施人员在 TenantDetailPage 修改某期刊的 Handlebars 模板文本。点击保存后,即刻影响客户新上传稿件的最终报告显示排版,且底层的 JSON 解析 100% 成功,系统零崩溃。
4. **双轨导出功能**:外部责编能够在查阅完全部 AI 评估后,在弹出的窗口中勾选 3 个致命错误,点击“生成退修信”,系统随即利用专属信函模板生成一封精准的专业邮件/Word。
[image1]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAA+CAYAAACWTEfwAAAF50lEQVR4Xu3dT4gkVx0H8A6uoihGguuS3e6u7plV2EWiYUVy0IOQg5eQkCgSlmAOSSSgKAYS1IvBQ8jJ/DuIBMSDBP8QIl5zWHKJqCCeIgk5rAcvQnLJBhbJJr/fzHvD69fVu7Puznj5fKCm6v3q1avq25dXf2YyAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgOpvP5y8Nw/C1kfov+tp+bG9vz/ratZrNZp+Pazzb16u41tsXi8VvY73V1uO44237asT5XowxH+/rAACHLoLJ+32tKvtu6OtXEkHnj31tv+LYT0Xw+kac+608f12i9q/Y93DfP8W+O6LP+bZ2/PjxT0ft723tasSxF2Lcx/o6AMChu0Jguxgh6de1febMmQ+3+zeJY16JY//T13vL5fLLGcRyif6/jOVsCV8/i+Xmvv8mY4EtruE7Uf9KW7samwJb1P/a1wAADlQJbDfE+ie5vY/l5X6MVELXc7Ud22/EcrHt0zmSM2ljSxz3m1g/OFJ/O6+hH6gNbNPp9KZcR/tSd90ry+oI66LPhaEEx37Z3t7+TN8fAOC6itBxrg8wZfl97RMh6KlovxqbR5pDNyrH782KxfFbWWv77FfObLVjbTJy/XW5ddgwwxf1czl+X+8NG2bYAAAOVXnOay1U5QxSW5/NZl+dbH6W7UP5pwSllZCV47ftMRGKHjp27NjHu9paYIv2PyblXL3Y9+7Q3BKN7Rfy5YdYP7BcLm/p+o4Gtv4axgJb1L7btgEADlwEkOdL0Hq2qd0zNM9p5XNm2ae2W0ePHv1E7PthbpdxNs6KLRaL321tbd3Y10s4W3n+bENgGx2/hs5Y3ovjHp1Opyfjmod6TJz3623/PFcsd7e1Uh/9ja3oc2GyObgCAFx/GUBKqPlRhJ3XT58+/ZFoX5zvPo+2E4LK+p7JyOxW1E/VlxJKv7VA1cjn5NZC0bUGtjj/T4fd59XO96Ew623fUjsf498xUl+7tt6wG9gAAA5HmR3beTA/27leLpdfiPWzua/U3stAVLZfyz7RvrOOEe1n6m3D3DeMBKpW7H/hxIkT07ZWwtnb8yu8dLBp/GE3YN4X6/PT6fRjQ3kpIn7LsXorN2femv55q3Pt7dEcv6/18ti+BgBwYDJsxXJ3DSqx/lO9lZgimN3fBZ0MdHv7S+1S9Lm97h8LVK0MUdHntbZWAtu1zLD9Zd68Jbq1tfW5XEf7XO1TxstZwqz/N9pn6r4qx+9rvUFgAwAOS75UkLdBc7sPKtE+lbWcrcp2fiaj9Fl5dmux+621vWM3BarGztum/flKmPqfZtjy2LJe+Q5b/rYMl/nduFyHs/W8uR57GaK/rjGDwAYAHJYIHn9rtveCSs5ORdi5t7bz9mUJSmtvR2a9zq7Vdh+oqhynuc36535/b2yGLcNX1E517Udyuw9ssX0uryd+y9PNeZ8v67XweRkr/fb74WAAgOuqBJgMPT+vs2rFzksCGeJifaHbt+ZygS3qZ2twamU4nJf/dNAu0f+dWP7d1XL8DGE/6MfpA9tk5Ntxy+Xylk2fMUkx7kfL5pH2xYUY+1d1O/p8sQ20AACHog8wJ0+e/GTWIqh8r60P5Y3RyYbZqRKo8r8lrPxXgHJL8lKM983+mE3GZtiqqN/a10YC26hh9zMma/9aKo5/vW2X31m3nxua/9gQfR/N/WbbAIBD04aTxWJxZwSS77f7q3ILMoPc3oxTa7jyv7bauX1ZTafTz867fz1Vl2HkGbZSfzPH6sPS5QJbzuzF73qyXse8eeEgZ9JqvT2mbZe3ad9vZwijfTGWP9Q2AMCBijDzSvn+2kqgGhN9vx3LXX39/y0DWyxP9PVW/L4fR5/TbW02m30p6v+cdN+Yy8+btO34zbd17W/FWPe1NQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADt4HSBrBPLIGp8wAAAAASUVORK5CYII=>

View File

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

View File

@@ -0,0 +1,73 @@
# **RVW V4.0 智能审稿 SaaS核心技术难点与稳定性防范指南**
**文档受众:** 后端研发、前端研发、AI 算法工程师
**文档目的:** 针对 RVW V4.0 (期刊SaaS版) 引入的“混合 Schema 双轨输出”与“SSO 统一通行证”架构,提前识别系统稳定性风险,并确立工程防范底线。
## **⚠️ 难点一Hybrid Schema 模式下的 JSON 转义风暴**
**【风险描述】**
我们在架构中设计了“混合 Schema”模式要求 LLM 在输出结构化 system\_metrics 的同时,在一个名为 expert\_report\_markdown 的 JSON 字段内输出几千字的自然语言长文本。
大模型在生成含有大量回车换行(\\n、双引号")、特殊反斜杠(\\)的医学长文本时,极易发生**转义字符遗漏**。几千字的文本中只要漏掉一个转义符,后端的 JSON.parse() 就会发生致命崩溃。
**【开发防范方案】**
1. **API 层强约束 (Structured Outputs)**
* 彻底摒弃在 Prompt 中软性要求 JSON。必须在调用 DeepSeek-V3 / GPT-4o 时,在 API 传参层强制开启 response\_format: { type: "json\_object" } 或直接传入严格的 JSON Schema。
2. **容错解析器 (Robust Parser)**
* 不要直接使用原生的 JSON.parse。建议引入容错率更高的解析库如 jsonrepair 或通过正则预处理),在解析失败时能自动修复常见的截断或转义错误。
3. **拥抱 Partial 容灾**
* 即使 JSON 彻底损坏,**决不允许让整个审稿流程4个 Skill 并行)全部挂掉**。必须复用 V3.0 中已成熟的 Promise.allSettled 和 partial\_completed 机制,将解析失败的 Skill 优雅降级并记录至 error\_details。
## **⚠️ 难点二Prompt 与 Handlebars 模板的隐性强耦合**
**【风险描述】**
内部实施人员在 ADMIN 端配置时,需要手写“评判标准 (Prompt)”和“展示模板 (Handlebars)”。
* **问题 A (变量错位)**Prompt 要求大模型输出 5 个字段,但 Handlebars 模板里写了接收 6 个字段的占位符(如 {{missing\_field}})。
* **问题 B (AI 幻觉断层)**Handlebars 模板严格依赖循环 {{\#each checkpoints}},期望渲染 20 项。但由于大模型“注意力稀释”,只输出了 18 项,导致生成的报告在视觉排版上出现错位或断层。
**【开发防范方案】**
1. **数据 Schema 校验防线 (Zod)**
* 在 LLM 返回数据交由 Handlebars 渲染之前,必须经过 Zod 进行 Schema 强校验。对于缺失的非致命字段给予默认值Fallback value填充。
2. **防御性模板渲染 (Defensive Templating)**
* 在向运营人员开放的 Handlebars 模板规范中,强制推行防御性语法。例如,大量使用 {{\#if field}} ... {{else}} ... {{/if}},避免因字段缺失导致渲染报错或出现大片空白。
## **⚠️ 难点三:长上下文与重度推理导致的超时灾难 (Timeout)**
**【风险描述】**
审稿业务是典型的“长上下文(数十页医学 PDF可能超 2 万 Token+ 复杂推理20项方法学规则”场景。
结合现有的云原生架构(阿里云 SAE 部署Serverless 网关通常存在 60 秒的请求超时限制。LLM 在处理超长文献和生成千字报告时,单次请求耗时极易突破 60\~120 秒,导致前端直接报 504 Gateway Timeout。
**【开发防范方案】**
1. **异步队列为王 (Platform-Only 架构复用)**
* 严禁在 HTTP 请求的生命周期内同步等待大模型返回。必须将审稿任务投递至平台层的 pg-boss 队列 (platform\_schema.job)。
2. **状态轮询与 SSE 进度流**
* 前端通过发起 GET /api/v2/rvw/tasks/:id/status 进行轮询,或直接接入 SSE (Server-Sent Events) 接收后台 reviewWorker 实时推流的执行进度。
3. **Skill 内部并发 (Map-Reduce)**
* 针对最耗时的 MethodologySkill后端可考虑将其拆分为“科研设计”、“统计学描述”、“结果评估”三个子请求向 LLM 发起内部并发,并在汇总后一起喂给 Handlebars 渲染以空间Token 成本)换取时间。
## **⚠️ 难点四SSO 统一账号下的多租户越权风险**
**【风险描述】**
为了实现医生与专家在“主站”与“期刊 SaaS 子域名”间的无缝漫游,我们采取了“统一底层 users 表”的架构。
这种架构最大的安全隐患是**数据越权Data Leakage**。如果 API 接口没有严格限制上下文A 期刊JTIM的责编可能会通过修改请求参数查看到 B 期刊CMJ的待审稿件库。
**【开发防范方案】**
1. **JWT 强制注入上下文**
* 登录签发 Token 时,必须将当前的 tenantId 压入 JWT Payload。
2. **Prisma 拦截器 / RLS (Row-Level Security)**
* 在后端的 auth.middleware.ts 中提取 tenantId 后,**所有**涉及 RVW 模块的 Prisma 查询findMany, update, delete 等),必须在 where 子句中强制挂载 tenantId: request.tenantId 的条件。
* 建议在开发规范中明确:禁止任何跳过 tenantId 校验的业务端裸查(除非是带有 ops:user-ops 权限的内部实施端操作)。
3. **路由鉴权守护**
* 依赖现有的 requireModule 中间件,严格确保只有订阅了 RVW 模块的租户用户,才能访问对应的审稿数据。

View File

@@ -0,0 +1,86 @@
# **通用英文医学期刊稿约审查标准 (Universal Submission Guidelines)**
**文档说明:** \> 本文档基于国际医学期刊编辑委员会ICMJE规范及主流 SCI/英文医学期刊的共性要求提炼。适用于英文医学论文在投稿前的全面自查及预审工作。
**核心逻辑:** 英文期刊审查采取\*\*“伦理一票否决制”**与**“体例严格限制制”\*\*。
## **🛑 第一部分:一票否决项(致命拒稿点)**
发生以下任何一种情况稿件通常会被编辑部直接拒稿Desk Rejection甚至可能引发学术不端调查
* \[ \] **AI 署名违规**:作者名单中绝对不能包含 ChatGPT、LLM、AI 或任何人工智能工具的名称。AI 无法对研究负责,不具备署名资格。
* \[ \] **盲审违规(若期刊要求双盲)**正文Main Text / Blinded Article中绝对不能出现作者姓名、机构名称、致谢以及能够明显暴露作者身份的资助编号或伦理批号。此类信息必须分离至首页Title Page
* \[ \] **一稿多投 / 重复发表声明缺失**:必须明确声明该稿件未在其他地方发表,也未同时投递给其他期刊。
* \[ \] **伦理与知情同意缺失**涉及人类或动物的实验未提供伦理委员会IRB/IACUC批准说明涉及人类受试者含病例报告未提供知情同意书Informed Consent获取声明。
## **📝 第二部分:结构化预审清单 (Submission Checklist)**
### **1\. 首页与元数据 (Title Page & Metadata)**
英文期刊通常要求将作者信息与正文分离,以满足盲审需求。
* \[ \] **文章标题 (Title)**:简明扼要,注意期刊要求的是 Title case实词首字母大写还是 Sentence case仅句首首字母大写
* \[ \] **短标题 (Running Head/Short Title)**:通常限制在 **50\~75个字符** 以内(含空格)。
* \[ \] **作者信息 (Authorship)**
* 必须明确标注 **通讯作者 (Corresponding author)**(提供完整机构地址、邮编及有效邮箱)。
* 强烈建议/强制提供主要作者的 **ORCID**
* 检查作者总数限制部分期刊对病例报告或综述有严格的作者人数上限如不超过4人
* \[ \] **统计声明 (Counts)**:必须在首页明确列出:总字数(分摘要和正文)、表格数、图片数。
### **2\. 声明与合规模块 (Declarations & Compliance) —— 【英文核心考点】**
所有英文医学期刊均要求在正文末尾或独立填写包含以下内容的声明章节:
* \[ \] **利益冲突声明 (Conflict of Interest)**:必须提供。即使没有利益冲突,也必须明确写出 "The authors declare no conflict of interest." 或 "None declared."。
* \[ \] **资金资助声明 (Funding/Support)**:写明资助机构全称及项目批准号。若无资助,也需明确声明。
* \[ \] **作者贡献声明 (Author Contributions)**:清晰列出每位作者的具体贡献(如:概念设计、数据采集、统计分析、论文撰写等)。
* \[ \] **AI 辅助声明 (AI Statement)**:如果在数据分析、大纲生成或语言润色中使用了 AI 工具,必须声明使用了何种工具及具体用途。
* \[ \] **数据可用性声明 (Data Availability)**:说明支撑研究结果的数据在哪里可以获取(如:公开数据库、按需向通讯作者索取等)。
* \[ \] **临床试验注册 (Trial Registration)**:若为临床干预试验,必须提供 WHO 认可的注册机构名称及注册号(如 UTN, NCT number
### **3\. 摘要与关键词 (Abstract & Keywords)**
* \[ \] **摘要格式匹配**
* **原创论著 (Original Research)**:强制要求 **结构化摘要 (Structured Abstract)**。必须包含如 Background/Objectives, Methods, Results, Conclusions 的明确小标题。字数通常严格限制在 **200 \- 250 words**
* **综述/病例报告 (Review/Case Report)**:通常为 **非结构化摘要 (Unstructured)**。字数限制在 **150 \- 200 words**
* \[ \] **关键词 (Keywords)**:通常提供 **3-6个****3-8个** 关键词。多用分号 (;) 隔开。必须与 MeSH 词表尽量对齐。
### **4\. 正文结构要求 (Main Text Structure)**
* \[ \] **IMRAD 结构检查(针对论著)**
* 标准章节结构必须为Introduction (引言), Methods/Materials and Methods (方法), Results (结果), Discussion (讨论)。部分期刊可能要求 Conclusions (结论) 单列。
* \[ \] **方法学 (Methods) 细节**
* 必须详细说明所用的**统计学软件**(含版本和公司名称位置,如 SPSS version 26.0, IBM, NY, USA
* P 值的报告需精确(如 ![][image1]),而非简单写 ![][image2]。
* \[ \] **引言 (Introduction) 限制**:部分期刊严格限制引言的篇幅(如仅需交代研究背景、目的和假设,不可长篇大论)。
### **5\. 图表规范 (Figures & Tables)**
* \[ \] **编号与引用**:使用 **阿拉伯数字** 连续编号(如 Table 1, Figure 1严禁使用罗马数字Table I。所有图表必须在正文中被按顺序交叉引用Cross-referenced
* \[ \] **格式与独立性**
* 表格必须是可编辑的文本格式(如 Word 表格),**严禁插入图片格式的表格**。
* 采用标准的**三线表**(去除垂直线,仅保留必要的水平线)。
* 图表必须具备“自明性Self-explanatory即读者不看正文也能看懂图表含义。缩写词必须在脚注Footnotes中展开。
* \[ \] **图例 (Figure Legends)**:不能直接放在图片上,需集中放在文章末尾或图片下方。
### **6\. 参考文献 (References)**
* \[ \] **引文格式**明确期刊要求的是温哥华格式Vancouver style多为数字上标/方括号还是哈佛格式Harvard style作者-年份)。
* \[ \] **数量与时效限制**
* 原创论著:通常 30 \- 50 篇。
* 综述:通常 50 \- 100 篇不等。
* 病例报告/短篇:通常 10 \- 15 篇以内。
* \[ \] **作者缩写规则**:仔细核对期刊对于多作者列举的规定(例如:列出前 3 位或前 6 位,之后的用 et al.)。
### **7\. 学术语言与排版细节 (Language & Style)**
* \[ \] **英文一致性**:全篇必须统一使用 **美式英语 (American English)****英式英语 (British English)**,不可混用。
* \[ \] **斜体字使用 (Italics)****属种名称**(如 *Escherichia coli*,第二次提及时缩写为 *E. coli*)、基因符号、非英语的外来拉丁词汇必须使用斜体。
* \[ \] **国际单位制 (SI Units)**:所有度量衡必须使用标准的国际单位。数字与单位之间通常需要留一个空格(如 5 mg而不是 5mg但摄氏度°C和百分号%)各期刊要求可能不同。
* \[ \] **数字拼写规则**
* 句首的数字必须拼写成英文单词(如 "Fifteen patients...",严禁 "15 patients...")。
* 一般情况下1\~10 的数字用英文单词拼写one to ten11及以上的数字使用阿拉伯数字。带有度量衡单位的数字直接用阿拉伯数字如 3 kg
[image1]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFUAAAAYCAYAAACLM7HoAAADN0lEQVR4Xu1YPWhUQRC+oILiH6Ln4f28ffdDjqhocWohgo2NRWwsIsRCbLRSEESIjWgfxFJiIWJjZWNlimDAInY2gmJhSM4iaLDIQZR4fvPe7DE3t++SFJc7cD8Y7u03szOz8/bvXSrl4eHh4eHh8R/DGPMwCIJ5/DZZ6tRm7jdzi7rfVqFQKJwSuTUqlco+bZME2N+3fcMwnAM1pG0k8vn8LrLVPAH9Xwlf77TeCdtB8zYQ5IvW9RqIeZlziopRKpX2UzuXy+WVaQdgN42J8Vm0ra9EQL/ksgG3jEKGtg2/F8CtCZNOpNPpPVy4Ga0jsK4jWK9BMTGWG4qbgSxITqNcLh+mvtls9pDk2d8DyVmAvwn5qMeJ9ghkVnLML1BxNd8CDC6SMyy1S1qHjgf6UVQeTBPxa4qf4FwSlzL0T1z5UiEgK5qv1Wo7wH9FrHu6H7hRVzzyBd05ybUBBtPUkWasQ/eIdPQmta6XQMw7FLdYLJ6UvB14ty2ACqSLw/y3BL5OhXUVFfEzxJFAf5Q5NNdZ/raTg49mC+SF1vUaeInPOfYRyduBd5sl0K8kjMcWdbvlEGcMvq7Ss6uoBHBPOReSBvlPdVkpqUwms1t00NLAjDio+7gA20kTB9+ojGsfEhso6qjkJcw6RbUrkpd93eqTikoA/5bziQT5ndc2LdA+ykZtB0K/sRVFxfMnut1YfUJRhziPs9RAXnPcpq3pjLKNYeLTtOOk7DdE8UoJfNsBJmGL142nyaTPCVdRuT5TksPVbpgL+0PyLbCyI4HNwmx++V/XPiQw6NOUly6e4dOfti3JS0D/kmwcPJ3+EY/fyYA/cqyAW+OY83S9YrsmZKTdUzRjr7hipKrV6l7u9EHrBgDRstPbkolvKn8lhyLcNmKbwAs5Tn316uOxvpGchOFVq7g/geM+yi/9u+ap2tdciQ8KwvjTcElQUaHlfZpmMhdLF2MVuse2DV8h2biujRbQz3I9dloOz2PsW99Tl+H/mCSmbCJS6DNQ9BsIIK/3Jl6WzzjPuw6bXxjgLUVvM/F/F4vQvebxDSubCGKflttA6yOBfHPsnyx0SJ2QPjw8PDw8PAYa/wDkFmFg1EgcgQAAAABJRU5ErkJggg==>
[image2]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAAAYCAYAAACyVACzAAAC+UlEQVR4Xu1XO4gUQRDd5TQQf4juLbe/mf3g4gcNVjMDUxE1MDEUDDRSEFRQA9FITA4jEUVETIxMTOSCRUHBSwVBuUC5Mzgjgzs4Qc/3dquX2poZ93tn0g+a3X716aqa7uqZVMrDw8PDw8NDIQiC26VS6Rt+V2V851y4X8ItWLv1QrFYPKRiW67VatusThKgf8PZhmH4AVRay5Fjg7lBdjSTyWzB/ylwD/Db1HoROKeWLxQKm0T2xcrWGljztMTUSrJSqWznPJ/PF4xqBNCbQeKf1dz56kCK1cpbjR9aJwKpKhWbVkY4R5YfFghyb8o85ThwTTz184ZrYsxrzqJarU7SNpfL7dK8+Lvl5iwW5q/B38c412g0Nir1eEDxGB1hy5+0MjjcMa5iIbAzEvApK7OA3h7qMiHDX5dYEostyUfiBTePseTmUqynWqcn4GCGzrnDYmR3JMELVtYvYHuXPsrl8gErSwL0L4vNQc0jwWvk/3UUIZ9LKNZXzQ9brNUE562ni/HMyvoBgnkC298YZSvrBSYha09p3hULv0c0rwH5UkI+rlgbOJeeNcsY8f+qyJN7Vjab3SxBxY1lPMGd1qYH0rB7T9tBbi6LPop1QvMaQY9iuRMkO+uN0VnBmNNcB+xTdGAb6RCYgJ9PDIg3qBUOivUoVhwgf04dHP+slVHYpNDeHEPAFWtxHMVSRakk8F2NX8MVpQ9+IhV990p+GBTEOR4B6bB9HY90DLHjD8cVJZDbkO1D8xqB7I4Ynreh49kuIrlj/ii2WPV6fasYzHYJxoTSCA0+JcnY9hC0b+4/msM6lwJ1XFHo/bS1p0VyfWXmH43OW/KR04FAzsYFNG5gjZtcZ5BXBwJxvQi6b6dWAfX7IHeeJG13yApk024OXyF1dL/iCYDOPjfnS6n4eui4zlazg58THaU1QCgvpRjHrSwJ0H0XtHfnY7G9EqPzE0lfNDR7KL9tFyB7KfntNjq0ZZ+l/0Xxf8/q/FeU+vzc8fDw8PAYDH8Bhm8pp1+h1UAAAAAASUVORK5CYII=>

View File

@@ -0,0 +1,994 @@
# RVW V4.0 智能审稿 SaaS 开发计划
> **文档版本:** v1.1
> **制定日期:** 2026-03-14
> **最后更新:** 2026-03-14架构审查报告修正 × 6
> **产品版本:** RVW V4.0 (Journal AI Review SaaS)
> **当前基线:** RVW V3.0.2(方法学分治并行 + 20检查点 + 展示收敛)
> **目标状态:** 多租户期刊 SaaS 平台支持路径路由隔离、SSO统一账号、ADMIN配置中枢、Handlebars报告渲染、退修信 SSE 流式生成
>
> **⚠️ v1.1 架构前提变更(来自架构审查报告):**
> MVP 阶段放弃子域名方案(个人证书不支持泛域名),改用 **URL 路径隔离**`review.xunzhengyixue.com/jtim`。10 本期刊以内路径方案完全够用,超过 10 本后再迁移子域名架构。
---
## 一、升级背景与核心目标
### 1.1 为什么要升级
当前 V3.0.2 是面向"内部临床研究人员"的单租户审稿工具,已具备强大的 4 维审查能力(稿约规范性 / 方法学 / 数据验证 / 临床评估)。
**痛点:**
1. **品牌混杂**不同期刊编辑JTIM、CMJ等登录的是同一套通用界面无法体现期刊个性
2. **标准无弹性**:每个期刊有自己独特的稿约要求,但系统只有一套固定规则
3. **缺乏完整工作流**:没有"人工复核确认 → 勾选缺陷 → 导出退修信"的编辑侧工作闭环
### 1.2 核心目标PRD 定义的 4 个目标)
| 目标 | 说明 |
|------|------|
| **多租户隔离** | 每个期刊作为独立租户,拥有独立子域名(`jtim.review.xunzhengyixue.com`)、独立品牌登录页、数据硬隔离 |
| **完整期刊工作流** | 作者投递预审 → 责编AI审查 → 人工复核勾选 → 导出内部报告/退修信 |
| **ADMIN 配置中枢** | 不另建后台,直接扩展现有 ADMIN 端的 TenantDetailPage实现"千刊千面"配置 |
| **SSO 统一通行证** | 底层复用 `platform_schema.users`,同一账号可在主站与各期刊子域名间无缝漫游 |
### 1.3 MVP 策略
**Managed SaaS代运营/配置)模式**:复杂配置由内部运营团队通过 ADMIN 端完成,期刊客户只需"开箱即用"业务端。这意味着 Phase 1-2 是内部工具建设Phase 3 才是对外交付。
---
## 二、整体架构变更一览
```
V3.0.2 现状 (单租户) V4.0 目标 (多租户 SaaS) — 路径隔离方案
──────────────────── ──────────────────────────────────────
单一登录页 review.xunzhengyixue.com/jtim (JTIM 专属)
↓ review.xunzhengyixue.com/cmj (CMJ 专属)
单一 ReviewTask 表 review.xunzhengyixue.com/... (更多期刊)
↓ ↓
固定 Skills 配置 前端路由提取 :tenantId → x-tenant-id Header
↓ ↓
固定 Prompt 内容 JWT Payload 注入 tenantId + 中间件双重校验
↓ ↓
固定 Word 导出格式 tenant_rvw_configs 表 (per-tenant 配置)
SkillExecutor 按租户动态装配
├── 静态系统协议 (RVW_xxx_SYSTEM_BASE)
└── 动态业务层 (tenant_rvw_configs)
Handlebars 渲染 expert_report_markdown
ADMIN 端支持模板实时预览)
双轨导出: 内部审稿报告 + 作者退修信 (SSE 流式)
```
---
## 三、三阶段里程碑
```
Phase 1 (1周) Phase 2 (1.5周) Phase 3 (2周)
多租户数据基础 ADMIN配置中枢 期刊业务端重构
────────────── ───────────────── ──────────────────────
DB Schema 扩展 → TenantDetailPage扩展 → 动态品牌登录页
JWT 注入 tenantId RVW Config CRUD API 稿件管理池 Dashboard
子域名路由检测 Skills 引擎按租户装配 沉浸式 4-Tab 审稿详情
CORS 动态白名单 Handlebars 渲染引擎 人工复核 (HitL) 确认
DevOps SSL证书 Zod Schema 校验 退修信一键生成
Prompt 动静分离落地 双轨导出 (报告+退修信)
```
**总工期预估4~4.5 周**
---
## 四、Phase 1多租户数据隔离与账号体系预计 1 周)
### 4.1 数据库改造
> ⚠️ **【审查报告修正 P0】历史数据迁移两步走**(来自架构审查报告第 1 条):
> 生产环境中已存在大量 V3.0 历史审稿任务。直接添加 `tenantId` 非空外键约束Prisma Migrate 会因历史数据为空而报错中断。
> **必须严格按以下两步执行:**
> 1. 先在 `platform_schema.tenants` 中初始化"默认临床主站租户"(如 `tenant-default-yanjiu`
> 2. 在 Prisma migration SQL 中,先 `ALTER TABLE ADD COLUMN tenant_id TEXT NULL`,再 `UPDATE review_tasks SET tenant_id = 'tenant-default-yanjiu' WHERE tenant_id IS NULL`,最后 `ALTER TABLE ALTER COLUMN tenant_id SET NOT NULL`
**任务 1.1:新增 `tenant_rvw_configs` 扩展表**
```sql
-- Prisma Schema 新增
model TenantRvwConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
-- 稿约规范评估配置
editorialRules Json? -- JSONB: 规则数组 + fatal 标记
-- 方法学评估配置
methodologyExpertPrompt String? -- Text: 专家的业务评判标准
methodologyHandlebarsTemplate String? -- Text: 报告渲染模板
-- 数据验证配置
dataForensicsLevel String @default("L2") -- L1/L2/L3
-- 临床评估配置
finerWeights Json? -- JSONB: {feasibility, innovation, ethics, relevance, novelty}
clinicalExpertPrompt String? -- Text: 临床评估补充要求
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@schema("platform_schema")
}
```
**执行方式:** `prisma migrate dev --name add_tenant_rvw_configs`
**任务 1.2`review_tasks` 表添加 `tenantId` 字段**
当前 `review_tasks` 表未关联租户。需要:
```sql
ALTER TABLE rvw_schema.review_tasks ADD COLUMN tenant_id TEXT REFERENCES platform_schema.tenants(id);
CREATE INDEX idx_review_tasks_tenant_id ON rvw_schema.review_tasks(tenant_id);
```
执行方式:`prisma migrate dev --name add_tenant_id_to_review_tasks`
---
### 4.2 SSO 鉴权体系升级
**任务 1.3JWT 身份信息(⚠️ 架构修正JWT 不含 tenantId**
> ⚠️ **【Sprint 审查修正 #1】JWT 中不应写入 tenantId**
> 若 JWT 中写死 `tenantId: 'jtim'`,由于主站与期刊门户共用同一个 `localStorage`,用户在新标签页打开 `review.../cmj` 时,系统会携带含 `jtim` 的 Token造成跨租户漫游验证失败。
>
> **正确的 SSO 统一通行证架构:**
> - JWT Payload 中**仅包含** `userId`(个人身份,不含业务 tenantId
> - 中间件(任务 1.4)完全通过 `x-tenant-id` Header 获取租户上下文
> - 后端实时查询 `tenant_members` 表核验 `userId` 是否属于该租户
> - 优势:同一个 Token 可以在多期刊间无缝漫游,零冲突
修改 `backend/src/modules/auth/auth.service.ts`
- JWT Payload 格式:`{ userId, role, iat, exp }`(移除 tenantId 字段)
- 登录接口本身不感知 tenantId登录是个人行为与租户无关
**任务 1.4:中间件双重校验防线**
修改 `backend/src/modules/auth/auth.middleware.ts`
> ⚠️ **【审查报告修正 P1-2】URL Slug → tenantId 映射**
> 前端传来的 `x-tenant-id: jtim` 是 `tenants.code` 字段(业务缩写),而 Prisma 查询需要 `tenants.id`。
> 查阅现有 Schema 确认:`tenants` 表已有 `code String @unique` 字段,`id` 为手动维护的字符串主键。
>
> **中间件处理逻辑(必须严格遵循):**
> 1. 从 Header 中提取 `tenantSlug = request.headers['x-tenant-id']`(即 `code` 值,如 `'jtim'`
> 2. 查询 `tenant = await prisma.tenants.findUnique({ where: { code: tenantSlug } })`
> **用 Redis 或内存 LRU 缓存此查询**TTL 5 分钟,避免每次请求都打 DB
> 3. 若查不到 → 返回 404租户不存在
> 4. 将 `tenant.id` 挂载到 `request.tenantId`,将完整 `tenant` 对象挂载到 `request.tenant`
> 5. **跨域校验**:校验该 `userId` 是否为 `tenant_members` 表中该租户的成员
> 若不是 → 拒绝 403 Forbidden内部运营人员持 `ops:user-ops` 权限除外)
**任务 1.5Prisma 全局查询守护**
在 RVW 模块所有 Prisma 查询中强制附带 `where: { tenantId: request.tenantId }`,杜绝"裸查"。
---
### 4.3 前端路径路由(已调整为路径隔离方案)
> ⚠️ **【审查报告修正 P2】tenantId 提取方式变更**(来自架构审查报告第 6 条):
> 路径方案下,后端无法从 `Host` 或 `Origin` 隐式推断租户,必须由前端显式携带 `x-tenant-id` Header。
**任务 1.6:前端 Tenant 识别逻辑**
修改 `frontend-v2/src/App.tsx`(或路由初始化):
```typescript
// MVP 路径隔离方案review.xunzhengyixue.com/:tenantSlug
const pathname = window.location.pathname;
const match = pathname.match(/^\/([a-zA-Z0-9-]+)/);
const tenantSlug = (match && match[1] !== 'api') ? match[1] : 'default';
// 存入 Zustand storetenantSlug 字段)
useTenantStore.setState({ tenantSlug });
```
> ⚠️ **【审查报告修正 P2-3】严禁使用 `axiosInstance.defaults.headers` 全局赋值**
> SPA 中 Axios 实例是全局单例,用 `defaults.headers` 写死后,用户从 `/jtim` 返回主站 `/` 或切换至 `/cmj` 时,旧 Header 不会自动清除,导致"在主站发出了携带 JTIM Header 的请求"状态污染。
> **必须使用请求拦截器动态读取:**
```typescript
// 在 axiosInstance 初始化时注册一次,此后永远动态读取当前租户
axiosInstance.interceptors.request.use(config => {
const { tenantSlug } = useTenantStore.getState();
if (tenantSlug && tenantSlug !== 'default') {
config.headers['x-tenant-id'] = tenantSlug;
}
return config;
});
```
**任务 1.6bZustand 租户 Store + 路由观察者 + RouteGuard 修正(✅ 已完成)**
> **解决的三个问题(对应 Sprint 审查 #3 React 状态陷阱):**
> 1. SPA 内部跳转(`<Link>` 导航)不刷新页面,一次性初始化代码不重新执行
> 2. Axios `defaults.headers` 全局写死导致租户状态污染
> 3. RouteGuard 将主平台业务路径(`/rvw`、`/ai-qa`)误判为租户 slug
**新增文件:**
`frontend-v2/src/framework/tenant/useTenantStore.ts` — Zustand 全局租户 store
```typescript
export const useTenantStore = create<TenantState>((set) => ({
tenantSlug: null,
setTenantSlug: (slug) => set({ tenantSlug: slug }),
}))
```
`frontend-v2/src/framework/tenant/useTenantObserver.ts` — 路由感知 hook使用 `useLocation()`
```typescript
// RESERVED_PATHS = 静态保留词 + MODULES 中所有注册路径首段(自动维护)
export function useTenantObserver() {
const location = useLocation()
const setTenantSlug = useTenantStore(s => s.setTenantSlug)
useEffect(() => {
setTenantSlug(extractTenantSlug(location.pathname))
}, [location.pathname, setTenantSlug])
}
```
**修改文件 `App.tsx`**
-`<BrowserRouter>` 内挂载 `<TenantBootstrap />`(调用 `useTenantObserver()`
- Axios 拦截器改为实时读取 store防止状态污染
```typescript
apiClient.interceptors.request.use((config) => {
const { tenantSlug } = useTenantStore.getState()
if (tenantSlug) config.headers['x-tenant-id'] = tenantSlug
return config
})
```
**修改文件 `RouteGuard.tsx`**
- 复用 `extractTenantSlug`(自动排除所有注册模块路径,防止 `/rvw` 误判)
- 未登录跳转对齐 App.tsx 现有路由 `/t/:tenantCode/login`
```typescript
// /jtim/dashboard 未登录 → /t/jtim/login?redirect=%2Fjtim%2Fdashboard
// /rvw/tasks 未登录 → /login行为不变
function buildLoginRedirect(pathname: string): string {
const tenantSlug = extractTenantSlug(pathname)
if (!tenantSlug) return '/login'
return `/t/${tenantSlug}/login?redirect=${encodeURIComponent(pathname)}`
}
```
**任务 1.6b:路由守卫租户感知重定向(✅ 已完成)**
> **问题根因**`RouteGuard.tsx` 原来写死 `<Navigate to="/login" />`,访问 `/jtim/*` 未登录时会跳到 `/login`,丢失 `jtim` 标识,登录页无法加载期刊品牌 UI。
已修改 `frontend-v2/src/framework/router/RouteGuard.tsx`
```typescript
// 新增两个辅助函数:
/** 不属于期刊租户的保留路径前缀 */
const RESERVED_PATHS = new Set(['login', 'admin', 'org', 'api', ''])
/** 提取第一段路径为 tenantSlug保留字返回 null */
function extractTenantSlug(pathname: string): string | null {
const firstSegment = pathname.split('/').filter(Boolean)[0] ?? ''
return RESERVED_PATHS.has(firstSegment) ? null : firstSegment
}
/** 构造携带租户标识的登录跳转目标 */
function buildLoginRedirect(pathname: string): string {
const tenantSlug = extractTenantSlug(pathname)
if (!tenantSlug) return '/login' // 主站保持原有行为
const redirectParam = encodeURIComponent(pathname)
return `/login?tenant=${tenantSlug}&redirect=${redirectParam}`
}
// 未登录跳转(原来):<Navigate to="/login" ... />
// 未登录跳转(现在):<Navigate to="/login?tenant=jtim&redirect=%2Fjtim%2Fdashboard" ... />
```
**效果**
- 访问 `/jtim/dashboard` 未登录 → 跳转 `/login?tenant=jtim&redirect=%2Fjtim%2Fdashboard`
- 访问 `/login``/admin` 等主站路径 → 仍跳转 `/login`(行为不变)
- 登录页可通过 `tenant` query param 加载 JTIM 品牌 UI登录后可通过 `redirect` param 跳回原页面
**任务 1.7:模块动态过滤**
利用现有 `moduleRegistry.ts`:若当前 `tenantId` 为期刊租户,隐藏 AIA/DC/PKB 等非 RVW 模块入口。
---
### 4.4 DevOps 配置(路径方案简化版)
> ⚠️ **【审查报告修正 P1】前端打包 base 路径 + Nginx SPA 回退**(来自架构审查报告第 3 条):
> 路径隔离方案下,若前端打包使用相对路径(`./`),访问 `/jtim` 时浏览器会错误请求 `/jtim/assets/...`,导致白屏。
> 且用户在 `/jtim/dashboard` 直接刷新时Nginx 找不到物理目录会报 404。
> **修正:** Vite `vite.config.ts` 中设置 `base: '/'`Nginx 必须配置 `try_files $uri $uri/ /index.html;`
**任务 1.8Nginx + Vite 配置(运维 + 前端)**
| 项 | 内容 | 负责人 |
|----|------|--------|
| **DNS** | 无需泛解析,沿用现有 `review.xunzhengyixue.com` A 记录即可 | 运维 |
| **SSL 证书** | 沿用现有 `*.xunzhengyixue.com` 证书,无需额外申购 | 运维 |
| **Nginx 配置** | 确保 SPA 路由回退:`try_files $uri $uri/ /index.html;` | 运维 |
| **Vite 打包** | `vite.config.ts``base: '/'`(绝对根路径,不可用相对路径)| 前端 |
| **CORS 白名单** | 后端 Fastify CORS 配置中增加 `review.xunzhengyixue.com` | 后端 |
---
### 4.5 Phase 1 验收标准
- [ ] `tenant_rvw_configs` 表成功创建并迁移
- [ ] 历史 `review_tasks``tenant_id` 已批量刷新为默认租户 ID无数据丢失
- [ ] 访问 `review.xunzhengyixue.com/jtim` 可正常加载前端(不白屏、不 404
- [ ]`/jtim/dashboard` 直接刷新页面正常加载Nginx SPA 回退生效)
- [ ] 使用主站账号登录JWT 中包含 `tenantId: 'jtim'`Axios 自动携带 `x-tenant-id` Header
- [ ] 用 JTIM 账号请求 CMJ 数据,后端返回 403
- [ ] 前端侧边栏在期刊租户下隐藏非 RVW 模块
---
## 五、Phase 2ADMIN 运营端配置中枢扩展(预计 1.5 周)
### 5.1 后端 API 新增
**任务 2.1RVW Config CRUD 接口**
新文件:`backend/src/modules/admin/rvw-config/`
- `rvwConfigController.ts`
- `rvwConfigService.ts`
- `rvwConfigRoutes.ts`
接口规范:
```
GET /api/admin/tenants/:id/rvw-config 获取租户审稿配置
PUT /api/admin/tenants/:id/rvw-config 更新UPSERT租户审稿配置
```
权限:复用现有 `ops:user-ops` 中间件,仅内部运营人员可访问。
---
### 5.2 前端 ADMIN 扩展
**任务 2.2TenantDetailPage 新增"智能审稿配置" Tab**
修改 `frontend-v2/src/modules/admin/pages/TenantDetailPage.tsx`
Tab 内包含 4 个子配置面板:
**Panel A — 稿约规范评估配置**
- 规则列表(可增删改):每条规则含 `code / description / fatal(是否一票否决)`
- 示例:`摘要字数 > 250` → fatal ✓
**Panel B — 方法学评估配置**
- 文本区业务评判标准方法学专家语言可自由编辑不需懂JSON
- 文本区Handlebars 报告展示模板(提供默认模板,可覆盖)
- 变量说明:`{{system_metrics.overall_score}}`, `{{#each system_metrics.checkpoints}}`
- > ⚠️ **【审查报告修正 P1】必须增加"测试渲染"预览**(来自架构审查报告第 5 条):
> 若无预览功能,运营人员写错 Handlebars 语法后需上传真实 PDF 等待 5 分钟才能发现报错,试错成本极高。
> **复用 ADMIN 现有的"测试渲染 (Test Render)"能力**:提供一份 Mock 好的 `system_metrics` JSON 假数据,点击"预览"按钮即可在右侧即时看到渲染效果,语法正确后再保存。
**Panel C — 数据验证配置**
- 下拉选择验证深度:`L1 算术验证` / `L2 统计验证` / `L3 双通道核查`
**Panel D — 临床评估配置**
- 5 个 FINER 权重输入框(总和校验 100%
- 文本区:专科特色补充要求
---
### 5.3 引擎改造(核心)
**任务 2.3SkillExecutor 按租户动态装配**
修改 `backend/src/modules/rvw/skills/core/executor.ts`(或 `reviewWorker.ts`
- 在执行每个 Skill 前,通过 `tenantId` 查询 `TenantRvwConfig`
- 将租户配置注入到 Skill 的执行上下文中
**任务 2.4Hybrid Prompt 拼装机制**
修改各 Skill 的 Prompt 获取逻辑:
```
最终发送给 LLM 的 Prompt =
[静态系统协议层] ← promptService.get('RVW_METHODOLOGY_SYSTEM_BASE')
+ [换行分隔]
+ [动态业务层] ← tenantRvwConfig.methodologyExpertPrompt
其中 静态系统协议层 强制包含:
- 必须以纯 JSON 输出json_object 模式)
- system_metrics 结构(含 20 项 checkpoints
- expert_report_markdown 字段由LLM按专家要求自由撰写
```
**任务 2.5LLM 调用升级 — Structured Outputs 强约束**
修改 `backend/src/common/llm/LLMFactory.ts`(或各 Skill 的 LLM 调用):
- 调用 DeepSeek-V3 / GPT-4o 时,传入 `response_format: { type: "json_object" }`
- 彻底摒弃仅靠 Prompt 文字要求 JSON 的"软性约束"
**任务 2.6Handlebars 渲染引擎**
新文件:`backend/src/modules/rvw/services/reportRenderer.ts`
```typescript
import Handlebars from 'handlebars';
import Zod from 'zod';
// Zod 校验 LLM 结构化输出
const methodologyOutputSchema = z.object({
system_metrics: z.object({
overall_score: z.number().min(0).max(100),
conclusion: z.enum(['直接接收', '小修', '大修', '拒稿']),
checkpoints: z.array(z.object({
id: z.number(),
item: z.string(),
status: z.enum(['pass', 'minor_issue', 'major_issue', 'not_mentioned']),
finding: z.string(),
suggestion: z.string().optional()
})).length(20)
}),
expert_report_markdown: z.string()
});
// Handlebars 渲染(使用租户模板或默认模板)
export function renderMethodologyReport(
data: MethodologyLLMOutput,
template?: string
): string {
const safeData = methodologyOutputSchema.parse(data); // Zod 兜底
const compiledTemplate = Handlebars.compile(template ?? DEFAULT_METHODOLOGY_TEMPLATE);
return compiledTemplate(safeData);
}
```
**任务 2.7:默认 Handlebars 模板(硬编码 + 可覆盖)**
为 4 个 Skill 各提供一套默认 Handlebars 模板(内嵌在代码中),租户可在 ADMIN 配置页覆盖:
```handlebars
{{! 默认方法学报告模板 }}
# 方法学评估报告
**综合评分:** {{system_metrics.overall_score}} **审稿结论:** {{system_metrics.conclusion}}
## 详细审查意见
{{expert_report_markdown}}
## 20 项检查点覆盖情况
{{#each system_metrics.checkpoints}}
- [{{#if (eq status "pass")}}{{else if (eq status "minor_issue")}}🟡{{else if (eq status "major_issue")}}🔴{{else}}{{/if}}] **{{id}}. {{item}}**{{finding}}
{{#if suggestion}}> 💡 建议:{{suggestion}}{{/if}}
{{/each}}
```
---
### 5.4 Phase 2 验收标准
- [ ] 内部运营人员在 `TenantDetailPage` 的"智能审稿配置"Tab 中,能为 JTIM 保存独立的方法学 Prompt 和 Handlebars 模板
- [ ] 运行 JTIM 租户的审稿任务,后端日志显示 Prompt 为"静态协议 + JTIM 业务层"拼接版本
- [ ] LLM 返回 `system_metrics` + `expert_report_markdown` 双字段的 JSON不崩溃
- [ ] Zod 校验可自动补全缺失的 `checkpoints` 字段(填入 `not_mentioned` 默认值)
- [ ] Handlebars 渲染出最终报告文本,内容与 JTIM 模板格式一致
---
## 六、Phase 3面向外部的期刊业务端重构预计 2 周)
### 6.1 动态品牌登录页
**任务 3.1:公开租户信息接口**
新增接口(无需鉴权):
```
GET /api/v1/tenants/public-info/:tenantSlug
Response: { logoUrl, brandColor, brandName, backgroundImageUrl }
```
**任务 3.2:动态品牌登录页渲染**
> **前置条件**:任务 1.6b 已完成,未登录跳转现在携带 `?tenant=jtim&redirect=/jtim/dashboard`
修改现有 `frontend-v2/src/pages/LoginPage.tsx`(而非新建页面,避免重复):
```typescript
// 在 LoginPage 初始化时读取 tenant query param
const [searchParams] = useSearchParams()
const tenantSlug = searchParams.get('tenant') // e.g., 'jtim'
const redirectPath = searchParams.get('redirect') // e.g., '/jtim/dashboard'
// 若 tenantSlug 非空,拉取公开配置,渲染期刊品牌
useEffect(() => {
if (tenantSlug) {
fetch(`/api/v1/tenants/public-info/${tenantSlug}`)
.then(r => r.json())
.then(config => setBrandConfig(config))
}
}, [tenantSlug])
// 登录成功后
onLoginSuccess(() => {
navigate(redirectPath ?? '/')
})
```
**渲染逻辑**
- `tenantSlug` 有值 → 显示期刊专属品牌 UILogo、品牌色、背景图
- `tenantSlug` 为空 → 显示主站默认 UI行为不变向后兼容
---
### 6.2 稿件管理池Dashboard 重构)
**任务 3.3:稿件列表宽表升级**
修改 `frontend-v2/src/modules/rvw/pages/Dashboard.tsx`
参考 `AI审稿V1.html` 原型设计:
- **顶部统计卡片**:待预审 / AI 审查中 / 需人工复核 / 今日已退修
- **稿件宽表**:稿件编号 / 标题 / 通讯作者 / 综合评分(环形) / 4 维状态图标 / 状态标签 / 操作按钮
- 4 维状态图标:✅ pass / ⚠️ warn / ❌ error / ⏳ pending
---
### 6.3 沉浸式审稿详情页(核心)
**任务 3.4:分屏布局重构**
修改 `frontend-v2/src/modules/rvw/components/TaskDetail.tsx`
参考原型,重构为两栏:
- **左侧 3/5**稿件原文预览PDF/Word 渲染或纯文本),带浮动工具栏
- **右侧 2/5**4-Tab 审查工作台(稿约规范 / 方法学 / 数据验证 / 临床评估)
**任务 3.5Human-in-the-Loop 问题确认**
修改各 Report 组件(`EditorialReport``MethodologyReport`等):
每个问题卡片底部增加复核操作:
```
[✓ 采纳此问题] [✗ 误报/忽略]
```
- 状态持久化到后端(新增 `review_task_confirmations` 字段或表)
- 只有"已采纳"的问题才会进入退修信
**任务 3.6:主报告渲染升级**
修改 `MethodologyReport.tsx` 等:
- 优先展示 `expert_report_markdown`Handlebars 渲染后的纯文本/Markdown
- 不再依赖组件内部"拼接 JSON 字段展示"的旧逻辑
---
### 6.4 用户-租户绑定 SOP无需新开发但必须列入上线 Checklist
> ⚠️ **【审查报告修正 P1】业务闭环缺失的关键补丁**(来自架构审查报告第 4 条):
> 系统已有 `platform_schema.tenant_members` 表和用户管理 API但计划中未说明"谁把责编拉进特定期刊租户"。
> 此项**无需新开发**,但必须写入交付 SOP
> **上线 Checklist必须完成**
> 1. 内部超管通过 ADMIN 端的【用户管理 → 租户成员管理】,将期刊责编账号添加至对应租户(如 JTIM
> 2. 赋予该用户 RVW 模块权限
> 3. 验证责编登录 `review.xunzhengyixue.com/jtim` 后能看到该期刊的稿件列表(不为空、不报 403
### 6.5 退修信生成(重点新功能)
**任务 3.7退修信生成后端接口SSE 流式方案)**
> ⚠️ **【审查报告修正 P0】退修信生成必须使用 SSE 流式,不可同步阻塞**(来自架构审查报告第 2 条):
> 退修信涉及 LLM 生成长文本,耗时极易超过 SAE 网关 60 秒限制。
> 系统已有 SSE 基础设施ASL Deep Research V2.0 已验证),直接复用。
>
> ⚙️ **【Sprint 审查修正 #2】运维必须调整 ALB 空闲超时时间**
> 阿里云 ALB 默认"连接空闲超时时间"为 **15 秒**。LLM 在生成退修信时若有思考停顿,超过 15 秒未推流ALB 会强制切断 TCP 连接,前端报 SSE 中断。
> **运维执行项**:进入 ALB 控制台 → 监听443 端口)→ 高级配置 → 将"连接空闲超时时间"调大至 **300 秒**。
接口设计(两步):
```
# 第一步SSE 流式生成(打字机效果)
GET /api/v2/rvw/tasks/:id/revision-letter/stream
Body via query: confirmedIssueIds=xxx,yyy
Response: text/event-stream实时推送 letterContent 增量文本
# 第二步:生成完毕后触发 Word 导出(短耗时,同步可接受)
POST /api/v2/rvw/tasks/:id/revision-letter/export
Body: { letterContent: string }
Response: { wordFileUrl: string }
```
逻辑:
1. 获取已确认的问题列表(按维度分组)
2. 调用 LLM以 stream 模式生成退修信,通过 SSE 推流到前端
3. 前端生成完毕后,用户确认内容,点击"导出 Word"触发第二步接口
**任务 3.8前端退修信弹窗SSE 打字机效果)**
> ⚠️ **【审查报告修正 P0-1】严禁使用原生 `EventSource`**
> 浏览器原生 `EventSource` 规范层面不支持设置任何自定义 HTTP Header无法携带 `Authorization: Bearer xxx`,也无法携带 `x-tenant-id`)。直接使用会导致后端 401 / 403。
> **必须使用 `@microsoft/fetch-event-source` 或手写 Fetch ReadableStream**
```typescript
import { fetchEventSource } from '@microsoft/fetch-event-source';
// 安装npm install @microsoft/fetch-event-source
await fetchEventSource(`/api/v2/rvw/tasks/${taskId}/revision-letter/stream`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'x-tenant-id': tenantSlug,
},
onmessage(event) {
setLetterContent(prev => prev + event.data); // 打字机效果
},
onerror(err) { /* 错误处理 */ }
});
```
新建 `RevisionLetterModal.tsx`
- 展示已采纳问题汇总(可再次勾选/取消)
- "生成退修信"按钮
- **打字机式实时渲染**:使用 `@microsoft/fetch-event-source`,字符逐步出现
- 生成完毕后显示"导出 Word"按钮(调用第二步同步接口)
---
### 6.5 双轨导出升级
**任务 3.9:内部审稿报告导出**
升级现有 Word 导出功能:
- 使用 Handlebars 渲染的 `expert_report_markdown` 替代旧的 JSON 拼接展示
- 保留 4 个维度的分章节结构
**任务 3.10:作者退修信导出**
退修信 Word 文档格式:
- 标题:`Revision Required - [稿件标题]`
- 信头:期刊名、日期
- 正文:按维度分组的问题与建议(敬语体)
- 结尾:编辑署名
---
### 6.6 Phase 3 验收标准
- [ ] 访问 `review.xunzhengyixue.com/jtim` 显示 JTIM 品牌登录页Logo/品牌色正确)
- [ ] 登录后进入 JTIM 专属工作台,左侧导航只显示 RVW 相关功能
- [ ] 上传稿件后,在 Dashboard 宽表中可看到 4 维状态图标实时更新
- [ ] 审稿完成后,点击"人工复核"进入分屏详情页
- [ ] 在每个问题卡片上点击"采纳"/"忽略",刷新后状态保持
- [ ] 方法学报告优先展示 Handlebars 渲染的 `expert_report_markdown`(格式与 ADMIN 配置模板一致)
- [ ] 点击"生成退修信"退修信以打字机效果实时渲染SSE 流式),生成完毕后可导出 Word
- [ ] 责编账号必须已通过 ADMIN 端绑定至 JTIM 租户,否则访问时正确提示 403
---
## 七、关键技术风险与防范
### 风险 1JSON 转义风暴(最高优先级)
**风险**LLM 在 `expert_report_markdown` 字段中输出含换行符、引号的长文本时JSON.parse 崩溃。
**防范方案:**
1. **API 层强约束**:所有 LLM 调用启用 `response_format: { type: "json_object" }`
2. **引入 jsonrepair**:安装 `jsonrepair` 包,替换直接使用 `JSON.parse` 的地方
3. **Partial 容灾**:继续保持 `Promise.allSettled` + `partial_completed` 机制
### 风险 2Handlebars 模板变量错位
**风险**:运营人员配置的模板引用了 LLM 没有输出的字段,渲染报错。
**防范方案:**
1. **Zod 校验先行**:在 Handlebars 渲染前,用 Zod Schema 对 LLM 输出进行校验并填充默认值
2. **防御性模板规范**:模板中大量使用 `{{#if field}}...{{else}}N/A{{/if}}`,并在 ADMIN 端提示
### 风险 3方法学超时已有缓解措施
**风险**20 检查点 + 长文本生成,超过 8 分钟超时。
**防范方案V3.0.2 已实施 + V4.0 增强):**
1. ✅ 分治并行A/B/C 三段)已实施
2. **V4.0 增强**`expert_report_markdown` 字段让 LLM 汇总输出一份完整报告,减少结构化字段填充压力
3. 快速模式开关(`RVW_METHODOLOGY_FAST_MODE`)规划中
### 风险 4多租户数据越权安全红线
**风险**A 期刊编辑通过修改参数访问 B 期刊稿件。
**防范方案:**
1. JWT Payload 中 tenantId 不可伪造(服务端签发)
2. auth.middleware.ts 中双重校验userId 有效性 + tenantId 归属)
3. 所有 RVW Prisma 查询强制附带 `where: { tenantId: request.tenantId }`
4. 开发规范:禁止跳过 tenantId 校验的裸查
---
## 八、各阶段任务清单(按人员分工)
### 后端工程师
| Phase | 任务编号 | 任务描述 | 工期 |
|-------|---------|---------|------|
| Phase 1 | 1.1 | 新增 `tenant_rvw_configs` + Prisma 迁移 | 0.5d |
| Phase 1 | 1.2 | `review_tasks` 添加 `tenantId` + Prisma 迁移 | 0.5d |
| Phase 1 | 1.3 | JWT 注入 tenantId | 1d |
| Phase 1 | 1.4 | 中间件双重校验 | 1d |
| Phase 1 | 1.5 | Prisma 查询全局守护 | 1d |
| Phase 2 | 2.1 | RVW Config CRUD API | 1d |
| Phase 2 | 2.3 | SkillExecutor 按租户动态装配 | 1d |
| Phase 2 | 2.4 | Hybrid Prompt 拼装机制 | 1.5d |
| Phase 2 | 2.5 | LLM Structured Outputs 强约束 | 0.5d |
| Phase 2 | 2.6 | Handlebars 渲染引擎 + Zod 校验 | 1.5d |
| Phase 2 | 2.7 | 4 个 Skill 默认 Handlebars 模板 | 1d |
| Phase 3 | 3.1 | 公开租户信息接口 | 0.5d |
| Phase 3 | 3.7 | 退修信生成接口 | 1.5d |
| Phase 3 | 3.9 | 内部报告导出升级 | 1d |
| Phase 3 | 3.10 | 退修信 Word 导出 | 1d |
### 前端工程师
| Phase | 任务编号 | 任务描述 | 工期 |
|-------|---------|---------|------|
| Phase 1 | 1.6 | Tenant 识别逻辑 + 全局 Header 注入 | 1d |
| Phase 1 | 1.7 | 模块动态过滤 | 0.5d |
| Phase 2 | 2.2 | TenantDetailPage 新增"智能审稿配置"Tab4个Panel | 3d |
| Phase 3 | 3.2 | 动态品牌登录页 | 1.5d |
| Phase 3 | 3.3 | Dashboard 稿件宽表升级 | 2d |
| Phase 3 | 3.4 | 分屏布局审稿详情页 | 2d |
| Phase 3 | 3.5 | Human-in-the-Loop 问题确认 | 1.5d |
| Phase 3 | 3.6 | 主报告展示优先 expert_report_markdown | 1d |
| Phase 3 | 3.8 | 退修信生成弹窗 | 1.5d |
### 运维工程师
| Phase | 任务编号 | 任务描述 | 工期 |
|-------|---------|---------|------|
| Phase 1 | 1.8a | 阿里云 DNS 泛解析 `*.review` 记录 | 0.5d |
| Phase 1 | 1.8b | 申购 `*.review.xunzhengyixue.com` SSL 证书并挂载 | 1d |
| Phase 1 | 1.8c | Nginx 配置子域名转发规则 | 0.5d |
| Phase 1 | 1.8d | 后端 CORS 动态白名单配置 | 0.5d |
---
## 九、开发规范对齐要求
### 9.1 数据库规范(必须遵守)
> 对应:`docs/04-开发规范/09-数据库开发规范.md` 三条铁律
- **必须** 使用 `npx prisma migrate dev --name xxx` 生成迁移文件,**禁止** `prisma db push`
- 每次 Prisma Schema 变更后,**立即** 在 `docs/05-部署文档/03-待部署变更清单.md` 追加变更记录
- 本次 V4.0 涉及的迁移文件清单(需逐一追加):
| 迁移名 | 内容 | Schema |
|--------|------|--------|
| `add_tenant_rvw_configs` | 新增 `tenant_rvw_configs` 表 | `platform_schema` |
| `add_tenant_id_to_review_tasks` | 历史数据平滑迁移(两步走)| `rvw_schema` |
### 9.2 OSS 存储规范(必须遵守)
> 对应:`docs/04-开发规范/11-OSS存储开发规范.md`
- 稿件文件上传/读取:**必须** 通过 `common/storage/` 统一接口,禁止前端直连 OSS
- > ☁️ **【Sprint 审查修正 #4】OSS Bucket 必须配置 CORS 跨域规则**
> 前端通过签名 URL 直连 OSS 下载/预览 PDF 时,浏览器会发起 OPTIONS 预检请求。若 Bucket 未配置 CORS前端直接报 CORS Error 白屏。
> **运维执行项**:阿里云 OSS 控制台 → 对应 Bucket → 权限管理 → 跨域设置,新增规则:
> - 允许来源:`https://review.xunzhengyixue.com`
> - 允许方法:`GET, PUT, POST, HEAD`
> - 允许 Headers`*`
> - 暴露 Headers`ETag, x-oss-request-id`
- 退修信 Word 导出:文件存储 Key 遵循规范路径格式:
```
rvw/{tenantCode}/{taskId}/manuscripts/{uuid}.docx # 原稿
rvw/{tenantCode}/{taskId}/reports/{uuid}.docx # 审稿报告
rvw/{tenantCode}/{taskId}/revision-letters/{uuid}.docx # 退修信
```
- 报告/退修信下载链接:**必须** 使用带过期时间的签名 URLSigned URL禁止公开直链
- 原始文件名(如 "张三投稿.docx"存数据库OSS Key 使用 UUID
### 9.3 认证授权规范(必须遵守)
> 对应:`docs/04-开发规范/10-模块认证规范.md`
**后端路由层**(所有新增 API 必须):
```typescript
// RVW Config API运营管理员才能访问
fastify.put('/api/admin/tenants/:id/rvw-config', {
preHandler: [authenticate, requirePermission('ops:user-ops')]
}, handler);
// RVW 业务 API普通用户 + 模块权限
fastify.get('/api/v2/rvw/tasks', {
preHandler: [authenticate, requireModule('RVW')]
}, handler);
```
**前端层**
- 普通业务 API使用 `common/api/axios.ts``apiClient`(已内置 JWT
- SSE退修信生成使用 `@microsoft/fetch-event-source` + 手动从 `getAccessToken()` 获取 Token
**Controller 层**
```typescript
// 必须从 request 提取,禁止硬编码
const userId = getUserId(request);
const tenantId = request.tenantId; // 由中间件翻译后挂载
```
### 9.4 安全规范(必须遵守)
> 对应:`docs/04-开发规范/12-安全开发规范.md`
**IDOR 防护**P0 级强制):
```typescript
// ✅ 所有 RVW 查询必须带 tenantId
const task = await prisma.reviewTasks.findFirst({
where: { id: taskId, tenantId: request.tenantId }
});
// ❌ 禁止裸查
const task = await prisma.reviewTasks.findUnique({ where: { id: taskId } });
```
**公开 API 限流**(防爬):
- `GET /api/v1/tenants/public-info/:tenantSlug`(无需鉴权的品牌配置接口)**必须** 加 Rate Limiting建议 30次/分钟/IP
**Handlebars 模板注入防护**
- 运营人员配置的 Handlebars 模板在服务端渲染时,**必须** 使用 `Handlebars.create()` 独立沙箱实例
- 禁用危险 helpers`{{raw}}` 等),防止模板注入攻击
- 渲染时捕获所有异常,失败降级返回 `expert_report_markdown` 原始内容
### 9.5 与 ADMIN 模块融合要点
> 对应:`docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md`
| V4.0 需求 | ADMIN 现有能力 | 融合方式 |
|-----------|--------------|---------|
| Handlebars 模板编辑 + 预览 | Phase 3.5.4 `PromptEditorPage` 已有 Test Render | 在 RVW Config Tab 中复用该组件 |
| 方法学业务 Prompt 配置 | Phase 3.5.1 `PromptService` + CodeMirror 编辑器 | 复用 `PromptEditor` 组件 |
| 用户-租户绑定 | Phase 4.1 用户管理 + `tenant_members` 表 | 走已有流程,纳入上线 SOP |
| 期刊品牌定制Logo/主题色) | ADMIN P2 待开发:`品牌定制配置` + `租户专属登录页` | **主动对齐**V4.0 中的动态登录页实现即为该 P2 项落地,需同步更新 ADMIN 开发状态文档 |
| 运营数据统计 | Phase 5.0 `ActivityService` 埋点 | 新增退修信生成、配置变更等埋点,使用现有 fire-and-forget 模式 |
---
## 十一、不在本次 V4.0 范围内(明确排除)
以下功能在本次 V4.0 中**不开发**待后续版本V4.1+)规划:
1. **投稿作者侧门户**:作者直接上传投稿、查看审稿意见(本次只做责编侧)
2. **期刊主编仪表盘**:全刊数据统计(本次只做责编工作台)
3. **外审专家模块**:分配外审、专家接收稿件(超出本次范围)
4. **配置中心自助访问**期刊客户自行修改配置MVP 阶段由内部运营代劳)
5. **微信登录**认证网关方案规划中MVP 阶段账密登录
6. **PDF 渲染左栏**Phase 3 的左侧稿件预览可先用纯文本展示,真正的 PDF 渲染后续迭代
---
## 十、全局技术依赖与选型
### 10.1 新增依赖(确需安装)
| 包 | 用途 | 安装命令 |
|----|------|---------|
| `@microsoft/fetch-event-source` | 支持自定义 Header 的 SSE 客户端 | `npm install @microsoft/fetch-event-source`(前端) |
| `jsonrepair` | LLM JSON 容错解析 | `npm install jsonrepair`(后端) |
| `zod` | LLM 输出 Schema 校验 | `npm install zod`(后端,若未安装) |
### 10.2 复用现有能力(禁止重复造轮子)
> ⚠️ 以下能力系统中已存在,**直接调用,严禁重新实现**
| 能力 | 现有位置 | RVW V4.0 使用场景 |
|------|---------|-----------------|
| **SSE 流式响应** | `backend/src/common/streaming/` `createStreamingService` | 退修信生成后端推流(替代自建 SSE |
| **缓存服务** | `backend/src/common/cache/` | 租户 slug → UUID 查询缓存TTL 5min |
| **Prompt 管理 + Handlebars** | `backend/src/common/prompt/` `PromptService` | Handlebars 报告模板渲染PromptService 已支持,无需新建 `reportRenderer.ts` |
| **LLM 网关** | `backend/src/common/llm/LLMFactory` | 所有 LLM 调用 |
| **认证中间件** | `backend/src/common/auth/auth.middleware.ts` `authenticate` | 所有新增 RVW API 路由 |
| **模块中间件** | `backend/src/common/auth/auth.middleware.ts` `requireModule` | RVW Config API 必须加 `requireModule('RVW')` |
| **Axios 实例(含认证)** | `frontend-v2/src/common/api/axios.ts` | 所有前端 API 调用(含自动 JWT 注入) |
| **Token 获取** | `frontend-v2/src/framework/auth/api.ts` `getAccessToken()` | SSE 手动鉴权(`@microsoft/fetch-event-source` 中使用) |
| **运营埋点** | `backend/src/common/logging/ActivityService` | 退修信生成、配置变更等新行为需埋点 |
| **存储服务** | `backend/src/common/storage/` | 稿件上传、报告/退修信 Word 导出存储 |
| **Prompt 测试渲染** | ADMIN `PromptEditorPage` 已有 Test Render 功能Phase 3.5.4 | Handlebars 模板预览直接复用 |
| **租户详情页** | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx` | 新增"智能审稿配置"Tab在此文件扩展 |
| **用户-租户绑定** | ADMIN 用户管理Phase 4.1 已完成)| 新期刊用户绑定,走已有流程,无需开发 |
| **品牌定制能力** | ADMIN `tenants``config JSON` 字段 + `P2 - 品牌定制` 待开发项 | 动态登录页的 Logo/主题色配置,与 ADMIN 品牌定制对齐,避免冲突 |
### 10.3 LLM 调用规范
| 项目 | 要求 |
|------|------|
| Structured Output | 所有新增 LLM 调用启用 `response_format: { type: "json_object" }` |
| 通过 `LLMFactory` | 禁止直接 new SDK 客户端 |
| maxTokens | 各 Section 分治调用使用 reduced budget2048防超时 |
---
## 十二、文档与设计参考
| 文档 | 路径 |
|------|------|
| PRD | `docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/AI智能审稿系统(期刊SaaS版)产品需求文档0314.md` |
| UI 原型 | `docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/AI审稿V1.html` |
| 技术风险指南 | `docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/核心技术难点与防范指南.md` |
| 域名与多租户指南 | `docs/03-业务模块/RVW-稿件审查系统/00-系统设计/V3.0/AI智能审稿域名与多租户技术指南.md` |
| 架构白皮书 | `docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/智能审稿终极稳定架构白皮书.md` |
| 当前模块状态 | `docs/03-业务模块/RVW-稿件审查系统/00-模块当前状态与开发指南.md` |
| 输出解耦计划 | `docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 智能审稿输出解耦开发计划.md` |
---
---
## 十三、架构审查修正汇总v1.0 → v1.3
**第一轮审查修正v1.0 → v1.1**
| # | 等级 | 修正内容 | 影响章节 |
|---|------|---------|---------|
| 1 | 🔴 P0 | 历史 `review_tasks` 平滑迁移两步走脚本 | 第四章 4.1 |
| 2 | 🔴 P0 | 退修信生成改为 SSE 流式,拆分为两步接口 | 第六章 6.5 |
| 3 | 🟡 P1 | Vite `base: '/'` + Nginx `try_files` 明确要求 | 第四章 4.4 |
| 4 | 🟡 P1 | 用户-租户绑定列入交付 SOP无需新开发| 第六章 6.4 |
| 5 | 🟡 P1 | Handlebars 编辑器增加"测试渲染"预览按钮 | 第五章 5.2 |
| 6 | 🟢 P2 | 前端携带 `x-tenant-id` Header + 后端从 Header 提取 | 第四章 4.3 |
| - | 架构前提 | 域名方案从子域名改为路径隔离 `review.xunzhengyixue.com/:tenantId` | 全文 |
**第二轮审查修正v1.1 → v1.2**
| # | 等级 | 修正内容 | 影响章节 |
|---|------|---------|---------|
| 7 | 🔴 P0 | SSE 接口严禁使用原生 `EventSource`,必须用 `@microsoft/fetch-event-source` | 第六章 6.5 |
| 8 | 🟡 P1 | URL Slug`jtim`)→ `tenants.code` → 中间件翻译为 `tenants.id`,加 Redis 缓存 | 第四章 4.2 |
| 9 | 🟢 P2 | Axios 全局 Header 改为请求拦截器动态读取,防状态污染 | 第四章 4.3 |
**第三轮规范对齐补丁v1.2 → v1.3,已完成实现)**
| # | 类型 | 补丁内容 | 来源 |
|---|------|---------|------|
| A | 🔄 复用 | 退修信 SSE 后端用 `createStreamingService`,不自建 | 通用能力层:`common/streaming/` |
| B | 🔄 复用 | 租户 slug 缓存用 `common/cache/` 服务,不自建 Redis 代码 | 通用能力层:`common/cache/` |
| C | 🔄 复用 | Handlebars 渲染直接扩展 `PromptService`,不新建 `reportRenderer.ts` | 通用能力层:`common/prompt/` |
| D | 🔄 复用 | 前端业务 API 用 `common/api/axios.ts`SSE 用 `getAccessToken()` | 认证规范 §2 |
| E | 🔄 复用 | Handlebars 预览复用 ADMIN `PromptEditorPage` Test Render | ADMIN Phase 3.5.4 |
| F | 📏 规范 | 所有新增后端路由加 `authenticate` + `requireModule('RVW')` | 认证规范 §3 |
| G | 📏 规范 | 所有新增 Prisma 查询必须带 `tenantId`,防 IDOR | 安全规范 §1 |
| H | 📏 规范 | OSS 路径遵循 `rvw/{tenantCode}/{taskId}/xxx/` 格式,报告用签名 URL | OSS 规范 §2-3 |
| I | 📏 规范 | DB 每次迁移更新 `docs/05-部署文档/03-待部署变更清单.md` | 数据库规范铁律 |
| J | 📏 规范 | 公开接口加 Rate LimitingHandlebars 渲染加沙箱防注入 | 安全规范 §1/§6 |
| K | 🤝 融合 | 动态登录页实现 = ADMIN P2 `品牌定制配置` + `租户专属登录页` 落地,对齐两端 | ADMIN 待开发 P2 |
**第四轮:运行时 Bug 修复v1.3 → v1.4,✅ 已完成实现)**
| # | 文件 | 修复内容 |
|---|------|---------|
| L | `RouteGuard.tsx` | 租户感知重定向:未登录时从 URL 提取 `tenantSlug`,跳转 `/t/jtim/login?redirect=/jtim/dashboard`,保留期刊上下文;主站路径行为不变 |
**第五轮Sprint 冲刺会架构细节修正v1.4 → v1.5,✅ 已完成实现)**
| # | 类型 | 内容 | 影响 |
|---|------|------|------|
| M | 🏗️ 架构修正 | JWT 不含 `tenantId`,后端实时通过 `x-tenant-id` Header + `tenant_members` 核验 | Task 1.3 |
| N | ✅ 已实现 | `useTenantStore` + `useTenantObserver` + `TenantBootstrap` 全链路,解决 SPA 状态陷阱 | Task 1.6b |
| O | ✅ 已实现 | `RouteGuard` 动态排除 MODULES 路径,对齐 `/t/:tenantCode/login` 路由 | Task 1.6b |
| P | ⚙️ 运维 | ALB 空闲超时从 15s 调至 300sSSE 退修信生成防切断)| Task 3.7 |
| Q | ⚙️ 运维 | OSS Bucket 新增 CORS 规则,允许 `review.xunzhengyixue.com` | Task 9.2 |
---
**文档版本:** v1.5
**制定日期:** 2026-03-14
**最后更新:** 2026-03-14第五轮Sprint 冲刺会 4 条细节修正 + useTenantObserver 全链路已实现)
**下一步行动:** Phase 1 启动 — DB 迁移脚本(含历史数据平滑迁移)+ 运维执行 ALB 超时 + OSS CORS 配置 + `@microsoft/fetch-event-source` 安装 + `/t/:tenantCode/login` 路由渲染期刊品牌 UI

View File

@@ -0,0 +1,67 @@
# **RVW V4.0 智能审稿 SaaS 开发计划 \- 架构与工程审查报告**
**审查目标:** 评估 V1.0 版开发计划的技术可行性识别潜在的架构漏洞、数据迁移风险及开发体验DX阻塞点。
**架构前提变更:** 确认 MVP 阶段为节约泛域名 SSL 证书成本,采用 **URL 路径隔离 (review.xunzhengyixue.com/jtim)** 方案。 **审查结论:** 整体架构设计优秀,但在**历史数据迁移、长耗时接口设计、以及路径路由下的静态资源加载**等方面存在 6 个具体的工程盲区,需在执行前修正。
## **🔴 核心高风险项 (P0级 \- 影响系统稳定与上线)**
### **1\. 历史数据迁移盲区review\_tasks 增加 tenantId 会导致现网报错**
* **计划现状**:任务 1.2 提出给现有的 review\_tasks 表添加 tenant\_id 字段,并设为外键。
* **风险点**线上生产环境RDS中已经存在大量 V3.0 的历史审稿任务。如果在 Prisma 中直接增加强制Required关联的 tenantId 字段,**Prisma Migrate 将会报错并中断**,因为历史数据该字段为空,违反了非空和外键约束。
* **修正建议**
在 Phase 1 的数据库改造中,必须明确两步走的迁移策略:
1. 先在 platform\_schema.tenants 中初始化一个“默认临床主站租户”(如 tenant-default-yanjiu
2. 编写自定义的数据迁移脚本Data Migration Script在添加 tenant\_id 列后,将所有历史 review\_tasks 的 tenant\_id 刷为这个默认租户的 ID最后再设置外键非空约束。
### **2\. 接口超时风险:退修信生成(任务 3.7)采用了同步阻塞设计**
* **计划现状**:任务 3.7 设计的接口 POST /api/v2/rvw/tasks/:id/revision-letter 是一个同步接口,直接返回 letterContent 和 wordFileUrl。
* **风险点**:组装数十个问题并请求大模型生成一封高质量、排版精美的退修信,耗时极易超过阿里云 SAE 网关的 **60秒超时限制**。如果让前端一直 loading 等待,极易出现 504 Gateway Timeout。
* **修正建议**
鉴于你们在 ASL Deep Research V2.0 中已经成功应用了 SSE 流式架构,强烈建议将退修信生成改为:
* **方案 A流式推荐**:前端调用 SSE 接口,打字机式实时渲染退修信,生成完毕后再通过另一个接口触发 Word 文件上传并获取 URL。
* **方案 B异步**:复用现有的 pg-boss 队列,前端轮询任务状态(但退修信字数不多,方案 A 体验更好)。
## **🟡 中风险项 (P1级 \- 影响开发体验与业务闭环)**
### **3\. 【新增架构风险】前端静态资源404与Nginx刷新白屏风险**
* **计划现状**MVP 架构已调整为 review.xunzhengyixue.com/:tenantId 路径隔离。
* **风险点**
1. **静态资源 404**:如果前端打包配置使用的是相对路径(./),用户访问 /jtim 时,浏览器会向 /jtim/assets/... 错误地请求 JS/CSS 文件,导致全站白屏。
2. **Nginx 刷新 404**:用户在 /jtim/dashboard 页面直接刷新浏览器时Nginx 会试图寻找服务器上的真实物理目录,找不到即报 404。
* **修正建议**
* **前端组**打包配置Vite/Webpack中的 base 或 publicPath 必须强制设置为绝对根路径 /。
* **运维组**:部署前端镜像的 nginx.conf 中,必须配置 try\_files $uri $uri/ /index.html; 以支持单页应用SPA的 History 路由回退。
### **4\. 业务闭环缺失:谁把“责编”拉进特定的租户?**
* **计划现状**:计划涵盖了数据隔离和 ADMIN 配置,但忽略了“用户-租户绑定User-Tenant Binding”的操作入口。
* **风险点**:当我们把 review.xunzhengyixue.com/jtim 交付给客户时JTIM 的编辑登录后,系统如何知道他属于 JTIM如果他不在 tenant\_members 表里,他依然会看到空数据或 403。
* **修正建议**
系统状态文档显示已有 platform\_schema.tenant\_members 表和用户管理 API。需要在 Phase 2 的任务中补充明确:**内部超管通过 ADMIN 端现有的【用户管理 / 租户成员管理】界面,预先将对应的医生账号添加到指定的期刊租户中,并赋予 RVW 模块权限**。这不需要新开发,但必须列入交付 SOP 流程。
### **5\. 灾难级配置风险Handlebars 缺少“实时预览”功能(任务 2.2**
* **计划现状**ADMIN 端的 Panel B 提供了一个供运营人员编辑 Handlebars 模板的文本区。
* **风险点**:如果运营人员不小心写错了语法(例如少写了一个 }),或者引用了错误的变量。由于没有预览功能,必须去前台上传一篇真实的 PDF等待 5 分钟 LLM 分析完,在最后一步渲染报告时系统才会崩溃。这会导致极高的配置试错成本。
* **修正建议**
强烈建议在任务 2.2 的 Handlebars 编辑器旁,复用你们在 ADMIN Phase 3.5.4 已经实现的\*\*“测试渲染 (Test Render)”\*\*功能。提供一份 Mock 好的完整 system\_metrics JSON 假数据,运营人员点击“预览”按钮,即可立刻在右侧看到 Markdown 报告的渲染效果,确保语法 100% 正确后再保存。
## **🟢 细节优化项 (P2级 \- 架构规范对齐)**
### **6\. 后端鉴权上下文提取方式需明确 (适应路径隔离)**
* **计划现状**:由子域名切换到了 URL 路径隔离。
* **风险点**:后端无法再通过请求头中的 Origin 或 Host 隐式判断当前用户正在访问哪个租户的工作台。
* **修正建议** 明确开发规范:前端在使用 Axios 发起对 RVW 模块的业务 API 请求时,必须在 Header 中显式携带 x-tenant-id从前端路由 :tenantId 中获取)。后端 auth.middleware.ts 统一从 Header 获取上下文,进行防越权校验。
## **🎯 总结与建议行动**
这份开发计划总体可以直接执行但建议在启动会Kick-off让技术负责人将以上 6 点更新进原有的 Markdown 计划中:
1. **后端/DBA**:确认 review\_tasks 的平滑迁移 SQL 脚本。
2. **后端**:将退修信接口(任务 3.7)重构为 SSE 流式响应,并在鉴权中间件增加 x-tenant-id 解析。
3. **前端 & 运维**:死守前端打包的 base: '/' 设置与 Nginx try\_files 配置,确保路径路由不白屏;并在 ADMIN 增加模板预览按钮。

View File

@@ -17,6 +17,8 @@
| # | 变更内容 | 迁移文件 | 优先级 | 备注 |
|---|---------|---------|--------|------|
| DB-1 | SSA Agent 执行记录新增分步执行与种子审计字段(`step_results/current_step/seed_audit` | `20260311_add_ssa_agent_step_seed_fields` | 高 | 按数据库规范生成Shadow DB 失败后采用降级流程产出 SQL并已人工收敛为仅本次字段变更 |
| DB-2 | RVW V4.0:新增 `platform_schema.tenant_rvw_configs`每期刊独立审稿配置含4维提示词+Handlebars模板 | `20260314_add_tenant_rvw_configs` | 高 | ⚠️ 部署前无需前置条件使用降级流程手动创建迁移SQL需执行 `prisma migrate resolve --applied 20260314_add_tenant_rvw_configs` 标记为已应用 |
| DB-3 | RVW V4.0`rvw_schema.review_tasks` 新增 `tenant_id` 字段 + 索引(历史数据平滑回填两步走) | `20260314_add_tenant_id_to_review_tasks` | 高 | ⚠️ **部署前必须先确认** `platform_schema.tenants` 中存在 `code='yanjiu'` 的主站默认租户迁移会自动将历史记录回填为该租户ID需执行 `prisma migrate resolve --applied 20260314_add_tenant_id_to_review_tasks` 标记为已应用 |
### 后端变更 (Node.js)
@@ -27,6 +29,8 @@
| BE-3 | Agent 切换为严格分步模式:`confirm_plan` 不生成整段代码,执行阶段统一按步骤生成 + 失败后依赖短路跳过后续步骤 | `backend/src/modules/ssa/services/ChatHandlerService.ts` | 重新构建镜像 | 修复“第3步失败仍尝试第4步”问题降低无效重试与误导性结果 |
| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 |
| BE-5 | RVW 审稿通道改造4 通道 Prompt 动静分离(业务提示词可编辑 + 系统协议固化)+ 方法学/稿约 JSON 结构化修复兜底 + DataForensics 默认切换为 LLM-only规则验证默认关闭 | `backend/src/modules/rvw/services/promptProtocols.ts`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/methodologyService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/modules/rvw/skills/library/DataForensicsSkill.ts`, `backend/src/modules/rvw/skills/core/types.ts`, `backend/src/common/document/ExtractionClient.ts`, `backend/src/common/prompt/prompt.fallbacks.ts` | 重新构建镜像 | 解决运营端改 Prompt 导致 JSON 解析失败;数据侦探默认仅“表格提取+LLM判断”规则代码保留可回切 |
| BE-6 | RVW V4.0 Phase 1Prisma Schema 新增 TenantRvwConfig 模型 + ReviewTask.tenantId 字段 + RVW租户中间件rvwTenantMiddlewareslug到UUID解析+tenant_members校验+缓存)+ FastifyRequest 扩展 tenantId/tenant 字段 | `backend/prisma/schema.prisma`, `backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts`, `backend/src/common/auth/auth.middleware.ts` | 重新构建镜像 + 执行 DB-2/DB-3 迁移 | 与 DB-2/DB-3 配套上线Prisma Client 已重新生成 |
|| BE-7 | RVW V4.0 Phase 2RVW Config CRUD APIGET/PUT `/api/admin/tenants/:id/rvw-config`+ Handlebars 渲染引擎Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
### 前端变更
@@ -35,6 +39,10 @@
| FE-1 | Agent 通道接入 step_* SSE 事件并展示分步执行状态(兼容旧 code_* 事件) | `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts`, `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/types/index.ts`, `frontend-v2/src/modules/ssa/stores/ssaStore.ts` | 重新构建镜像 | 右侧工作区可见每步状态/错误/耗时,便于排障 |
| FE-2 | Agent 计划阶段复用 QPER 变量编辑控件(单变量/多变量)并接入保存、确认前自动保存 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx`, `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx` | 重新构建镜像 | 对接 `PATCH /agent-executions/:executionId/plan-params`,实现 5A.5 前后端闭环 |
| FE-3 | Agent 工作区增强:在分步状态下可展开查看每步已生成结果(`reportBlocks`),并兼容严格分步模式下的 `code_pending` 空代码预览 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` | 重新构建镜像 | 修复“有结果但代码下方不可见”与状态显示误导问题 |
|| FE-4 | RVW V4.0 Phase 2TenantDetailPage 新增「智能审稿配置」Tab4 Panel稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
|| BE-8 | RVW V4.0 Phase 3后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id整个多租户功能失效 |
|| FE-5 | RVW V4.0 Phase 3新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/t/:tenant/login -> /:tenant/rvw` 主流程 |
### Python 微服务变更

View File

@@ -5,10 +5,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider, useAuthHeartbeat } from './framework/auth'
import { PermissionProvider } from './framework/permission'
import { RouteGuard } from './framework/router'
import { useTenantObserver, useTenantStore } from './framework/tenant'
import apiClient from './common/api/axios'
import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout'
import TenantPortalLayout from './framework/layout/TenantPortalLayout'
import LoginPage from './pages/LoginPage'
import TenantLoginPage from './pages/TenantLoginPage'
import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard'
import PromptListPage from './pages/admin/PromptListPage'
@@ -16,6 +20,11 @@ import PromptEditorPage from './pages/admin/PromptEditorPage'
import TenantListPage from './pages/admin/tenants/TenantListPage'
import TenantDetailPage from './pages/admin/tenants/TenantDetailPage'
import { MODULES } from './framework/modules/moduleRegistry'
import { lazy, Suspense } from 'react'
import { Spin } from 'antd'
// 期刊租户门户专用 RVW 模块Tailwind 原型风格,与主站 /rvw 完全隔离)
const TenantRvwModule = lazy(() => import('./modules/rvw/TenantModule'))
// 用户管理页面
import UserListPage from './modules/admin/pages/UserListPage'
import UserFormPage from './modules/admin/pages/UserFormPage'
@@ -68,6 +77,26 @@ function AuthHeartbeatBootstrap() {
return null
}
/**
* 租户路由观察者:监听 React Router location 变化,
* 实时同步 tenantSlug 到 Zustand store解决 SPA 内部跳转时 store 不更新的问题。
* 必须在 <BrowserRouter> 内部挂载。
*/
function TenantBootstrap() {
useTenantObserver()
return null
}
// Axios 请求拦截器:每次请求时从 store 实时读取 tenantSlug
// 避免使用 defaults.headers 全局写死导致的租户状态污染。
apiClient.interceptors.request.use((config) => {
const { tenantSlug } = useTenantStore.getState()
if (tenantSlug) {
config.headers['x-tenant-id'] = tenantSlug
}
return config
})
function App() {
return (
<ConfigProvider locale={zhCN}>
@@ -79,10 +108,13 @@ function App() {
{/* 权限提供者:模块级权限管理 */}
<PermissionProvider>
<BrowserRouter>
{/* 租户路由观察者:监听路由变化,实时同步 tenantSlug 到 store */}
<TenantBootstrap />
<Routes>
{/* 登录页面(无需认证) */}
<Route path="/login" element={<LoginPage />} />
<Route path="/t/:tenantCode/login" element={<LoginPage />} />
{/* 期刊租户专属登录页(全屏原型风格,独立于所有 Layout*/}
<Route path="/t/:tenantCode/login" element={<TenantLoginPage />} />
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
@@ -150,6 +182,35 @@ function App() {
<Route path="audit" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>
{/* ============================================================
* 期刊租户门户路由RVW V4.0 多租户)
* 路径格式review.xunzhengyixue.com/:tenantSlug/rvw/*
*
* 匹配优先级说明React Router v6 基于路径特异性打分):
* - /login、/admin、/org 等静态路径特异性 > /:tenantSlug动态段
* - TenantPortalLayout 在加载时调用公开 API 验证租户是否存在;
* 不存在404→ 自动重定向 /login规避路径误匹配。
* ============================================================ */}
<Route path="/:tenantSlug" element={<TenantPortalLayout />}>
{/* /:tenantSlug → 自动跳转到 /:tenantSlug/rvw */}
<Route index element={<Navigate to="rvw" replace />} />
{/* /:tenantSlug/rvw/* → RouteGuard认证+模块权限)→ RVW 模块 */}
<Route
path="rvw/*"
element={
<RouteGuard requiredModule="RVW" moduleName="智能审稿">
<Suspense fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" />
</div>
}>
<TenantRvwModule />
</Suspense>
</RouteGuard>
}
/>
</Route>
{/* 404重定向 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,131 @@
/**
* 期刊租户门户布局
* ─ 提供原型图风格的极简品牌顶栏白色h-16
* ─ 内容区全屏,无 Sidebar、无多余导航
* ─ 路由: /:tenantSlug/*
*/
import { useState, useEffect } from 'react';
import { useParams, Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd';
import { useAuth } from '../auth';
interface TenantBrand {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
}
export default function TenantPortalLayout() {
const { tenantSlug } = useParams<{ tenantSlug: string }>();
const { logout, user } = useAuth();
const [brand, setBrand] = useState<TenantBrand | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!tenantSlug) { setNotFound(true); setLoading(false); return; }
fetch(`/api/v1/public/tenant-config/${tenantSlug}`)
.then(async res => {
if (res.status === 404) { setNotFound(true); return; }
const data = await res.json();
if (data?.success && data.data) setBrand(data.data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [tenantSlug]);
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" />
</div>
);
}
if (notFound) return <Navigate to="/login" replace />;
// ── 品牌信息 ─────────────────────────────────────────────────
const journalName = brand?.name || tenantSlug || '';
// 期刊门户固定副标题为"智能审稿工作台",除非 API 返回了更具体的名称
const systemName = brand?.systemName && brand.systemName !== 'AI临床研究平台'
? brand.systemName
: '智能审稿工作台';
// 取期刊名首字母大写缩写最多2个字母用于 Header 方块 Logo
const abbr = journalName
.split(/\s+/)
.slice(0, 2)
.map(w => w[0]?.toUpperCase() ?? '')
.join('') || 'JT';
const handleLogout = async () => {
await logout();
window.location.href = `/t/${tenantSlug}/login`;
};
return (
<div className="h-screen w-full flex flex-col overflow-hidden bg-gray-50 font-sans antialiased">
{/* ════════════════════════════════════════════════════════════
品牌顶栏 ── 100% 对照原型图 AI审稿V1.html header 实现
════════════════════════════════════════════════════════════ */}
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0 z-10">
{/* 左侧:方块 Logo + 单行大标题(原型图 text-lg font-bold */}
<div className="flex items-center space-x-3">
{/* 品牌色方块(原型图 w-8 h-8 bg-brand-700 rounded font-bold */}
<div className="w-8 h-8 bg-[#0369a1] rounded text-white flex items-center justify-center font-bold text-sm select-none shrink-0">
{abbr}
</div>
{/* 单行大标题:期刊名 + 固定后缀,完全复制原型图 h1 风格 */}
<h1 className="text-base font-bold text-gray-800 whitespace-nowrap">
{journalName}&nbsp;{systemName}
</h1>
</div>
{/* 右侧:原型图风格的边框容器(铃铛 + 用户) */}
<div className="h-10 flex items-center rounded-md border border-gray-200 bg-white px-2 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center text-gray-500 hover:text-gray-700 transition-colors rounded-md hover:bg-gray-50"
aria-label="通知"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" strokeWidth={1.9} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div className="w-px h-5 bg-gray-200 mx-2" />
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-2 pr-1 hover:opacity-80 transition-opacity"
>
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-xs font-semibold text-white select-none shrink-0 shadow-sm"
style={{ background: 'linear-gradient(135deg, #38bdf8 0%, #0369a1 100%)' }}
>
{user?.name?.[0] ?? 'U'}
</div>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap leading-none">
{user?.name ?? '用户'}
</span>
<svg className="w-3 h-3 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</header>
{/* ═══════════ 内容区全屏Outlet 填满)══════════════════════ */}
<div className="flex-1 overflow-hidden">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,23 +1,39 @@
import { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth'
import { extractTenantSlug } from '../tenant'
import { Result, Button } from 'antd'
import { LockOutlined } from '@ant-design/icons'
/**
* 路由守卫组件
*
* @description
* 保护需要特定权限的路由防止用户通过URL直接访问无权限页面
*
* 检查顺序:
* 1. 检查是否已登录(未登录→重定向到登录页)
* 1. 检查是否已登录(未登录→携带租户标识重定向到登录页)
* 2. 检查模块权限(无权限→显示权限不足页面)
* 3. 有权限→渲染子组件
*
* @version 2026-01-16 更新为模块权限系统
* @version 2026-03-14 V4.0:租户感知重定向
* - 期刊租户路径(/jtim/*)→ /t/jtim/login?redirect=/jtim/dashboard
* - 主平台路径(/rvw/*, /ai-qa/* 等)→ /login行为不变
* - extractTenantSlug 由 useTenantObserver 模块统一维护,自动排除所有注册模块路径
*/
/**
* 构造未登录时的登录跳转目标。
*
* 对齐 App.tsx 中已定义的路由:<Route path="/t/:tenantCode/login" />
*
* - 期刊租户下:/t/jtim/login?redirect=%2Fjtim%2Fdashboard
* - 主站下:/login保持原有行为向后兼容
*/
function buildLoginRedirect(pathname: string): string {
const tenantSlug = extractTenantSlug(pathname)
if (!tenantSlug) return '/login'
const redirectParam = encodeURIComponent(pathname)
return `/t/${tenantSlug}/login?redirect=${redirectParam}`
}
interface RouteGuardProps {
/** 子组件 */
children: ReactNode
@@ -47,7 +63,9 @@ const RouteGuard = ({
// 1. 检查是否登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
// V4.0 租户感知重定向:保留 tenantSlug让登录页渲染对应期刊品牌
const loginTarget = buildLoginRedirect(location.pathname)
return <Navigate to={loginTarget} state={{ from: location }} replace />
}
// 2. 检查模块权限(如果指定了 requiredModule

View File

@@ -0,0 +1,2 @@
export { useTenantStore } from './useTenantStore'
export { useTenantObserver, extractTenantSlug } from './useTenantObserver'

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { MODULES } from '../modules/moduleRegistry'
import { useTenantStore } from './useTenantStore'
/**
* 构建保留路径集合:静态保留词 + 所有已注册业务模块的首段路径。
*
* 这样可以确保 /rvw/tasks、/ai-qa 等主平台路径不会被误判为租户 slug
* 同时新增模块时自动纳入保留集,无需手动维护。
*/
const STATIC_RESERVED = new Set(['login', 'admin', 'org', 'api', 't', 'user', ''])
function buildReservedPaths(): Set<string> {
const modulePaths = MODULES.map(m => m.path.split('/').filter(Boolean)[0] ?? '')
return new Set([...STATIC_RESERVED, ...modulePaths])
}
const RESERVED_PATHS = buildReservedPaths()
/**
* 从 pathname 中提取期刊租户 slug。
*
* 逻辑:取首段路径,若在保留集中则返回 null非期刊上下文
*
* 示例:
* /jtim/dashboard → 'jtim'
* /cmj → 'cmj'
* /rvw/tasks → null主平台业务模块
* /login → null保留路径
* /admin/tenants → null保留路径
*/
export function extractTenantSlug(pathname: string): string | null {
const firstSegment = pathname.split('/').filter(Boolean)[0] ?? ''
return RESERVED_PATHS.has(firstSegment) ? null : firstSegment
}
/**
* 全局租户路由观察者 Hook。
*
* 必须在 <BrowserRouter> 内部挂载(因依赖 useLocation
* 监听每次路由变化,自动同步最新 tenantSlug 到 Zustand store
* 避免 SPA 内部跳转时未刷新页面store 中的值过期。
*
* 挂载位置App.tsx 中 BrowserRouter 内部的 bootstrap 组件。
*
* 使用示例App.tsx
* function TenantBootstrap() {
* useTenantObserver()
* return null
* }
* // 在 <BrowserRouter> 内部:
* <TenantBootstrap />
*/
export function useTenantObserver() {
const location = useLocation()
const setTenantSlug = useTenantStore(s => s.setTenantSlug)
useEffect(() => {
const slug = extractTenantSlug(location.pathname)
setTenantSlug(slug)
}, [location.pathname, setTenantSlug])
}

View File

@@ -0,0 +1,22 @@
import { create } from 'zustand'
interface TenantState {
/** 当前期刊租户标识(如 'jtim'、'cmj'),主站场景下为 null */
tenantSlug: string | null
setTenantSlug: (slug: string | null) => void
}
/**
* 全局租户状态 Store
*
* 由 useTenantObserver 在每次路由变化时实时更新。
* Axios 拦截器、SSE 请求等消费此 store 来获取 x-tenant-id Header 的值。
*
* 使用示例:
* const tenantSlug = useTenantStore(s => s.tenantSlug)
* const { tenantSlug } = useTenantStore.getState() // 在拦截器等非 React 环境中
*/
export const useTenantStore = create<TenantState>((set) => ({
tenantSlug: null,
setTenantSlug: (slug) => set({ tenantSlug: slug }),
}))

View File

@@ -0,0 +1,18 @@
/**
* 期刊租户门户专用 RVW 模块入口
* ─ 与主站 index.tsx 完全隔离,不影响主站 /rvw 路由
* ─ 在 TenantPortalLayout 的 <Outlet> 中渲染(已提供顶栏)
*/
import { Routes, Route } from 'react-router-dom';
import TenantDashboard from './pages/TenantDashboard';
import TenantTaskDetail from './pages/TenantTaskDetail';
const TenantRvwModule: React.FC = () => (
<Routes>
<Route index element={<TenantDashboard />} />
<Route path=":taskId" element={<TenantTaskDetail />} />
</Routes>
);
export default TenantRvwModule;

View File

@@ -0,0 +1,538 @@
/**
* 期刊租户门户 - 审稿任务列表页Dashboard
* ─ 100% 无 Sidebar全宽布局
* ─ 设计 100% 还原 AI审稿V1.html 原型图Dashboard 视图)
* ─ 功能对应现有 RVW 模块(上传/运行/查看报告)
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { message } from 'antd';
import * as api from '../api';
import type { ReviewTask, AgentType } from '../types';
import AgentModal from '../components/AgentModal';
// ── 内联 SVG 状态图标 ─────────────────────────────────────────────
const IconCheck = () => (
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/>
</svg>
);
const IconWarn = () => (
<svg className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
);
const IconError = () => (
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
);
const IconSpinner = () => (
<svg className="w-5 h-5 text-gray-300 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
);
const IconUpload = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
// ── 状态辅助函数 ───────────────────────────────────────────────────
type DimStatus = 'pass' | 'warn' | 'error' | 'pending';
const FINAL_STATUSES: ReviewTask['status'][] = ['completed', 'partial_completed'];
function isTaskFinal(task: ReviewTask): boolean {
return FINAL_STATUSES.includes(task.status);
}
function getEditorialStatus(task: ReviewTask): DimStatus {
const hasResult = !!task.editorialReview || typeof task.editorialScore === 'number';
if (!hasResult) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('editorial'))) {
return 'pass';
}
return 'pending';
}
const score = task.editorialReview?.overall_score ?? task.editorialScore ?? 0;
if (score >= 70) return 'pass';
if (score >= 50) return 'warn';
return 'error';
}
function getMethodologyStatus(task: ReviewTask): DimStatus {
const hasResult =
!!task.methodologyReview ||
typeof task.methodologyScore === 'number' ||
!!task.methodologyStatus;
if (!hasResult) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('methodology'))) {
return 'pass';
}
return 'pending';
}
const score = task.methodologyReview?.overall_score ?? task.methodologyScore ?? 0;
if (score >= 70) return 'pass';
if (score >= 50) return 'warn';
return 'error';
}
function getForensicsStatus(task: ReviewTask): DimStatus {
if (!task.forensicsResult) {
// 数据验证是基础流程的一部分,任务完成但列表接口未回传详细 forensicsResult 时按通过显示
return isTaskFinal(task) ? 'pass' : 'pending';
}
const errCnt = task.forensicsResult.summary?.errorCount ?? 0;
const warnCnt = task.forensicsResult.summary?.warningCount ?? 0;
if (errCnt > 0) return 'error';
if (warnCnt > 0) return 'warn';
return 'pass';
}
function getClinicalStatus(task: ReviewTask): DimStatus {
if (!task.clinicalReview) {
if (isTaskFinal(task) && (!task.selectedAgents?.length || task.selectedAgents.includes('clinical'))) {
return 'pass';
}
return 'pending';
}
return 'pass';
}
function StatusIcon({ status }: { status: DimStatus }) {
if (status === 'pass') return <IconCheck />;
if (status === 'warn') return <IconWarn />;
if (status === 'error') return <IconError />;
return <IconSpinner />;
}
// 综合分数圆形徽章
function ScoreBadge({ score }: { score?: number }) {
if (!score || score === 0) {
return (
<div className="w-9 h-9 rounded-full border-4 border-gray-100 bg-gray-50 flex items-center justify-center">
<span className="text-[10px] text-gray-400 font-bold">-</span>
</div>
);
}
const cls = score >= 80
? 'border-green-200 text-green-600 bg-green-50'
: score >= 60
? 'border-amber-200 text-amber-600 bg-amber-50'
: 'border-red-200 text-red-600 bg-red-50';
return (
<div className={`w-9 h-9 rounded-full border-4 flex items-center justify-center font-bold text-xs ${cls}`}>
{score}
</div>
);
}
// 状态 Badge胶囊标签
function StatusBadge({ status }: { status: ReviewTask['status'] }) {
const map: Record<ReviewTask['status'], { label: string; cls: string }> = {
pending: { label: '待审查', cls: 'bg-gray-100 text-gray-600' },
extracting: { label: '提取中', cls: 'bg-blue-100 text-blue-700' },
reviewing: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
reviewing_editorial: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
reviewing_methodology: { label: 'AI审查中', cls: 'bg-amber-100 text-amber-700' },
completed: { label: 'AI审查完毕', cls: 'bg-sky-100 text-sky-700' },
partial_completed: { label: '部分完成', cls: 'bg-amber-100 text-amber-700' },
failed: { label: '失败', cls: 'bg-red-100 text-red-600' },
};
const { label, cls } = map[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' };
return (
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${cls}`}>{label}</span>
);
}
// ── 主组件 ────────────────────────────────────────────────────────
export default function TenantDashboard() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [tasks, setTasks] = useState<ReviewTask[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// 智能体选择弹窗
const [agentModalVisible, setAgentModalVisible] = useState(false);
const [pendingTask, setPendingTask] = useState<ReviewTask | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// ── 数据加载 ──────────────────────────────────────────────────
const loadTasks = useCallback(async () => {
try {
setLoading(true);
const data = await api.getTasks();
setTasks(data);
} catch {
message.error('加载任务列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadTasks(); }, [loadTasks]);
// 轮询处理中的任务
useEffect(() => {
const processing = tasks.filter(t =>
['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(t.status)
);
if (processing.length === 0) return;
const iv = setInterval(async () => {
for (const t of processing) {
try {
const updated = await api.getTask(t.id);
setTasks(prev => prev.map(x => x.id === updated.id ? updated : x));
} catch {}
}
}, 3000);
return () => clearInterval(iv);
}, [tasks]);
// ── 统计数据 ──────────────────────────────────────────────────
const isProcessing = (t: ReviewTask) =>
['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(t.status);
const stats = {
total: tasks.length,
pending: tasks.filter(t => t.status === 'pending').length,
processing: tasks.filter(isProcessing).length,
completed: tasks.filter(t => ['completed', 'partial_completed'].includes(t.status)).length,
};
// ── 搜索过滤 ──────────────────────────────────────────────────
const filteredTasks = tasks.filter(t =>
search === '' || t.fileName.toLowerCase().includes(search.toLowerCase())
);
// ── 上传稿件 ──────────────────────────────────────────────────
const uploadFiles = useCallback(async (files: File[]) => {
if (!files.length) return;
for (const file of files) {
try {
message.loading({ content: `正在上传 ${file.name}`, key: file.name });
await api.uploadManuscript(file);
message.success({ content: `${file.name} 上传成功`, key: file.name, duration: 2 });
} catch (err: any) {
message.error({ content: `${file.name} 上传失败: ${err.message}`, key: file.name, duration: 3 });
}
}
loadTasks();
}, [loadTasks]);
const handleFilePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
e.target.value = '';
await uploadFiles(Array.from(files));
};
const handleOpenFilePicker = async () => {
// 优先走浏览器原生文件选择器 APIChrome/Edge 支持,稳定性优于 hidden input click
const maybeWindow = window as Window & {
showOpenFilePicker?: (options: {
multiple?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
}) => Promise<Array<{ getFile: () => Promise<File> }>>;
};
if (typeof maybeWindow.showOpenFilePicker === 'function') {
try {
const handles = await maybeWindow.showOpenFilePicker({
multiple: true,
types: [
{
description: '稿件文件',
accept: {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
},
},
],
});
const files = await Promise.all(handles.map(h => h.getFile()));
await uploadFiles(files);
return;
} catch (err: any) {
// 用户主动取消选择,不提示错误
if (err?.name === 'AbortError') return;
// 其他异常时降级到 input.click()
}
}
// 回退:传统 hidden input
fileInputRef.current?.click();
};
// ── 运行审查 ──────────────────────────────────────────────────
const handleRunTask = (task: ReviewTask) => {
setPendingTask(task);
setAgentModalVisible(true);
};
const handleConfirmRun = async (agents: AgentType[]) => {
if (isSubmitting || !pendingTask) return;
const task = pendingTask;
setAgentModalVisible(false);
setPendingTask(null);
setIsSubmitting(true);
try {
message.loading({ content: '正在启动审查…', key: 'run' });
const { jobId } = await api.runTask(task.id, agents);
message.success({ content: '审查已启动', key: 'run', duration: 2 });
// 对齐旧版体验:启动后立即进入“审稿过程页”,动态查看进度与分模块结果
navigate(`${task.id}?jobId=${encodeURIComponent(jobId)}`);
} catch (err: any) {
message.error({ content: err.message || '启动失败', key: 'run', duration: 3 });
} finally {
setIsSubmitting(false);
}
};
// ── 删除 ──────────────────────────────────────────────────────
const handleDelete = async (task: ReviewTask) => {
if (!window.confirm(`确认删除「${task.fileName}」?`)) return;
try {
await api.deleteTask(task.id);
message.success('删除成功');
loadTasks();
} catch (err: any) {
message.error(err.message || '删除失败');
}
};
// ── 统计卡片配置 ──────────────────────────────────────────────
const statCards = [
{ label: '全部稿件', value: stats.total, icon: '📥', color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '待审查', value: stats.pending, icon: '⏳', color: 'text-gray-600', bg: 'bg-gray-50' },
{ label: 'AI 审查中', value: stats.processing, icon: '🤖', color: 'text-amber-600', bg: 'bg-amber-50' },
{ label: '已完成', value: stats.completed, icon: '✅', color: 'text-green-600', bg: 'bg-green-50' },
];
return (
<div className="h-full flex flex-col overflow-auto bg-gray-50 p-6">
{/* ═══ 统计卡片 ════════════════════════════════════════════ */}
<div className="grid grid-cols-4 gap-5 mb-6">
{statCards.map(card => (
<div key={card.label} className="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex items-center">
<div className={`w-12 h-12 ${card.bg} rounded-lg flex items-center justify-center text-xl mr-4 shrink-0`}>
{card.icon}
</div>
<div>
<p className="text-sm text-gray-500">{card.label}</p>
<p className={`text-2xl font-bold ${card.color}`}>{card.value}</p>
</div>
</div>
))}
</div>
{/* ═══ 稿件列表表格 ════════════════════════════════════════ */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col min-h-0">
{/* 表格标题栏 */}
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50/50 shrink-0">
<h2 className="text-base font-bold text-gray-800">稿 (Manuscripts Pool)</h2>
<div className="flex items-center space-x-2">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索文件名…"
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-sky-400 focus:border-sky-400 transition"
/>
{/* 上传按钮:原生文件选择器 API + hidden input 回退 */}
<div className="relative">
<button
type="button"
onClick={handleOpenFilePicker}
className="flex items-center gap-1.5 bg-[#0284c7] text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-[#0369a1] transition shadow-sm"
>
<IconUpload />
稿
</button>
<input
ref={fileInputRef}
type="file"
accept=".docx,.doc,.pdf"
multiple
className="hidden"
onChange={handleFilePick}
aria-label="上传稿件"
/>
</div>
</div>
</div>
{/* 表格内容 */}
<div className="overflow-auto flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<svg className="animate-spin h-8 w-8 text-sky-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
) : filteredTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<div className="w-16 h-16 bg-sky-50 text-sky-400 rounded-full flex items-center justify-center mx-auto mb-3 text-2xl">
📄
</div>
<p className="text-sm font-medium text-gray-600">{search ? '未找到匹配的稿件' : '暂无稿件'}</p>
{!search && <p className="text-xs text-gray-400 mt-1">稿</p>}
</div>
) : (
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-500 border-b border-gray-200 sticky top-0">
<tr>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-center">稿</th>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTasks.map(task => {
const isCompleted = ['completed', 'partial_completed'].includes(task.status);
const isPending = task.status === 'pending';
const isRunning = isProcessing(task);
return (
<tr
key={task.id}
className="hover:bg-sky-50/30 transition-colors"
>
{/* 文件名 */}
<td className="px-6 py-4">
<span
className={`font-medium text-gray-800 max-w-xs truncate block ${isCompleted ? 'cursor-pointer hover:text-[#0284c7]' : ''}`}
title={task.fileName}
onClick={() => isCompleted && navigate(task.id)}
>
{task.fileName}
</span>
{task.fileSize > 0 && (
<span className="text-xs text-gray-400">{api.formatFileSize(task.fileSize)}</span>
)}
</td>
{/* 上传时间 */}
<td className="px-6 py-4 text-gray-500 text-xs whitespace-nowrap">
{new Date(task.createdAt).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})}
</td>
{/* 综合评分圆圈 */}
<td className="px-6 py-4">
<ScoreBadge score={task.overallScore} />
</td>
{/* 四维状态图标 */}
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getEditorialStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getMethodologyStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getForensicsStatus(task)} />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex justify-center">
<StatusIcon status={getClinicalStatus(task)} />
</div>
</td>
{/* 状态 Badge */}
<td className="px-6 py-4"><StatusBadge status={task.status} /></td>
{/* 操作按钮 */}
<td className="px-6 py-4 text-right whitespace-nowrap">
{isCompleted && (
<button
onClick={() => navigate(task.id)}
className="text-[#0284c7] hover:text-[#0369a1] font-medium bg-sky-50 px-3 py-1 rounded-md text-sm transition hover:bg-sky-100"
>
</button>
)}
{isPending && (
<button
onClick={() => handleRunTask(task)}
className="text-white bg-[#0284c7] hover:bg-[#0369a1] font-medium px-3 py-1 rounded-md text-sm transition"
>
</button>
)}
{isRunning && (
<span className="text-gray-400 text-sm flex items-center gap-1 justify-end">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</span>
)}
{task.status === 'failed' && (
<button
onClick={() => handleRunTask(task)}
className="text-red-600 hover:text-red-700 font-medium bg-red-50 px-3 py-1 rounded-md text-sm transition"
>
</button>
)}
<button
onClick={() => handleDelete(task)}
className="ml-2 text-gray-400 hover:text-red-500 transition text-xs"
title="删除"
>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* 智能体选择弹窗(复用现有) */}
<AgentModal
visible={agentModalVisible}
taskCount={1}
onClose={() => { setAgentModalVisible(false); setPendingTask(null); }}
onConfirm={handleConfirmRun}
isSubmitting={isSubmitting}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
/**
* 租户 RVW 任务详情页(旧流程适配壳)
* - 复用旧版 TaskDetail 的“审稿过程页 + 动态 Tab”交互
* - 仅做租户路由适配,保持全屏展示(不引入 Sidebar
* - 路由:/:tenantSlug/rvw/:taskId
*/
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import * as api from '../api';
import type { ReviewTask } from '../types';
import TaskDetail from '../components/TaskDetail';
export default function TenantTaskDetail() {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [task, setTask] = useState<ReviewTask | null>(null);
const [loading, setLoading] = useState(true);
const jobId = searchParams.get('jobId');
useEffect(() => {
if (!taskId) {
setLoading(false);
return;
}
(async () => {
try {
const data = await api.getTask(taskId);
setTask(data);
} catch {
message.error('加载任务失败');
} finally {
setLoading(false);
}
})();
}, [taskId]);
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-slate-50">
<svg className="animate-spin h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
);
}
if (!task) {
return (
<div className="h-full flex items-center justify-center text-slate-500 bg-slate-50">
</div>
);
}
return (
<div className="h-full overflow-hidden bg-slate-50">
<TaskDetail
task={task}
jobId={jobId}
onBack={() => navigate('..', { relative: 'path' })}
/>
</div>
);
}

View File

@@ -103,11 +103,18 @@ export default function LoginPage() {
// 智能跳转:根据用户角色和模块权限判断目标页面
const getRedirectPath = useCallback(() => {
const from = (location.state as any)?.from?.pathname;
const userRole = user?.role;
const userModules = user?.modules || [];
// 如果有明确的来源页面,优先处理
// 1. ?redirect= 查询参数优先RouteGuard 对租户路由设置,如 /t/jtim/login?redirect=%2Fjtim%2Frvw
const searchParams = new URLSearchParams(location.search);
const redirectParam = searchParams.get('redirect');
if (redirectParam) {
return decodeURIComponent(redirectParam);
}
// 2. location.state.fromReact Router 历史导航状态,兼容旧逻辑)
const from = (location.state as any)?.from?.pathname;
if (from && from !== '/') {
// 如果目标是运营管理端,检查权限
if (from.startsWith('/admin')) {
@@ -125,9 +132,14 @@ export default function LoginPage() {
return from;
}
// 没有来源页面,智能判断默认目标
// 3. 期刊租户专属登录页(/t/:tenantCode/login且无 redirect 参数时,默认进入该租户审稿页
if (tenantCode) {
return `/${tenantCode}/rvw`;
}
// 4. 兜底:根据模块权限智能判断默认落地页
return getDefaultModule(userModules);
}, [location, user]);
}, [location, user, tenantCode]);
// 根据用户模块权限获取默认跳转页面
// 路径需要与 moduleRegistry.ts 保持一致!

View File

@@ -0,0 +1,360 @@
/**
* 期刊租户专属登录页
* ─ 100% 独立全屏,无任何顶部导航/Layout 包裹
* ─ 设计 100% 还原 AI审稿V1.html 原型图
* ─ 功能:密码登录 + 验证码登录(与主站 LoginPage 逻辑相同)
* ─ 路由: /t/:tenantCode/login
*/
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useAuth } from '../framework/auth';
import type { ChangePasswordRequest } from '../framework/auth';
interface TenantBrand {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
isReviewOnly?: boolean;
}
// ── 内联 SVG 图标(不依赖任何图标库)──────────────────────────────
const IconBookMedical = () => (
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 4h2v5l-1-.75L9 9V4zm9 16H6V4h1v9l3-2.25L13 13V4h5v16z"/>
<path d="M11 14h2v2h2v2h-2v2h-2v-2H9v-2h2v-2z"/>
</svg>
);
const IconArrowRight = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
);
const IconSpinner = () => (
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
);
// ── 主组件 ───────────────────────────────────────────────────────
export default function TenantLoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { tenantCode } = useParams<{ tenantCode: string }>();
const {
loginWithPassword,
loginWithCode,
sendVerificationCode,
isLoading,
user,
changePassword,
} = useAuth();
// ── 状态 ──────────────────────────────────────────────────────
const [brand, setBrand] = useState<TenantBrand | null>(null);
const [activeTab, setActiveTab] = useState<'password' | 'code'>('password');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
const [showPwdModal, setShowPwdModal] = useState(false);
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
// ── 获取租户品牌信息 ──────────────────────────────────────────
useEffect(() => {
if (!tenantCode) return;
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
.then(r => r.json())
.then(data => { if (data.success && data.data) setBrand(data.data); })
.catch(() => {});
}, [tenantCode]);
// ── 验证码倒计时 ──────────────────────────────────────────────
useEffect(() => {
if (countdown <= 0) return;
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
return () => clearTimeout(t);
}, [countdown]);
// ── 登录后跳转路径 ────────────────────────────────────────────
const getRedirect = useCallback(() => {
const params = new URLSearchParams(location.search);
const r = params.get('redirect');
if (r) return decodeURIComponent(r);
return `/${tenantCode}/rvw`;
}, [location.search, tenantCode]);
useEffect(() => {
if (!user) return;
if (user.isDefaultPassword) { setShowPwdModal(true); return; }
navigate(getRedirect(), { replace: true });
}, [user, navigate, getRedirect]);
// ── 发送验证码 ────────────────────────────────────────────────
const handleSendCode = async () => {
if (!/^1[3-9]\d{9}$/.test(phone)) { setErrorMsg('请输入正确的手机号'); return; }
try {
await sendVerificationCode(phone, 'LOGIN');
setCountdown(60);
setErrorMsg('');
} catch (e: any) { setErrorMsg(e.message || '发送失败'); }
};
// ── 登录提交 ──────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg('');
try {
if (activeTab === 'password') {
await loginWithPassword(phone, password);
} else {
await loginWithCode(phone, code);
}
} catch (e: any) { setErrorMsg(e.message || '登录失败,请重试'); }
};
// ── 修改默认密码 ──────────────────────────────────────────────
const handleChangePwd = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg('');
if (newPwd.length < 6) { setErrorMsg('密码至少 6 位'); return; }
if (newPwd !== confirmPwd) { setErrorMsg('两次输入的密码不一致'); return; }
try {
await changePassword({ oldPassword: password, newPassword: newPwd } as ChangePasswordRequest);
setShowPwdModal(false);
navigate(getRedirect(), { replace: true });
} catch (e: any) { setErrorMsg(e.message || '修改失败'); }
};
// ── 品牌数据 ──────────────────────────────────────────────────
const journalName = brand?.name || 'Journal of Translational Internal Medicine';
const systemName = brand?.systemName || 'AI 智能审稿系统';
const logoUrl = brand?.logo;
// ── 通用 input class ─────────────────────────────────────────
const inputCls = [
'w-full px-4 py-2.5',
'border border-gray-300 rounded-lg',
'text-gray-800 placeholder-gray-400 text-sm',
'focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500',
'transition',
].join(' ');
return (
<div className="flex h-screen w-full overflow-hidden font-sans antialiased">
{/* ═══════════ 左侧品牌展示区 ═══════════════════════════════ */}
<div className="w-1/2 bg-[#0c4a6e] flex flex-col justify-center items-center text-white p-12 relative overflow-hidden">
{/* 网格背景纹理 */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)',
backgroundSize: '28px 28px',
}}
/>
<div className="z-10 text-center max-w-sm">
{/* Logo 圆形 */}
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
{logoUrl ? (
<img src={logoUrl} alt={journalName} className="w-16 h-16 object-contain rounded-full" />
) : (
<span className="text-[#0369a1]"><IconBookMedical /></span>
)}
</div>
<h1 className="text-3xl font-bold mb-3 leading-snug">{journalName}</h1>
<p className="text-lg text-sky-200 mb-8">{systemName} (Editor Portal)</p>
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-200">
</div>
</div>
</div>
{/* ═══════════ 右侧登录表单区 ════════════════════════════════ */}
<div className="w-1/2 flex items-center justify-center bg-white">
<div className="w-96">
<h2 className="text-3xl font-bold mb-2 text-gray-800"></h2>
<p className="text-gray-500 mb-6 text-sm"></p>
{/* ── Tab 切换(分段选择器风格,对照原型图)── */}
<div className="flex mb-7 bg-gray-100 rounded-lg p-1">
{(['password', 'code'] as const).map(tab => (
<button
key={tab}
type="button"
onClick={() => { setActiveTab(tab); setErrorMsg(''); }}
className={[
'flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200',
activeTab === tab
? 'bg-white text-[#0284c7] shadow-sm ring-1 ring-gray-200'
: 'text-gray-500 hover:text-gray-700',
].join(' ')}
>
{tab === 'password' ? '密码登录' : '验证码登录'}
</button>
))}
</div>
{/* ── 错误提示 ── */}
{errorMsg && (
<div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{errorMsg}
</div>
)}
{/* ── 表单 ── */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* 手机号 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="请输入手机号"
maxLength={11}
required
className={inputCls}
/>
</div>
{/* 密码 */}
{activeTab === 'password' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="请输入密码"
required
className={inputCls}
/>
</div>
)}
{/* 验证码 */}
{activeTab === 'code' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={e => setCode(e.target.value)}
placeholder="6 位验证码"
maxLength={6}
required
className={[inputCls, 'flex-1'].join(' ')}
/>
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
className={[
'px-4 py-2.5 rounded-lg text-sm font-medium border transition whitespace-nowrap',
countdown > 0
? 'border-gray-300 text-gray-400 cursor-not-allowed'
: 'border-[#0284c7] text-[#0284c7] hover:bg-sky-50',
].join(' ')}
>
{countdown > 0 ? `${countdown}s 后重发` : '发送验证码'}
</button>
</div>
</div>
)}
{/* 记住我 / 忘记密码 */}
{activeTab === 'password' && (
<div className="flex items-center justify-between">
<label className="flex items-center cursor-pointer select-none">
<input type="checkbox" className="rounded border-gray-300 text-[#0284c7] mr-2" />
<span className="text-sm text-gray-600"></span>
</label>
<a href="#" className="text-sm text-[#0284c7] hover:text-[#0369a1] transition-colors">
</a>
</div>
)}
{/* 提交按钮 */}
<button
type="submit"
disabled={isLoading}
className={[
'w-full flex items-center justify-center gap-2',
'bg-[#0284c7] text-white font-medium py-2.5 rounded-lg',
'hover:bg-[#0369a1] transition-colors',
'shadow-[0_4px_14px_rgba(2,132,199,0.35)]',
'disabled:opacity-60 disabled:cursor-not-allowed',
].join(' ')}
>
{isLoading ? <IconSpinner /> : null}
{!isLoading && <IconArrowRight />}
</button>
</form>
</div>
</div>
{/* ═══════════ 首次登录修改密码 Modal ════════════════════════ */}
{showPwdModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 w-[400px] shadow-2xl">
<h3 className="text-xl font-bold text-gray-800 mb-1"></h3>
<p className="text-gray-500 text-sm mb-6"></p>
{errorMsg && (
<div className="mb-4 px-4 py-2 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{errorMsg}
</div>
)}
<form onSubmit={handleChangePwd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)}
required minLength={6} placeholder="至少 6 位"
className={inputCls}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)}
required placeholder="再次输入新密码"
className={inputCls}
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => { setShowPwdModal(false); navigate(getRedirect(), { replace: true }); }}
className="flex-1 py-2.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 text-sm transition"
>
</button>
<button
type="submit"
className="flex-1 py-2.5 bg-[#0284c7] text-white rounded-lg text-sm font-medium hover:bg-[#0369a1] transition"
>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -18,7 +18,14 @@ import {
Table,
message,
Spin,
InputNumber,
Divider,
Alert,
Typography,
} from 'antd';
const { TextArea } = Input;
const { Text } = Typography;
import {
ArrowLeftOutlined,
EditOutlined,
@@ -37,11 +44,15 @@ import {
updateTenant,
configureModules,
createTenant,
fetchRvwConfig,
saveRvwConfig,
type TenantDetail,
type TenantModuleConfig,
type TenantType,
type CreateTenantRequest,
type UpdateTenantRequest,
type TenantRvwConfig,
type UpdateRvwConfigRequest,
} from './api/tenantApi';
const { Option } = Select;
@@ -83,7 +94,13 @@ const TenantDetailPage = () => {
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'info');
const [moduleConfigs, setModuleConfigs] = useState<TenantModuleConfig[]>([]);
// RVW V4.0 审稿配置
const [rvwConfig, setRvwConfig] = useState<TenantRvwConfig | null>(null);
const [rvwConfigLoading, setRvwConfigLoading] = useState(false);
const [rvwConfigSaving, setRvwConfigSaving] = useState(false);
const [form] = Form.useForm();
const [rvwForm] = Form.useForm();
// 加载租户详情
const loadTenant = async () => {
@@ -114,6 +131,72 @@ const TenantDetailPage = () => {
loadTenant();
}, [id]);
// 加载 RVW 审稿配置
const loadRvwConfig = async () => {
if (!id || isNew) return;
setRvwConfigLoading(true);
try {
const data = await fetchRvwConfig(id);
setRvwConfig(data);
if (data) {
rvwForm.setFieldsValue({
methodologyExpertPrompt: data.methodologyExpertPrompt || '',
methodologyHandlebarsTemplate: data.methodologyHandlebarsTemplate || '',
dataForensicsLevel: data.dataForensicsLevel || 'L2',
clinicalExpertPrompt: data.clinicalExpertPrompt || '',
finer_feasibility: data.finerWeights?.feasibility ?? 20,
finer_innovation: data.finerWeights?.innovation ?? 20,
finer_ethics: data.finerWeights?.ethics ?? 20,
finer_relevance: data.finerWeights?.relevance ?? 20,
finer_novelty: data.finerWeights?.novelty ?? 20,
});
}
} catch (err: any) {
message.error(err.message || '加载审稿配置失败');
} finally {
setRvwConfigLoading(false);
}
};
// 保存 RVW 审稿配置
const handleSaveRvwConfig = async () => {
if (!id || isNew) return;
try {
const values = await rvwForm.validateFields();
setRvwConfigSaving(true);
const finerWeights = {
feasibility: values.finer_feasibility,
innovation: values.finer_innovation,
ethics: values.finer_ethics,
relevance: values.finer_relevance,
novelty: values.finer_novelty,
};
const total = Object.values(finerWeights).reduce((a, b) => a + Number(b), 0);
if (Math.abs(total - 100) > 1) {
message.error(`FINER 权重之和应为 100当前为 ${total}`);
return;
}
const payload: UpdateRvwConfigRequest = {
methodologyExpertPrompt: values.methodologyExpertPrompt || null,
methodologyHandlebarsTemplate: values.methodologyHandlebarsTemplate || null,
dataForensicsLevel: values.dataForensicsLevel,
clinicalExpertPrompt: values.clinicalExpertPrompt || null,
finerWeights,
};
const saved = await saveRvwConfig(id, payload);
setRvwConfig(saved);
message.success('审稿配置已保存');
} catch (err: any) {
if (err.errorFields) return;
message.error(err.message || '保存审稿配置失败');
} finally {
setRvwConfigSaving(false);
}
};
// 保存租户信息
const handleSave = async () => {
try {
@@ -292,7 +375,6 @@ const TenantDetailPage = () => {
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'info',
@@ -431,7 +513,123 @@ const TenantDetailPage = () => {
</div>
),
},
{
key: 'rvw-config',
label: '智能审稿配置',
disabled: isNew,
children: (
<Spin spinning={rvwConfigLoading}>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="每个期刊租户可独立配置审查标准。留空则使用系统默认值,填写后将覆盖该租户的审查行为。"
/>
<Form form={rvwForm} layout="vertical">
{/* Panel A稿约规范 */}
<Divider orientation="left">A. 稿</Divider>
<Alert
type="warning"
showIcon
message="稿约规则编辑器将在 P1 版本提供可视化配置界面,当前暂不支持自定义。"
style={{ marginBottom: 16 }}
/>
{/* Panel B方法学评估 */}
<Divider orientation="left">B. </Divider>
<Form.Item
name="methodologyExpertPrompt"
label="方法学专家评判标准(业务 Prompt"
extra="专家可在此自由描述审查标准,无需懂 JSON。系统会自动拼装到 AI 指令中。"
>
<TextArea
rows={12}
placeholder="你是一位资深临床研究方法学专家与医学统计学审稿人常年为《The Lancet》、《JAMA》或《中华医学杂志》等国内外顶尖期刊提供审稿意见..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item
name="methodologyHandlebarsTemplate"
label="方法学报告展示模板Handlebars留空使用系统默认"
extra="支持 Handlebars 语法,可引用 {{overall_score}}、{{conclusion}}、{{summary}}、{{checkpoints}} 等变量。"
>
<TextArea
rows={8}
placeholder="# 方法学评估报告&#10;&#10;**综合评分:** {{overall_score}} 分 ..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
{/* Panel C数据验证 */}
<Divider orientation="left">C. </Divider>
<Form.Item
name="dataForensicsLevel"
label="数据验证深度"
rules={[{ required: true }]}
>
<Select style={{ width: 240 }}>
<Select.Option value="L1">L1 + </Select.Option>
<Select.Option value="L2">L2 </Select.Option>
<Select.Option value="L3">L3 </Select.Option>
</Select>
</Form.Item>
{/* Panel D临床专业评估 */}
<Divider orientation="left">D. </Divider>
<Form.Item
name="clinicalExpertPrompt"
label="临床首席科学家评判标准(业务 Prompt"
extra="同方法学,可自由描述临床科学评审标准。"
>
<TextArea
rows={10}
placeholder="你作为临床首席科学家,将对一份手稿是否值得接收进行评价..."
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item label="FINER 五维权重(总计应为 100">
<Space size="large" wrap>
{[
{ key: 'finer_feasibility', label: '可行性 F' },
{ key: 'finer_innovation', label: '创新性 I' },
{ key: 'finer_ethics', label: '伦理性 E' },
{ key: 'finer_relevance', label: '相关性 R' },
{ key: 'finer_novelty', label: '新颖性 N' },
].map(({ key, label }) => (
<Form.Item key={key} name={key} label={label} style={{ marginBottom: 0 }}>
<InputNumber min={0} max={100} step={5} addonAfter="%" />
</Form.Item>
))}
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
100 20
</Text>
</div>
</Form.Item>
<Form.Item>
<Button
type="primary"
onClick={handleSaveRvwConfig}
loading={rvwConfigSaving}
style={{ backgroundColor: PRIMARY_COLOR, borderColor: PRIMARY_COLOR }}
>
稿
</Button>
</Form.Item>
</Form>
</Spin>
),
},
]}
onChange={(key) => {
setActiveTab(key);
if (key === 'rvw-config' && !rvwConfig && !rvwConfigLoading) {
loadRvwConfig();
}
}}
/>
</Card>
</div>

View File

@@ -244,6 +244,68 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
return result.data;
}
// ==================== RVW Config API ====================
/** 租户审稿配置 */
export interface TenantRvwConfig {
id: string;
tenantId: string;
editorialRules: unknown | null;
methodologyExpertPrompt: string | null;
methodologyHandlebarsTemplate: string | null;
dataForensicsLevel: string;
finerWeights: Record<string, number> | null;
clinicalExpertPrompt: string | null;
createdAt: string;
updatedAt: string;
}
/** 更新审稿配置请求 */
export interface UpdateRvwConfigRequest {
editorialRules?: unknown | null;
methodologyExpertPrompt?: string | null;
methodologyHandlebarsTemplate?: string | null;
dataForensicsLevel?: string;
finerWeights?: Record<string, number> | null;
clinicalExpertPrompt?: string | null;
}
/**
* 获取租户智能审稿配置
*/
export async function fetchRvwConfig(tenantId: string): Promise<TenantRvwConfig | null> {
const response = await fetch(`${API_BASE}/${tenantId}/rvw-config`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '获取审稿配置失败');
}
const result = await response.json();
return result.data;
}
/**
* 保存UPSERT租户智能审稿配置
*/
export async function saveRvwConfig(tenantId: string, data: UpdateRvwConfigRequest): Promise<TenantRvwConfig> {
const response = await fetch(`${API_BASE}/${tenantId}/rvw-config`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '保存审稿配置失败');
}
const result = await response.json();
return result.data;
}