feat(rvw): complete tenant portal polish and ops assignment fixes

Finalize RVW tenant portal UX and reliability updates by aligning login/profile interactions, stabilizing SMS code sends in weak-network scenarios, and fixing multi-tenant assignment payload handling to prevent runtime errors. Refresh RVW status and deployment checklist docs with SAE routing, frontend image build, and post-release validation guidance.

Made-with: Cursor
This commit is contained in:
2026-03-15 18:22:01 +08:00
parent 83e395824b
commit 707f783229
20 changed files with 498 additions and 153 deletions

View File

@@ -288,6 +288,13 @@ export async function assignTenantToUser(
});
}
if (error.message.includes('参数格式错误')) {
return reply.status(400).send({
code: 400,
message: error.message,
});
}
return reply.status(500).send({
code: 500,
message: error.message || '分配租户失败',

View File

@@ -28,6 +28,31 @@ const prisma = new PrismaClient();
// 默认密码
const DEFAULT_PASSWORD = '123456';
/**
* 将模块参数标准化为字符串数组
* - undefined/null => undefined
* - string => [string]
* - string[] => string[]
* 其他类型抛错,避免出现 map/filter 运行时错误
*/
function normalizeModuleCodes(
raw: unknown,
fieldName: string = 'modules'
): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) {
return raw
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
}
if (typeof raw === 'string') {
const code = raw.trim();
return code ? [code] : [];
}
throw new Error(`${fieldName} 参数格式错误,应为字符串数组`);
}
/**
* 根据用户ID获取用户的 departmentId
*/
@@ -340,6 +365,10 @@ export async function getUserById(userId: string, scope: UserQueryScope): Promis
* 创建用户
*/
export async function createUser(data: CreateUserRequest, creatorId: string): Promise<UserDetail> {
const normalizedAllowedModules = normalizeModuleCodes(
(data as any).allowedModules,
'allowedModules'
);
// 检查手机号是否已存在
const existingUser = await prisma.user.findUnique({
where: { phone: data.phone },
@@ -411,9 +440,9 @@ export async function createUser(data: CreateUserRequest, creatorId: string): Pr
});
// 如果指定了模块权限,创建用户模块记录
if (data.allowedModules && data.allowedModules.length > 0) {
if (normalizedAllowedModules && normalizedAllowedModules.length > 0) {
await tx.user_modules.createMany({
data: data.allowedModules.map((moduleCode) => ({
data: normalizedAllowedModules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
@@ -564,6 +593,10 @@ export async function assignTenantToUser(
data: AssignTenantRequest,
assignerId: string
): Promise<void> {
const normalizedAllowedModules = normalizeModuleCodes(
(data as any).allowedModules,
'allowedModules'
);
// 检查用户是否存在
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
@@ -602,9 +635,9 @@ export async function assignTenantToUser(
});
// 如果指定了模块权限,创建用户模块记录
if (data.allowedModules && data.allowedModules.length > 0) {
if (normalizedAllowedModules && normalizedAllowedModules.length > 0) {
await tx.user_modules.createMany({
data: data.allowedModules.map((moduleCode) => ({
data: normalizedAllowedModules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
@@ -684,6 +717,7 @@ export async function updateUserModules(
data: UpdateUserModulesRequest,
updaterId: string
): Promise<void> {
const normalizedModules = normalizeModuleCodes((data as any).modules, 'modules') || [];
// 检查用户是否是该租户成员
const membership = await prisma.tenant_members.findUnique({
where: {
@@ -704,7 +738,7 @@ export async function updateUserModules(
select: { code: true },
});
const validModuleCodes = allModules.map((m) => m.code);
const invalidModules = data.modules.filter((m) => !validModuleCodes.includes(m));
const invalidModules = normalizedModules.filter((m) => !validModuleCodes.includes(m));
if (invalidModules.length > 0) {
throw new Error(`以下模块代码不存在: ${invalidModules.join(', ')}`);
}
@@ -720,9 +754,9 @@ export async function updateUserModules(
});
// 创建新的模块权限
if (data.modules.length > 0) {
if (normalizedModules.length > 0) {
await tx.user_modules.createMany({
data: data.modules.map((moduleCode) => ({
data: normalizedModules.map((moduleCode) => ({
id: uuidv4(),
user_id: userId,
tenant_id: data.tenantId,
@@ -736,7 +770,7 @@ export async function updateUserModules(
logger.info('[UserService] User modules updated', {
userId,
tenantId: data.tenantId,
modules: data.modules,
modules: normalizedModules,
updatedBy: updaterId,
});
}

View File

@@ -11,7 +11,7 @@ import { ModelType } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';
import { getPromptService } from '../../../common/prompt/index.js';
import { composeRvwSystemPrompt } from './promptProtocols.js';
import { composeRvwSystemPrompt, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
export interface ClinicalReviewResult {
report: string;
@@ -40,6 +40,7 @@ export async function reviewClinical(
businessPrompt = result.content;
isDraft = result.isDraft;
}
businessPrompt = sanitizeRvwBusinessPrompt('clinical', businessPrompt);
if (isDraft) {
logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt调试模式', { userId });

View File

@@ -14,7 +14,7 @@ import { prisma } from '../../../config/database.js';
import { getPromptService } from '../../../common/prompt/index.js';
import { EditorialReview } from '../types/index.js';
import { parseJSONFromLLMResponse } from './utils.js';
import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js';
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
function isValidEditorialReview(result: unknown): result is EditorialReview {
if (!result || typeof result !== 'object') return false;
@@ -88,6 +88,7 @@ export async function reviewEditorialStandards(
isDraft = result.isDraft;
}
}
businessPrompt = sanitizeRvwBusinessPrompt('editorial', businessPrompt);
if (isDraft) {
logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt调试模式', { userId });

View File

@@ -15,7 +15,7 @@ import { prisma } from '../../../config/database.js';
import { getPromptService } from '../../../common/prompt/index.js';
import { MethodologyCheckpoint, MethodologyIssue, MethodologyPart, MethodologyReview } from '../types/index.js';
import { parseJSONFromLLMResponse } from './utils.js';
import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js';
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const;
type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number];
@@ -464,6 +464,7 @@ export async function reviewMethodology(
source: 'prompt_service',
});
}
businessPrompt = sanitizeRvwBusinessPrompt('methodology', businessPrompt);
const llmAdapter = LLMFactory.getAdapter(modelType);
logger.info('[RVW:Methodology] 开始分治并行评估', {

View File

@@ -107,3 +107,57 @@ export function getRvwProtocol(channel: RvwPromptChannel): string {
return RVW_PROTOCOLS[channel];
}
/**
* RVW 业务 Prompt 轻量净化器(止血版)
* 目的:避免运营端业务 Prompt 中“输出格式指令”与研发固化协议冲突
*/
export function sanitizeRvwBusinessPrompt(channel: RvwPromptChannel, businessPrompt: string): string {
const raw = (businessPrompt || '').trim();
if (!raw) return '';
const lines = raw.split(/\r?\n/);
const out: string[] = [];
let skipOutputSection = false;
for (const line of lines) {
const normalized = line.trim().toLowerCase();
// 进入“输出要求/输出格式”段后,后续内容通常是格式指令,直接截断
if (
/^输出要求/.test(line.trim()) ||
/^输出格式/.test(line.trim()) ||
/^report format/.test(normalized) ||
/^output requirements?/.test(normalized) ||
/^output format/.test(normalized)
) {
skipOutputSection = true;
continue;
}
if (skipOutputSection) continue;
// 删除高风险格式约束行(保留业务评判标准)
const shouldDrop =
/请按以下格式输出/.test(line) ||
/严格仅输出\s*json/i.test(line) ||
/仅输出\s*json/i.test(line) ||
/不要\s*markdown/i.test(line) ||
/不要代码块/i.test(line) ||
/返回结构化\s*json/i.test(line) ||
/审稿结论/.test(line) ||
/总体评价/.test(line) ||
/详细问题清单与建议/.test(line);
// 临床模块本身是 markdown 报告结构,允许保留“总体评价/审稿结论”等业务描述
if (channel === 'clinical' && /审稿结论|总体评价|详细问题清单与建议/.test(line)) {
out.push(line);
continue;
}
if (!shouldDrop) out.push(line);
}
const sanitized = out.join('\n').trim();
return sanitized || raw;
}

View File

@@ -4,6 +4,7 @@
*/
import { MethodologyReview, MethodologyStatus } from '../types/index.js';
import { jsonrepair } from 'jsonrepair';
/**
* 从LLM响应中解析JSON
@@ -14,13 +15,27 @@ export function parseJSONFromLLMResponse<T>(content: string): T {
// 1. 尝试直接解析
return JSON.parse(content) as T;
} catch {
// 1.1 先尝试 jsonrepair处理尾逗号、引号缺失等常见脏 JSON
try {
const repaired = jsonrepair(content);
return JSON.parse(repaired) as T;
} catch {
// 继续后续提取策略
}
// 2. 尝试提取```json代码块
const jsonMatch = content.match(/```json\s*\n?([\s\S]*?)\n?```/);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[1].trim()) as T;
} catch {
// 继续尝试其他方法
// 尝试修复代码块 JSON
try {
const repaired = jsonrepair(jsonMatch[1].trim());
return JSON.parse(repaired) as T;
} catch {
// 继续尝试其他方法
}
}
}
@@ -30,7 +45,12 @@ export function parseJSONFromLLMResponse<T>(content: string): T {
try {
return JSON.parse(objectMatch[1]) as T;
} catch {
// 继续尝试其他方法
try {
const repaired = jsonrepair(objectMatch[1]);
return JSON.parse(repaired) as T;
} catch {
// 继续尝试其他方法
}
}
}
@@ -39,7 +59,12 @@ export function parseJSONFromLLMResponse<T>(content: string): T {
try {
return JSON.parse(arrayMatch[1]) as T;
} catch {
// 失败
try {
const repaired = jsonrepair(arrayMatch[1]);
return JSON.parse(repaired) as T;
} catch {
// 失败
}
}
}

View File

@@ -31,7 +31,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { randomUUID } from 'crypto';
import { composeRvwSystemPrompt } from '../../services/promptProtocols.js';
import { composeRvwSystemPrompt, sanitizeRvwBusinessPrompt } from '../../services/promptProtocols.js';
// 默认关闭规则验证,仅做“表格提取 + LLM 判断”
const RULE_BASED_VALIDATION_ENABLED = process.env.RVW_FORENSICS_RULES_ENABLED === 'true';
@@ -304,6 +304,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
);
businessPrompt = promptResult.content;
}
businessPrompt = sanitizeRvwBusinessPrompt('data_validation', businessPrompt);
// 拼接所有表格为 user message
const tableTexts: string[] = [];