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:
@@ -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 || '分配租户失败',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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] 开始分治并行评估', {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 失败
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user