feat(admin): Complete Phase 3.5.1-3.5.4 Prompt Management System (83%)

Summary:
- Implement Prompt management infrastructure and core services
- Build admin portal frontend with light theme
- Integrate CodeMirror 6 editor for non-technical users

Phase 3.5.1: Infrastructure Setup
- Create capability_schema for Prompt storage
- Add prompt_templates and prompt_versions tables
- Add prompt:view/edit/debug/publish permissions
- Migrate RVW prompts to database (RVW_EDITORIAL, RVW_METHODOLOGY)

Phase 3.5.2: PromptService Core
- Implement gray preview logic (DRAFT for debuggers, ACTIVE for users)
- Module-level debug control (setDebugMode)
- Handlebars template rendering
- Variable extraction and validation (extractVariables, validateVariables)
- Three-level disaster recovery (database -> cache -> hardcoded fallback)

Phase 3.5.3: Management API
- 8 RESTful endpoints (/api/admin/prompts/*)
- Permission control (PROMPT_ENGINEER can edit, SUPER_ADMIN can publish)

Phase 3.5.4: Frontend Management UI
- Build admin portal architecture (AdminLayout, OrgLayout)
- Add route system (/admin/*, /org/*)
- Implement PromptListPage (filter, search, debug switch)
- Implement PromptEditor (CodeMirror 6 simplified for clinical users)
- Implement PromptEditorPage (edit, save, publish, test, version history)

Technical Details:
- Backend: 6 files, ~2044 lines (prompt.service.ts 596 lines)
- Frontend: 9 files, ~1735 lines (PromptEditorPage.tsx 399 lines)
- CodeMirror 6: Line numbers, auto-wrap, variable highlight, search, undo/redo
- Chinese-friendly: 15px font, 1.8 line-height, system fonts

Next Step: Phase 3.5.5 - Integrate RVW module with PromptService

Tested: Backend API tests passed (8/8), Frontend pending user testing
Status: Ready for Phase 3.5.5 RVW integration
This commit is contained in:
2026-01-11 21:25:16 +08:00
parent cdfbc9927a
commit 5523ef36ea
297 changed files with 15914 additions and 1266 deletions

View File

@@ -0,0 +1,235 @@
/**
* Auth Controller
*
* 认证相关的 HTTP 请求处理
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { authService } from './auth.service.js';
import type { PasswordLoginRequest, VerificationCodeLoginRequest, ChangePasswordRequest } from './auth.service.js';
import { logger } from '../logging/index.js';
/**
* 密码登录
*
* POST /api/v1/auth/login/password
*/
export async function loginWithPassword(
request: FastifyRequest<{ Body: PasswordLoginRequest }>,
reply: FastifyReply
) {
try {
const result = await authService.loginWithPassword(request.body);
return reply.status(200).send({
success: true,
data: result,
});
} catch (error) {
const message = error instanceof Error ? error.message : '登录失败';
logger.warn('登录失败', { error: message, phone: request.body.phone });
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message,
});
}
}
/**
* 验证码登录
*
* POST /api/v1/auth/login/code
*/
export async function loginWithVerificationCode(
request: FastifyRequest<{ Body: VerificationCodeLoginRequest }>,
reply: FastifyReply
) {
try {
const result = await authService.loginWithVerificationCode(request.body);
return reply.status(200).send({
success: true,
data: result,
});
} catch (error) {
const message = error instanceof Error ? error.message : '登录失败';
logger.warn('验证码登录失败', { error: message, phone: request.body.phone });
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message,
});
}
}
/**
* 发送验证码
*
* POST /api/v1/auth/verification-code
*/
export async function sendVerificationCode(
request: FastifyRequest<{ Body: { phone: string; type?: 'LOGIN' | 'RESET_PASSWORD' } }>,
reply: FastifyReply
) {
try {
const { phone, type = 'LOGIN' } = request.body;
const result = await authService.sendVerificationCode(phone, type);
return reply.status(200).send({
success: true,
data: {
message: '验证码已发送',
expiresIn: result.expiresIn,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : '发送失败';
logger.warn('发送验证码失败', { error: message, phone: request.body.phone });
return reply.status(400).send({
success: false,
error: 'BadRequest',
message,
});
}
}
/**
* 获取当前用户信息
*
* GET /api/v1/auth/me
*/
export async function getCurrentUser(
request: FastifyRequest,
reply: FastifyReply
) {
try {
if (!request.user) {
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message: '未认证',
});
}
const user = await authService.getCurrentUser(request.user.userId);
return reply.status(200).send({
success: true,
data: user,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取用户信息失败';
logger.error('获取用户信息失败', { error: message, userId: request.user?.userId });
return reply.status(500).send({
success: false,
error: 'InternalServerError',
message,
});
}
}
/**
* 修改密码
*
* POST /api/v1/auth/change-password
*/
export async function changePassword(
request: FastifyRequest<{ Body: ChangePasswordRequest }>,
reply: FastifyReply
) {
try {
if (!request.user) {
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message: '未认证',
});
}
await authService.changePassword(request.user.userId, request.body);
return reply.status(200).send({
success: true,
data: {
message: '密码修改成功',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : '修改密码失败';
logger.warn('修改密码失败', { error: message, userId: request.user?.userId });
return reply.status(400).send({
success: false,
error: 'BadRequest',
message,
});
}
}
/**
* 刷新 Token
*
* POST /api/v1/auth/refresh
*/
export async function refreshToken(
request: FastifyRequest<{ Body: { refreshToken: string } }>,
reply: FastifyReply
) {
try {
const { refreshToken } = request.body;
if (!refreshToken) {
return reply.status(400).send({
success: false,
error: 'BadRequest',
message: '请提供 refreshToken',
});
}
const tokens = await authService.refreshToken(refreshToken);
return reply.status(200).send({
success: true,
data: tokens,
});
} catch (error) {
const message = error instanceof Error ? error.message : '刷新Token失败';
logger.warn('刷新Token失败', { error: message });
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message,
});
}
}
/**
* 登出
*
* POST /api/v1/auth/logout
*
* 注意JWT 是无状态的登出主要是前端清除Token
* 如果需要服务端登出,需要维护 Token 黑名单
*/
export async function logout(
request: FastifyRequest,
reply: FastifyReply
) {
// TODO: 如果需要服务端登出可以将Token加入黑名单
logger.info('用户登出', { userId: request.user?.userId });
return reply.status(200).send({
success: true,
data: {
message: '登出成功',
},
});
}

View File

@@ -0,0 +1,256 @@
/**
* Auth Middleware
*
* Fastify 认证中间件
*
* 功能:
* - 验证 JWT Token
* - 注入用户信息到请求
* - 权限检查
*/
import { FastifyRequest, FastifyReply, FastifyInstance, preHandlerHookHandler } from 'fastify';
import { jwtService } from './jwt.service.js';
import type { DecodedToken } from './jwt.service.js';
import { logger } from '../logging/index.js';
/**
* 扩展 Fastify Request 类型
*/
declare module 'fastify' {
interface FastifyRequest {
user?: DecodedToken;
}
}
/**
* 认证错误类
*/
export class AuthenticationError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number = 401) {
super(message);
this.name = 'AuthenticationError';
this.statusCode = statusCode;
}
}
/**
* 授权错误类
*/
export class AuthorizationError extends Error {
public statusCode: number;
constructor(message: string) {
super(message);
this.name = 'AuthorizationError';
this.statusCode = 403;
}
}
/**
* 认证中间件
*
* 验证 JWT Token 并注入用户信息
*/
export const authenticate: preHandlerHookHandler = async (
request: FastifyRequest,
reply: FastifyReply
) => {
try {
// 1. 获取 Authorization Header
const authHeader = request.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (!token) {
throw new AuthenticationError('未提供认证令牌');
}
// 2. 验证 Token
const decoded = jwtService.verifyToken(token);
// 3. 注入用户信息
request.user = decoded;
logger.debug('认证成功', {
userId: decoded.userId,
role: decoded.role,
tenantId: decoded.tenantId,
});
} catch (error) {
if (error instanceof AuthenticationError) {
return reply.status(error.statusCode).send({
error: 'Unauthorized',
message: error.message,
});
}
if (error instanceof Error) {
return reply.status(401).send({
error: 'Unauthorized',
message: error.message,
});
}
return reply.status(401).send({
error: 'Unauthorized',
message: '认证失败',
});
}
};
/**
* 可选认证中间件
*
* 如果有 Token 则验证,没有也放行
*/
export const optionalAuthenticate: preHandlerHookHandler = async (
request: FastifyRequest,
_reply: FastifyReply
) => {
try {
const authHeader = request.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (token) {
const decoded = jwtService.verifyToken(token);
request.user = decoded;
}
} catch (error) {
// 可选认证,忽略错误
logger.debug('可选认证Token无效或已过期');
}
};
/**
* 角色检查中间件工厂
*
* @param allowedRoles 允许的角色列表
*/
export function requireRoles(...allowedRoles: string[]): preHandlerHookHandler {
return async (request: FastifyRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: '未认证',
});
}
if (!allowedRoles.includes(request.user.role)) {
logger.warn('权限不足', {
userId: request.user.userId,
role: request.user.role,
requiredRoles: allowedRoles,
});
return reply.status(403).send({
error: 'Forbidden',
message: '权限不足',
});
}
};
}
/**
* 权限检查中间件工厂
*
* @param requiredPermission 需要的权限code
*/
export function requirePermission(requiredPermission: string): preHandlerHookHandler {
return async (request: FastifyRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: '未认证',
});
}
// TODO: 从缓存或数据库获取用户权限
// 目前简化处理:超级管理员拥有所有权限
if (request.user.role === 'SUPER_ADMIN') {
return;
}
// TODO: 实现权限检查逻辑
// const hasPermission = await checkUserPermission(request.user.userId, requiredPermission);
// if (!hasPermission) {
// return reply.status(403).send({
// error: 'Forbidden',
// message: `需要权限: ${requiredPermission}`,
// });
// }
};
}
/**
* 租户检查中间件
*
* 确保用户只能访问自己租户的数据
*/
export const requireSameTenant: preHandlerHookHandler = async (
request: FastifyRequest,
reply: FastifyReply
) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: '未认证',
});
}
// 超级管理员可以访问所有租户
if (request.user.role === 'SUPER_ADMIN') {
return;
}
// 从请求参数或body中获取tenantId
const requestTenantId =
(request.params as any)?.tenantId ||
(request.body as any)?.tenantId ||
(request.query as any)?.tenantId;
if (requestTenantId && requestTenantId !== request.user.tenantId) {
logger.warn('租户不匹配', {
userId: request.user.userId,
userTenantId: request.user.tenantId,
requestTenantId,
});
return reply.status(403).send({
error: 'Forbidden',
message: '无权访问此租户数据',
});
}
};
/**
* 注册认证插件到 Fastify
*/
export async function registerAuthPlugin(fastify: FastifyInstance): Promise<void> {
// 添加 decorate 以支持 request.user
fastify.decorateRequest('user', undefined);
// 注册全局错误处理
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof AuthenticationError) {
return reply.status(error.statusCode).send({
error: 'Unauthorized',
message: error.message,
});
}
if (error instanceof AuthorizationError) {
return reply.status(error.statusCode).send({
error: 'Forbidden',
message: error.message,
});
}
// 其他错误交给默认处理
throw error;
});
logger.info('✅ 认证插件已注册');
}

