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:
436
backend/src/common/auth/auth.service.ts
Normal file
436
backend/src/common/auth/auth.service.ts
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user