View File

@@ -0,0 +1,140 @@
/**
* Auth Routes
*
* 认证相关路由定义
*/
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import {
loginWithPassword,
loginWithVerificationCode,
sendVerificationCode,
getCurrentUser,
changePassword,
refreshToken,
logout,
} from './auth.controller.js';
import { authenticate } from './auth.middleware.js';
/**
* 登录请求 Schema
*/
const passwordLoginSchema = {
body: {
type: 'object',
required: ['phone', 'password'],
properties: {
phone: { type: 'string', pattern: '^1[3-9]\\d{9}$', description: '手机号' },
password: { type: 'string', minLength: 6, description: '密码' },
},
},
};
const codeLoginSchema = {
body: {
type: 'object',
required: ['phone', 'code'],
properties: {
phone: { type: 'string', pattern: '^1[3-9]\\d{9}$', description: '手机号' },
code: { type: 'string', minLength: 6, maxLength: 6, description: '验证码' },
},
},
};
const sendCodeSchema = {
body: {
type: 'object',
required: ['phone'],
properties: {
phone: { type: 'string', pattern: '^1[3-9]\\d{9}$', description: '手机号' },
type: { type: 'string', enum: ['LOGIN', 'RESET_PASSWORD'], default: 'LOGIN' },
},
},
};
const changePasswordSchema = {
body: {
type: 'object',
required: ['newPassword', 'confirmPassword'],
properties: {
oldPassword: { type: 'string', description: '原密码(可选,验证码修改时不需要)' },
newPassword: { type: 'string', minLength: 6, description: '新密码' },
confirmPassword: { type: 'string', minLength: 6, description: '确认密码' },
},
},
};
const refreshTokenSchema = {
body: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string', description: 'Refresh Token' },
},
},
};
/**
* 注册认证路由
*/
export async function authRoutes(
fastify: FastifyInstance,
_options: FastifyPluginOptions
): Promise<void> {
// ========== 公开路由(无需认证)==========
/**
* 密码登录
*/
fastify.post('/login/password', {
schema: passwordLoginSchema,
}, loginWithPassword);
/**
* 验证码登录
*/
fastify.post('/login/code', {
schema: codeLoginSchema,
}, loginWithVerificationCode);
/**
* 发送验证码
*/
fastify.post('/verification-code', {
schema: sendCodeSchema,
}, sendVerificationCode);
/**
* 刷新 Token
*/
fastify.post('/refresh', {
schema: refreshTokenSchema,
}, refreshToken);
// ========== 需要认证的路由 ==========
/**
* 获取当前用户信息
*/
fastify.get('/me', {
preHandler: [authenticate],
}, getCurrentUser);
/**
* 修改密码
*/
fastify.post('/change-password', {
preHandler: [authenticate],
schema: changePasswordSchema,
}, changePassword as any);
/**
* 登出
*/
fastify.post('/logout', {
preHandler: [authenticate],
}, logout);
}
export default authRoutes;

View File

@@ -0,0 +1,436 @@
/**
* Auth Service
*
* 认证业务逻辑
*
* 支持两种登录方式:
* 1. 手机号 + 验证码
* 2. 手机号 + 密码
*/
import bcrypt from 'bcryptjs';
import { prisma } from '../../config/database.js';
import { jwtService } from './jwt.service.js';
import type { JWTPayload, TokenResponse } from './jwt.service.js';
import { logger } from '../logging/index.js';
/**
* 登录请求 - 密码方式
*/
export interface PasswordLoginRequest {
phone: string;
password: string;
}
/**
* 登录请求 - 验证码方式
*/
export interface VerificationCodeLoginRequest {
phone: string;
code: string;
}
/**
* 修改密码请求
*/
export interface ChangePasswordRequest {
oldPassword?: string; // 如果用验证码修改,可不提供
newPassword: string;
confirmPassword: string;
}
/**
* 用户信息响应
*/
export interface UserInfoResponse {
id: string;
phone: string;
name: string;
email?: string | null;
role: string;
tenantId: string;
tenantCode?: string;
tenantName?: string;
departmentId?: string | null;
departmentName?: string | null;
isDefaultPassword: boolean;
permissions: string[];
}
/**
* 登录响应
*/
export interface LoginResponse {
user: UserInfoResponse;
tokens: TokenResponse;
}
/**
* Auth Service 类
*/
export class AuthService {
/**
* 密码登录
*/
async loginWithPassword(request: PasswordLoginRequest): Promise<LoginResponse> {
const { phone, password } = request;
// 1. 查找用户
const user = await prisma.User.findUnique({
where: { phone },
include: {
tenants: true,
departments: true,
},
});
if (!user) {
logger.warn('登录失败:用户不存在', { phone });
throw new Error('手机号或密码错误');
}
// 2. 验证密码
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
logger.warn('登录失败:密码错误', { phone, userId: user.id });
throw new Error('手机号或密码错误');
}
// 3. 检查用户状态
if (user.status !== 'active') {
logger.warn('登录失败:用户已禁用', { phone, userId: user.id, status: user.status });
throw new Error('账号已被禁用,请联系管理员');
}
// 4. 获取用户权限
const permissions = await this.getUserPermissions(user.role);
// 5. 生成 JWT
const jwtPayload: JWTPayload = {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
};
const tokens = jwtService.generateTokens(jwtPayload);
// 6. 更新最后登录时间
await prisma.User.update({
where: { id: user.id },
data: { updatedAt: new Date() },
});
logger.info('用户登录成功(密码方式)', {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
});
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
email: user.email,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
tenantName: user.tenants?.name,
departmentId: user.department_id,
departmentName: user.departments?.name,
isDefaultPassword: user.is_default_password,
permissions,
},
tokens,
};
}
/**
* 验证码登录
*/
async loginWithVerificationCode(request: VerificationCodeLoginRequest): Promise<LoginResponse> {
const { phone, code } = request;
// 1. 验证验证码
const verificationCode = await prisma.verification_codes.findFirst({
where: {
phone,
code,
type: 'LOGIN',
is_used: false,
expires_at: { gt: new Date() },
},
orderBy: { created_at: 'desc' },
});
if (!verificationCode) {
logger.warn('登录失败:验证码无效', { phone });
throw new Error('验证码无效或已过期');
}
// 2. 标记验证码已使用
await prisma.verification_codes.update({
where: { id: verificationCode.id },
data: { is_used: true },
});
// 3. 查找用户
const user = await prisma.User.findUnique({
where: { phone },
include: {
tenants: true,
departments: true,
},
});
if (!user) {
logger.warn('登录失败:用户不存在', { phone });
throw new Error('用户不存在');
}
// 4. 检查用户状态
if (user.status !== 'active') {
logger.warn('登录失败:用户已禁用', { phone, userId: user.id, status: user.status });
throw new Error('账号已被禁用,请联系管理员');
}
// 5. 获取用户权限
const permissions = await this.getUserPermissions(user.role);
// 6. 生成 JWT
const jwtPayload: JWTPayload = {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
};
const tokens = jwtService.generateTokens(jwtPayload);
logger.info('用户登录成功(验证码方式)', {
userId: user.id,
phone: user.phone,
role: user.role,
});
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
email: user.email,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
tenantName: user.tenants?.name,
departmentId: user.department_id,
departmentName: user.departments?.name,
isDefaultPassword: user.is_default_password,
permissions,
},
tokens,
};
}
/**
* 获取当前用户信息
*/
async getCurrentUser(userId: string): Promise<UserInfoResponse> {
const user = await prisma.User.findUnique({
where: { id: userId },
include: {
tenants: true,
departments: true,
},
});
if (!user) {
throw new Error('用户不存在');
}
const permissions = await this.getUserPermissions(user.role);
return {
id: user.id,
phone: user.phone,
name: user.name,
email: user.email,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
tenantName: user.tenants?.name,
departmentId: user.department_id,
departmentName: user.departments?.name,
isDefaultPassword: user.is_default_password,
permissions,
};
}
/**
* 修改密码
*/
async changePassword(userId: string, request: ChangePasswordRequest): Promise<void> {
const { oldPassword, newPassword, confirmPassword } = request;
// 1. 验证新密码
if (newPassword !== confirmPassword) {
throw new Error('两次输入的密码不一致');
}
if (newPassword.length < 6) {
throw new Error('密码长度至少为6位');
}
// 2. 获取用户
const user = await prisma.User.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('用户不存在');
}
// 3. 如果提供了旧密码,验证旧密码
if (oldPassword) {
const isValidPassword = await bcrypt.compare(oldPassword, user.password);
if (!isValidPassword) {
throw new Error('原密码错误');
}
}
// 4. 加密新密码
const hashedPassword = await bcrypt.hash(newPassword, 10);
// 5. 更新密码
await prisma.User.update({
where: { id: userId },
data: {
password: hashedPassword,
is_default_password: false,
password_changed_at: new Date(),
},
});
logger.info('用户修改密码成功', { userId });
}
/**
* 发送验证码
*/
async sendVerificationCode(phone: string, type: 'LOGIN' | 'RESET_PASSWORD'): Promise<{ expiresIn: number }> {
// 1. 检查用户是否存在
const user = await prisma.User.findUnique({
where: { phone },
});
if (!user) {
throw new Error('用户不存在');
}
// 2. 检查是否频繁发送1分钟内只能发一次
const recentCode = await prisma.verification_codes.findFirst({
where: {
phone,
created_at: { gt: new Date(Date.now() - 60 * 1000) },
},
});
if (recentCode) {
throw new Error('验证码发送过于频繁,请稍后再试');
}
// 3. 生成6位验证码
const code = Math.floor(100000 + Math.random() * 900000).toString();
// 4. 设置5分钟过期
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
// 5. 保存验证码
await prisma.verification_codes.create({
data: {
phone,
code,
type: type as any, // VerificationType enum
expires_at: expiresAt,
},
});
// TODO: 实际发送短信
// 开发环境直接打印验证码
logger.info('📱 验证码已生成', { phone, code, type, expiresAt });
console.log(`\n📱 验证码: ${code} (有效期5分钟)\n`);
return { expiresIn: 300 }; // 5分钟
}
/**
* 刷新 Token
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
return jwtService.refreshToken(refreshToken, async (userId) => {
const user = await prisma.User.findUnique({
where: { id: userId },
include: { tenants: true },
});
if (!user) {
return null;
}
return {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
};
});
}
/**
* 获取用户权限列表
*/
private async getUserPermissions(role: string): Promise<string[]> {
const rolePermissions = await prisma.role_permissions.findMany({
where: { role: role as any },
include: { permissions: true },
});
return rolePermissions.map(rp => rp.permissions.code);
}
/**
* 根据用户ID获取JWT Payload用于刷新Token
*/
async getUserPayloadById(userId: string): Promise<JWTPayload | null> {
const user = await prisma.User.findUnique({
where: { id: userId },
include: { tenants: true },
});
if (!user) {
return null;
}
return {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
};
}
}
// 导出单例
export const authService = new AuthService();

View File

@@ -0,0 +1,35 @@
/**
* Auth Module
*
* 认证模块导出
*/
// JWT Service
export { jwtService, JWTService } from './jwt.service.js';
export type { JWTPayload, TokenResponse, DecodedToken } from './jwt.service.js';
// Auth Service
export { authService, AuthService } from './auth.service.js';
export type {
PasswordLoginRequest,
VerificationCodeLoginRequest,
ChangePasswordRequest,
UserInfoResponse,
LoginResponse,
} from './auth.service.js';
// Auth Middleware
export {
authenticate,
optionalAuthenticate,
requireRoles,
requirePermission,
requireSameTenant,
registerAuthPlugin,
AuthenticationError,
AuthorizationError,
} from './auth.middleware.js';
// Auth Routes
export { authRoutes } from './auth.routes.js';

View File

@@ -0,0 +1,186 @@
/**
* JWT Service
*
* JWT令牌的生成、验证和刷新
*
* 设计原则:
* - 使用 jsonwebtoken 库
* - Token payload 包含用户ID、角色、租户ID
* - 支持 Access Token 和 Refresh Token
*/
import jwt, { SignOptions, JwtPayload } from 'jsonwebtoken';
import { config } from '../../config/env.js';
/**
* JWT Payload 接口
*/
export interface JWTPayload {
/** 用户ID */
userId: string;
/** 用户手机号 */
phone: string;
/** 用户角色 */
role: string;
/** 租户ID */
tenantId: string;
/** 租户Code用于URL路由 */
tenantCode?: string;
/** 是否为默认密码 */
isDefaultPassword?: boolean;
}
/**
* Token 响应接口
*/
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number; // 秒
tokenType: 'Bearer';
}
/**
* 解码后的 Token 接口
*/
export interface DecodedToken extends JWTPayload {
iat: number; // 签发时间
exp: number; // 过期时间
}
// Token 配置
const ACCESS_TOKEN_EXPIRES_IN = '2h'; // Access Token 2小时过期
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // Refresh Token 7天过期
const ACCESS_TOKEN_EXPIRES_SECONDS = 2 * 60 * 60; // 7200秒
/**
* JWT Service 类
*/
export class JWTService {
private readonly secret: string;
constructor() {
this.secret = config.jwtSecret;
if (this.secret === 'your-secret-key-change-in-production' && config.nodeEnv === 'production') {
throw new Error('JWT_SECRET must be configured in production environment');
}
}
/**
* 生成 Access Token
*/
generateAccessToken(payload: JWTPayload): string {
const options: SignOptions = {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
issuer: 'aiclinical',
subject: payload.userId,
};
return jwt.sign(payload, this.secret, options);
}
/**
* 生成 Refresh Token
*/
generateRefreshToken(payload: JWTPayload): string {
// Refresh Token 只包含必要信息
const refreshPayload = {
userId: payload.userId,
type: 'refresh',
};
const options: SignOptions = {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
issuer: 'aiclinical',
subject: payload.userId,
};
return jwt.sign(refreshPayload, this.secret, options);
}
/**
* 生成完整的 Token 响应
*/
generateTokens(payload: JWTPayload): TokenResponse {
return {
accessToken: this.generateAccessToken(payload),
refreshToken: this.generateRefreshToken(payload),
expiresIn: ACCESS_TOKEN_EXPIRES_SECONDS,
tokenType: 'Bearer',
};
}
/**
* 验证 Token
*/
verifyToken(token: string): DecodedToken {
try {
const decoded = jwt.verify(token, this.secret, {
issuer: 'aiclinical',
}) as DecodedToken;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('Token已过期');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error('Token无效');
}
throw error;
}
}
/**
* 刷新 Token
*
* 使用 Refresh Token 获取新的 Access Token
*/
async refreshToken(refreshToken: string, getUserById: (id: string) => Promise<JWTPayload | null>): Promise<TokenResponse> {
// 验证 Refresh Token
const decoded = this.verifyToken(refreshToken);
if ((decoded as any).type !== 'refresh') {
throw new Error('无效的Refresh Token');
}
// 获取用户最新信息
const user = await getUserById(decoded.userId);
if (!user) {
throw new Error('用户不存在');
}
// 生成新的 Tokens
return this.generateTokens(user);
}
/**
* 从 Authorization Header 中提取 Token
*/
extractTokenFromHeader(authHeader: string | undefined): string | null {
if (!authHeader) {
return null;
}
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
return null;
}
return token;
}
/**
* 解码 Token不验证签名
*
* 用于调试或获取过期token的payload
*/
decodeToken(token: string): DecodedToken | null {
const decoded = jwt.decode(token);
return decoded as DecodedToken | null;
}
}
// 导出单例
export const jwtService = new JWTService();

View File

@@ -313,5 +313,6 @@ export function getBatchItems<T>(

View File

@@ -0,0 +1,33 @@
/**
* Prompt 管理模块导出
*/
// Service
export { PromptService, getPromptService, resetPromptService } from './prompt.service.js';
// Types
export type {
PromptStatus,
ModelConfig,
PromptTemplate,
PromptVersion,
RenderedPrompt,
DebugState,
GetPromptOptions,
VariableValidation,
} from './prompt.types.js';
// Fallbacks
export {
getFallbackPrompt,
hasFallbackPrompt,
getAllFallbackCodes,
FALLBACK_PROMPTS,
} from './prompt.fallbacks.js';
// Routes
export { promptRoutes } from './prompt.routes.js';
// Controller (for testing)
export * from './prompt.controller.js';

View File

@@ -0,0 +1,418 @@
/**
* Prompt 管理 API 控制器
*/
import type { FastifyRequest, FastifyReply } from 'fastify';
import { getPromptService } from './prompt.service.js';
import type { ModelConfig } from './prompt.types.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 请求类型定义
interface ListPromptsQuery {
module?: string;
}
interface GetPromptParams {
code: string;
}
interface SaveDraftBody {
content: string;
modelConfig?: ModelConfig;
changelog?: string;
}
interface PublishBody {
// 可扩展
}
interface RollbackBody {
version: number;
}
interface SetDebugModeBody {
modules: string[];
enabled: boolean;
}
interface TestRenderBody {
content: string;
variables: Record<string, any>;
}
/**
* 获取 Prompt 列表
* GET /api/admin/prompts?module=RVW
*/
export async function listPrompts(
request: FastifyRequest<{ Querystring: ListPromptsQuery }>,
reply: FastifyReply
) {
try {
const { module } = request.query;
const promptService = getPromptService(prisma);
const templates = await promptService.listTemplates(module);
// 转换为 API 响应格式
const result = templates.map(t => ({
id: t.id,
code: t.code,
name: t.name,
module: t.module,
description: t.description,
variables: t.variables,
latestVersion: t.versions[0] ? {
version: t.versions[0].version,
status: t.versions[0].status,
createdAt: t.versions[0].created_at,
} : null,
updatedAt: t.updated_at,
}));
return reply.send({
success: true,
data: result,
total: result.length,
});
} catch (error) {
console.error('[PromptController] listPrompts error:', error);
return reply.status(500).send({
success: false,
error: 'Failed to list prompts',
});
}
}
/**
* 获取 Prompt 详情(含版本历史)
* GET /api/admin/prompts/:code
*/
export async function getPromptDetail(
request: FastifyRequest<{ Params: GetPromptParams }>,
reply: FastifyReply
) {
try {
const { code } = request.params;
const promptService = getPromptService(prisma);
const template = await promptService.getTemplateDetail(code);
if (!template) {
return reply.status(404).send({
success: false,
error: 'Prompt not found',
});
}
// 转换版本历史
const versions = template.versions.map(v => ({
id: v.id,
version: v.version,
status: v.status,
content: v.content,
modelConfig: v.model_config,
changelog: v.changelog,
createdBy: v.created_by,
createdAt: v.created_at,
}));
return reply.send({
success: true,
data: {
id: template.id,
code: template.code,
name: template.name,
module: template.module,
description: template.description,
variables: template.variables,
versions,
createdAt: template.created_at,
updatedAt: template.updated_at,
},
});
} catch (error) {
console.error('[PromptController] getPromptDetail error:', error);
return reply.status(500).send({
success: false,
error: 'Failed to get prompt detail',
});
}
}
/**
* 保存草稿
* POST /api/admin/prompts/:code/draft
*
* 需要权限: prompt:edit
*/
export async function saveDraft(
request: FastifyRequest<{ Params: GetPromptParams; Body: SaveDraftBody }>,
reply: FastifyReply
) {
try {
const { code } = request.params;
const { content, modelConfig, changelog } = request.body;
const userId = (request as any).user?.id;
const promptService = getPromptService(prisma);
// 保存草稿
const draft = await promptService.saveDraft(
code,
content,
modelConfig,
changelog,
userId
);
// 提取变量信息
const variables = promptService.extractVariables(content);
return reply.send({
success: true,
data: {
id: draft.id,
version: draft.version,
status: draft.status,
variables,
message: 'Draft saved successfully',
},
});
} catch (error: any) {
console.error('[PromptController] saveDraft error:', error);
return reply.status(400).send({
success: false,
error: error.message || 'Failed to save draft',
});
}
}
/**
* 发布 Prompt
* POST /api/admin/prompts/:code/publish
*
* 需要权限: prompt:publish
*/
export async function publishPrompt(
request: FastifyRequest<{ Params: GetPromptParams; Body: PublishBody }>,
reply: FastifyReply
) {
try {
const { code } = request.params;
const userId = (request as any).user?.id;
const promptService = getPromptService(prisma);
const published = await promptService.publish(code, userId);
return reply.send({
success: true,
data: {
version: published.version,
status: 'ACTIVE',
message: `Prompt ${code} published successfully (v${published.version})`,
},
});
} catch (error: any) {
console.error('[PromptController] publishPrompt error:', error);
return reply.status(400).send({
success: false,
error: error.message || 'Failed to publish prompt',
});
}
}
/**
* 回滚到指定版本
* POST /api/admin/prompts/:code/rollback
*
* 需要权限: prompt:publish
*/
export async function rollbackPrompt(
request: FastifyRequest<{ Params: GetPromptParams; Body: RollbackBody }>,
reply: FastifyReply
) {
try {
const { code } = request.params;
const { version } = request.body;
const userId = (request as any).user?.id;
const promptService = getPromptService(prisma);
const rolled = await promptService.rollback(code, version, userId);
return reply.send({
success: true,
data: {
version: rolled.version,
status: 'ACTIVE',
message: `Prompt ${code} rolled back to v${version}`,
},
});
} catch (error: any) {
console.error('[PromptController] rollbackPrompt error:', error);
return reply.status(400).send({
success: false,
error: error.message || 'Failed to rollback prompt',
});
}
}
/**
* 设置调试模式
* POST /api/admin/prompts/debug
*
* 需要权限: prompt:debug
*/
export async function setDebugMode(
request: FastifyRequest<{ Body: SetDebugModeBody }>,
reply: FastifyReply
) {
try {
const { modules, enabled } = request.body;
const userId = (request as any).user?.id;
if (!userId) {
return reply.status(401).send({
success: false,
error: 'User not authenticated',
});
}
const promptService = getPromptService(prisma);
promptService.setDebugMode(userId, modules, enabled);
const state = promptService.getDebugState(userId);
return reply.send({
success: true,
data: {
userId,
modules: state ? Array.from(state.modules) : [],
enabled,
message: enabled
? `Debug mode enabled for modules: [${modules.join(', ')}]`
: 'Debug mode disabled',
},
});
} catch (error: any) {
console.error('[PromptController] setDebugMode error:', error);
return reply.status(500).send({
success: false,
error: error.message || 'Failed to set debug mode',
});
}
}
/**
* 获取当前用户的调试状态
* GET /api/admin/prompts/debug
*/
export async function getDebugStatus(
request: FastifyRequest,
reply: FastifyReply
) {
try {
const userId = (request as any).user?.id;
if (!userId) {
return reply.status(401).send({
success: false,
error: 'User not authenticated',
});
}
const promptService = getPromptService(prisma);
const state = promptService.getDebugState(userId);
return reply.send({
success: true,
data: {
userId,
isDebugging: !!state,
modules: state ? Array.from(state.modules) : [],
enabledAt: state?.enabledAt,
},
});
} catch (error: any) {
console.error('[PromptController] getDebugStatus error:', error);
return reply.status(500).send({
success: false,
error: error.message || 'Failed to get debug status',
});
}
}
/**
* 测试渲染 Prompt
* POST /api/admin/prompts/test-render
*/
export async function testRender(
request: FastifyRequest<{ Body: TestRenderBody }>,
reply: FastifyReply
) {
try {
const { content, variables } = request.body;
const promptService = getPromptService(prisma);
// 提取变量
const extractedVars = promptService.extractVariables(content);
// 校验变量
const validation = promptService.validateVariables(content, variables);
// 渲染
const rendered = promptService.render(content, variables);
return reply.send({
success: true,
data: {
rendered,
extractedVariables: extractedVars,
validation,
},
});
} catch (error: any) {
console.error('[PromptController] testRender error:', error);
return reply.status(400).send({
success: false,
error: error.message || 'Failed to render prompt',
});
}
}
/**
* 清除缓存
* POST /api/admin/prompts/:code/invalidate-cache
*/
export async function invalidateCache(
request: FastifyRequest<{ Params: GetPromptParams }>,
reply: FastifyReply
) {
try {
const { code } = request.params;
const promptService = getPromptService(prisma);
promptService.invalidateCache(code);
return reply.send({
success: true,
data: {
code,
message: `Cache invalidated for ${code}`,
},
});
} catch (error: any) {
console.error('[PromptController] invalidateCache error:', error);
return reply.status(500).send({
success: false,
error: error.message || 'Failed to invalidate cache',
});
}
}

View File

@@ -0,0 +1,100 @@
/**
* 兜底 PromptHardcoded Fallbacks
*
* 三级容灾机制的最后一道防线:
* 1. 正常:从数据库获取 ACTIVE 版本
* 2. 缓存:数据库不可用时使用缓存
* 3. 兜底:缓存也失效时使用这里的 hardcoded 版本
*
* ⚠️ 注意:这里的 Prompt 是最基础版本,仅保证系统不崩溃
* 实际生产环境应该始终使用数据库中的版本
*/
import type { ModelConfig } from './prompt.types.js';
interface FallbackPrompt {
content: string;
modelConfig: ModelConfig;
}
/**
* RVW 模块兜底 Prompt
*/
const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
RVW_EDITORIAL: {
content: `你是一位专业的医学期刊编辑,负责评估稿件的规范性。
【评估标准】
1. 文稿科学性与实用性
2. 文题中文不超过20字英文不超过10实词
3. 作者格式
4. 摘要300-500字含目的、方法、结果、结论
5. 关键词2-5个
6. 医学名词和药物名称
7. 缩略语
8. 计量单位
9. 图片格式
10. 动态图像
11. 参考文献
请输出JSON格式的评估结果包含overall_score和items数组。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
RVW_METHODOLOGY: {
content: `你是一位资深的医学统计学专家,负责评估稿件的方法学质量。
【评估框架】
第一部分:科研设计评估(研究类型、对象、对照、质控)
第二部分:统计学方法描述(软件、方法、混杂因素)
第三部分:统计分析评估(方法正确性、结果描述)
请输出JSON格式的评估结果包含overall_score和parts数组。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
};
/**
* ASL 模块兜底 Prompt预留
*/
const ASL_FALLBACKS: Record<string, FallbackPrompt> = {
ASL_SCREENING: {
content: `你是一位文献筛选专家,负责根据纳入排除标准筛选文献。
请根据提供的标准对文献进行筛选输出JSON格式的结果。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.2 },
},
};
/**
* 所有模块的兜底 Prompt 汇总
*/
export const FALLBACK_PROMPTS: Record<string, FallbackPrompt> = {
...RVW_FALLBACKS,
...ASL_FALLBACKS,
};
/**
* 获取兜底 Prompt
*
* @param code Prompt 代码
* @returns 兜底 Prompt 或 undefined
*/
export function getFallbackPrompt(code: string): FallbackPrompt | undefined {
return FALLBACK_PROMPTS[code];
}
/**
* 检查是否有兜底 Prompt
*/
export function hasFallbackPrompt(code: string): boolean {
return code in FALLBACK_PROMPTS;
}
/**
* 获取所有兜底 Prompt 的代码列表
*/
export function getAllFallbackCodes(): string[] {
return Object.keys(FALLBACK_PROMPTS);
}

View File

@@ -0,0 +1,223 @@
/**
* Prompt 管理 API 路由
*
* 路由前缀: /api/admin/prompts
*/
import type { FastifyInstance } from 'fastify';
import {
listPrompts,
getPromptDetail,
saveDraft,
publishPrompt,
rollbackPrompt,
setDebugMode,
getDebugStatus,
testRender,
invalidateCache,
} from './prompt.controller.js';
// Schema 定义
const listPromptsSchema = {
querystring: {
type: 'object',
properties: {
module: { type: 'string', description: '过滤模块,如 RVW, ASL, DC' },
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
code: { type: 'string' },
name: { type: 'string' },
module: { type: 'string' },
description: { type: 'string' },
variables: { type: 'array', items: { type: 'string' } },
latestVersion: {
type: 'object',
properties: {
version: { type: 'number' },
status: { type: 'string' },
createdAt: { type: 'string' },
},
},
updatedAt: { type: 'string' },
},
},
},
total: { type: 'number' },
},
},
},
};
const getPromptDetailSchema = {
params: {
type: 'object',
properties: {
code: { type: 'string' },
},
required: ['code'],
},
};
const saveDraftSchema = {
params: {
type: 'object',
properties: {
code: { type: 'string' },
},
required: ['code'],
},
body: {
type: 'object',
properties: {
content: { type: 'string', description: 'Prompt 内容(支持 Handlebars' },
modelConfig: {
type: 'object',
properties: {
model: { type: 'string' },
temperature: { type: 'number' },
maxTokens: { type: 'number' },
},
},
changelog: { type: 'string', description: '变更说明' },
},
required: ['content'],
},
};
const publishSchema = {
params: {
type: 'object',
properties: {
code: { type: 'string' },
},
required: ['code'],
},
};
const rollbackSchema = {
params: {
type: 'object',
properties: {
code: { type: 'string' },
},
required: ['code'],
},
body: {
type: 'object',
properties: {
version: { type: 'number', description: '目标版本号' },
},
required: ['version'],
},
};
const setDebugModeSchema = {
body: {
type: 'object',
properties: {
modules: {
type: 'array',
items: { type: 'string' },
description: '要调试的模块列表,如 ["RVW"] 或 ["ALL"]',
},
enabled: { type: 'boolean', description: '是否开启调试模式' },
},
required: ['modules', 'enabled'],
},
};
const testRenderSchema = {
body: {
type: 'object',
properties: {
content: { type: 'string', description: 'Prompt 模板内容' },
variables: {
type: 'object',
additionalProperties: true,
description: '变量键值对',
},
},
required: ['content', 'variables'],
},
};
/**
* 注册 Prompt 管理路由
*/
export async function promptRoutes(fastify: FastifyInstance) {
// 列表
fastify.get('/', {
schema: listPromptsSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:view')],
handler: listPrompts,
});
// 详情
fastify.get('/:code', {
schema: getPromptDetailSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:view')],
handler: getPromptDetail,
});
// 保存草稿(需要 prompt:edit
fastify.post('/:code/draft', {
schema: saveDraftSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
handler: saveDraft,
});
// 发布(需要 prompt:publish
fastify.post('/:code/publish', {
schema: publishSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:publish')],
handler: publishPrompt,
});
// 回滚(需要 prompt:publish
fastify.post('/:code/rollback', {
schema: rollbackSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:publish')],
handler: rollbackPrompt,
});
// 调试模式 - 获取状态
fastify.get('/debug', {
// preHandler: [fastify.authenticate],
handler: getDebugStatus,
});
// 调试模式 - 设置(需要 prompt:debug
fastify.post('/debug', {
schema: setDebugModeSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:debug')],
handler: setDebugMode,
});
// 测试渲染
fastify.post('/test-render', {
schema: testRenderSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
handler: testRender,
});
// 清除缓存
fastify.post('/:code/invalidate-cache', {
schema: getPromptDetailSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
handler: invalidateCache,
});
}
export default promptRoutes;

View File

@@ -0,0 +1,595 @@
/**
* Prompt 管理服务
*
* 核心功能:
* 1. 灰度预览:调试模式下返回 DRAFT正常返回 ACTIVE
* 2. 变量渲染Handlebars 模板引擎
* 3. 三级容灾:数据库 → 缓存 → 兜底
* 4. 热更新Postgres LISTEN/NOTIFY
* 5. 变量校验:自动提取和验证变量
*/
import { PrismaClient, Prisma } from '@prisma/client';
import Handlebars from 'handlebars';
import type {
RenderedPrompt,
GetPromptOptions,
ModelConfig,
VariableValidation,
DebugState,
} from './prompt.types.js';
import { getFallbackPrompt } from './prompt.fallbacks.js';
// 默认模型配置
const DEFAULT_MODEL_CONFIG: ModelConfig = {
model: 'deepseek-v3',
temperature: 0.3,
};
// 缓存 TTL
const CACHE_TTL = 5 * 60; // 5分钟
export class PromptService {
private prisma: PrismaClient;
private cache: Map<string, { data: any; expiresAt: number }>;
private debugStates: Map<string, DebugState>; // userId -> DebugState
private notifyClient: any; // 用于 LISTEN/NOTIFY
constructor(prisma: PrismaClient) {
this.prisma = prisma;
this.cache = new Map();
this.debugStates = new Map();
}
/**
* 🎯 核心方法:获取渲染后的 Prompt
*
* 灰度逻辑:
* - 如果用户开启了该模块的调试模式,返回 DRAFT
* - 否则返回 ACTIVE
*
* @param code Prompt 代码,如 'RVW_EDITORIAL'
* @param variables 模板变量
* @param options 选项userId 用于判断调试模式)
*/
async get(
code: string,
variables: Record<string, any> = {},
options: GetPromptOptions = {}
): Promise<RenderedPrompt> {
const { userId, skipCache = false } = options;
try {
// 1. 判断是否处于调试模式
const isDebugging = userId ? this.isDebugging(userId, code) : false;
// 2. 获取 Prompt 版本
let version;
if (isDebugging) {
// 调试模式:优先获取 DRAFT
version = await this.getDraftVersion(code);
if (!version) {
// 没有 DRAFT降级到 ACTIVE
version = await this.getActiveVersion(code, skipCache);
}
} else {
// 正常模式:获取 ACTIVE
version = await this.getActiveVersion(code, skipCache);
}
// 3. 如果数据库获取失败,使用兜底
if (!version) {
console.warn(`[PromptService] Fallback to hardcoded prompt: ${code}`);
const fallback = getFallbackPrompt(code);
if (!fallback) {
throw new Error(`Prompt not found and no fallback available: ${code}`);
}
return {
content: this.render(fallback.content, variables),
modelConfig: fallback.modelConfig,
version: 0,
isDraft: false,
};
}
// 4. 渲染模板
const content = this.render(version.content, variables);
const modelConfig = (version.model_config as ModelConfig) || DEFAULT_MODEL_CONFIG;
return {
content,
modelConfig,
version: version.version,
isDraft: version.status === 'DRAFT',
};
} catch (error) {
console.error(`[PromptService] Error getting prompt ${code}:`, error);
// 最后的兜底
const fallback = getFallbackPrompt(code);
if (fallback) {
return {
content: this.render(fallback.content, variables),
modelConfig: fallback.modelConfig,
version: 0,
isDraft: false,
};
}
throw error;
}
}
/**
* 获取 ACTIVE 版本(带缓存)
*/
async getActiveVersion(code: string, skipCache = false) {
const cacheKey = `prompt:active:${code}`;
// 检查缓存
if (!skipCache) {
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
}
// 从数据库获取
const template = await this.prisma.prompt_templates.findUnique({
where: { code },
include: {
versions: {
where: { status: 'ACTIVE' },
orderBy: { version: 'desc' },
take: 1,
},
},
});
if (!template || template.versions.length === 0) {
return null;
}
const version = template.versions[0];
// 写入缓存
this.setCache(cacheKey, version, CACHE_TTL);
return version;
}
/**
* 获取 DRAFT 版本(不缓存,总是实时)
*/
async getDraftVersion(code: string) {
const template = await this.prisma.prompt_templates.findUnique({
where: { code },
include: {
versions: {
where: { status: 'DRAFT' },
orderBy: { version: 'desc' },
take: 1,
},
},
});
if (!template || template.versions.length === 0) {
return null;
}
return template.versions[0];
}
/**
* 渲染模板Handlebars
*/
render(template: string, variables: Record<string, any>): string {
try {
const compiled = Handlebars.compile(template, { noEscape: true });
return compiled(variables);
} catch (error) {
console.error('[PromptService] Template render error:', error);
// 渲染失败返回原始模板
return template;
}
}
/**
* 从内容中提取变量名
*
* 支持:
* - {{variable}}
* - {{#if variable}}...{{/if}}
* - {{#each items}}...{{/each}}
*/
extractVariables(content: string): string[] {
const regex = /\{\{([^#/][^}]*)\}\}/g;
const variables = new Set<string>();
let match;
while ((match = regex.exec(content)) !== null) {
const varName = match[1].trim();
// 排除 Handlebars 助手函数
if (!varName.startsWith('#') && !varName.startsWith('/') && !varName.includes(' ')) {
variables.add(varName);
}
}
return Array.from(variables);
}
/**
* 校验变量完整性
*/
validateVariables(
content: string,
providedVariables: Record<string, any>
): VariableValidation {
const expectedVars = this.extractVariables(content);
const providedKeys = Object.keys(providedVariables);
const missingVariables = expectedVars.filter(v => !providedKeys.includes(v));
const extraVariables = providedKeys.filter(v => !expectedVars.includes(v));
return {
isValid: missingVariables.length === 0,
missingVariables,
extraVariables,
};
}
// ==================== 调试模式管理 ====================
/**
* 设置调试模式
*
* @param userId 用户ID
* @param modules 要调试的模块列表,如 ['RVW'] 或 ['ALL']
* @param enabled 是否开启
*/
setDebugMode(userId: string, modules: string[], enabled: boolean): void {
if (enabled) {
this.debugStates.set(userId, {
userId,
modules: new Set(modules),
enabledAt: new Date(),
});
console.log(`[PromptService] Debug mode enabled for user ${userId}, modules: [${modules.join(', ')}]`);
} else {
this.debugStates.delete(userId);
console.log(`[PromptService] Debug mode disabled for user ${userId}`);
}
}
/**
* 检查用户是否在某模块的调试模式
*/
isDebugging(userId: string, code: string): boolean {
const state = this.debugStates.get(userId);
if (!state) return false;
// 提取模块名(如 'RVW_EDITORIAL' -> 'RVW'
const module = code.split('_')[0];
// 检查是否调试全部或指定模块
return state.modules.has('ALL') || state.modules.has(module);
}
/**
* 获取用户的调试状态
*/
getDebugState(userId: string): DebugState | null {
return this.debugStates.get(userId) || null;
}
/**
* 获取所有调试中的用户
*/
getAllDebugUsers(): string[] {
return Array.from(this.debugStates.keys());
}
// ==================== 缓存管理 ====================
private getFromCache(key: string): any {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
private setCache(key: string, data: any, ttlSeconds: number): void {
this.cache.set(key, {
data,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
/**
* 清除指定 Prompt 的缓存
*/
invalidateCache(code: string): void {
const cacheKey = `prompt:active:${code}`;
this.cache.delete(cacheKey);
console.log(`[PromptService] Cache invalidated for: ${code}`);
}
/**
* 清除所有缓存
*/
clearAllCache(): void {
this.cache.clear();
console.log('[PromptService] All cache cleared');
}
// ==================== LISTEN/NOTIFY 热更新 ====================
/**
* 启动 Postgres LISTEN/NOTIFY 监听
*
* 使用方法:
* 1. 在数据库中创建触发器:
* CREATE OR REPLACE FUNCTION notify_prompt_change() RETURNS trigger AS $$
* BEGIN
* PERFORM pg_notify('prompt_changed', NEW.code);
* RETURN NEW;
* END;
* $$ LANGUAGE plpgsql;
*
* CREATE TRIGGER prompt_change_trigger
* AFTER INSERT OR UPDATE ON capability_schema.prompt_versions
* FOR EACH ROW EXECUTE FUNCTION notify_prompt_change();
*
* 2. 调用 promptService.startListening()
*/
async startListening(): Promise<void> {
// 注意Prisma 不直接支持 LISTEN需要使用原生 pg 客户端
// 这里先留空,后续如果需要可以实现
console.log('[PromptService] LISTEN/NOTIFY not yet implemented - using manual cache invalidation');
}
/**
* 停止监听
*/
async stopListening(): Promise<void> {
if (this.notifyClient) {
await this.notifyClient.end();
this.notifyClient = null;
}
}
// ==================== 管理功能 ====================
/**
* 获取所有模板列表
*/
async listTemplates(module?: string) {
const where = module ? { module } : {};
return this.prisma.prompt_templates.findMany({
where,
include: {
versions: {
orderBy: { version: 'desc' },
take: 1,
},
},
orderBy: { code: 'asc' },
});
}
/**
* 获取模板详情(含所有版本)
*/
async getTemplateDetail(code: string) {
return this.prisma.prompt_templates.findUnique({
where: { code },
include: {
versions: {
orderBy: { version: 'desc' },
},
},
});
}
/**
* 保存草稿
*/
async saveDraft(
code: string,
content: string,
modelConfig?: ModelConfig,
changelog?: string,
createdBy?: string
) {
// 获取模板
const template = await this.prisma.prompt_templates.findUnique({
where: { code },
include: {
versions: {
orderBy: { version: 'desc' },
take: 1,
},
},
});
if (!template) {
throw new Error(`Template not found: ${code}`);
}
// 自动提取变量
const variables = this.extractVariables(content);
// 更新模板的变量字段
await this.prisma.prompt_templates.update({
where: { code },
data: { variables },
});
// 计算新版本号
const latestVersion = template.versions[0];
const newVersion = latestVersion ? latestVersion.version + 1 : 1;
// 检查是否已有 DRAFT
const existingDraft = await this.prisma.prompt_versions.findFirst({
where: {
template_id: template.id,
status: 'DRAFT',
},
});
if (existingDraft) {
// 更新现有 DRAFT
return this.prisma.prompt_versions.update({
where: { id: existingDraft.id },
data: {
content,
model_config: (modelConfig || existingDraft.model_config) as unknown as Prisma.InputJsonValue,
changelog,
created_by: createdBy,
},
});
} else {
// 创建新 DRAFT
return this.prisma.prompt_versions.create({
data: {
template_id: template.id,
version: newVersion,
content,
model_config: (modelConfig || DEFAULT_MODEL_CONFIG) as unknown as Prisma.InputJsonValue,
status: 'DRAFT',
changelog,
created_by: createdBy,
},
});
}
}
/**
* 发布草稿DRAFT → ACTIVE
*
* 需要 prompt:publish 权限
*/
async publish(code: string, createdBy?: string) {
// 获取 DRAFT
const template = await this.prisma.prompt_templates.findUnique({
where: { code },
});
if (!template) {
throw new Error(`Template not found: ${code}`);
}
const draft = await this.prisma.prompt_versions.findFirst({
where: {
template_id: template.id,
status: 'DRAFT',
},
});
if (!draft) {
throw new Error(`No draft found for: ${code}`);
}
// 事务:归档旧 ACTIVE激活新版本
await this.prisma.$transaction([
// 1. 归档当前 ACTIVE
this.prisma.prompt_versions.updateMany({
where: {
template_id: template.id,
status: 'ACTIVE',
},
data: {
status: 'ARCHIVED',
},
}),
// 2. 激活 DRAFT
this.prisma.prompt_versions.update({
where: { id: draft.id },
data: {
status: 'ACTIVE',
created_by: createdBy,
},
}),
]);
// 清除缓存
this.invalidateCache(code);
console.log(`[PromptService] Published: ${code} (v${draft.version})`);
return draft;
}
/**
* 回滚到指定版本
*/
async rollback(code: string, targetVersion: number, createdBy?: string) {
const template = await this.prisma.prompt_templates.findUnique({
where: { code },
});
if (!template) {
throw new Error(`Template not found: ${code}`);
}
// 获取目标版本
const targetVersionRecord = await this.prisma.prompt_versions.findFirst({
where: {
template_id: template.id,
version: targetVersion,
},
});
if (!targetVersionRecord) {
throw new Error(`Version ${targetVersion} not found for: ${code}`);
}
// 事务:归档当前 ACTIVE重新激活目标版本
await this.prisma.$transaction([
this.prisma.prompt_versions.updateMany({
where: {
template_id: template.id,
status: 'ACTIVE',
},
data: {
status: 'ARCHIVED',
},
}),
this.prisma.prompt_versions.update({
where: { id: targetVersionRecord.id },
data: {
status: 'ACTIVE',
created_by: createdBy,
},
}),
]);
// 清除缓存
this.invalidateCache(code);
console.log(`[PromptService] Rolled back: ${code} to v${targetVersion}`);
return targetVersionRecord;
}
}
// 单例模式
let instance: PromptService | null = null;
export function getPromptService(prisma: PrismaClient): PromptService {
if (!instance) {
instance = new PromptService(prisma);
}
return instance;
}
export function resetPromptService(): void {
instance = null;
}

View File

@@ -0,0 +1,69 @@
/**
* Prompt 管理系统类型定义
*/
// Prompt 状态
export type PromptStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
// 模型配置
export interface ModelConfig {
model: string; // 模型名称,如 'deepseek-v3'
temperature?: number; // 温度参数
maxTokens?: number; // 最大输出token
topP?: number; // Top-P采样
fallback?: string; // 降级模型
}
// Prompt 模板
export interface PromptTemplate {
id: number;
code: string; // 唯一标识符,如 'RVW_EDITORIAL'
name: string; // 人类可读名称
module: string; // 所属模块: RVW, ASL, DC, IIT, PKB, AIA
description?: string;
variables?: string[]; // 预期变量列表
createdAt: Date;
updatedAt: Date;
}
// Prompt 版本
export interface PromptVersion {
id: number;
templateId: number;
version: number;
content: string; // Prompt 内容(支持 Handlebars
modelConfig?: ModelConfig;
status: PromptStatus;
changelog?: string;
createdBy?: string;
createdAt: Date;
}
// 渲染后的 Prompt
export interface RenderedPrompt {
content: string; // 渲染后的内容
modelConfig: ModelConfig;
version: number;
isDraft: boolean; // 是否来自草稿(调试模式)
}
// 调试模式状态
export interface DebugState {
userId: string;
modules: Set<string>; // 'RVW', 'ASL', 'DC', 'ALL'
enabledAt: Date;
}
// 获取 Prompt 的选项
export interface GetPromptOptions {
userId?: string; // 用于判断调试模式
skipCache?: boolean; // 跳过缓存
}
// 变量校验结果
export interface VariableValidation {
isValid: boolean;
missingVariables: string[];
extraVariables: string[];
}