Files
AIclinicalresearch/backend/src/common/auth/auth.service.ts
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
2026-01-16 13:42:10 +08:00

492 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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[];
modules: 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);
const modules = await this.getUserModules(user.id);
// 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: { lastLoginAt: new Date() },
});
logger.info('用户登录成功(密码方式)', {
userId: user.id,
phone: user.phone,
role: user.role,
tenantId: user.tenant_id,
modules: modules.length,
});
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,
modules, // 新增:返回模块列表
},
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);
const modules = await this.getUserModules(user.id);
// 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,
modules: modules.length,
});
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,
modules, // 新增:返回模块列表
},
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);
}
/**
* 获取用户可访问的模块列表
*
* 逻辑:
* 1. 查询用户所有租户关系
* 2. 对每个租户,检查租户订阅的模块
* 3. 如果用户有自定义模块权限,使用自定义权限
* 4. 否则继承租户的全部模块权限
* 5. 去重后返回所有可访问模块
*/
private async getUserModules(userId: string): Promise<string[]> {
// 获取用户的所有租户关系
const tenantMembers = await prisma.tenant_members.findMany({
where: { user_id: userId },
});
const allAccessibleModules = new Set<string>();
for (const tm of tenantMembers) {
// 获取租户订阅的模块
const tenantModules = await prisma.tenant_modules.findMany({
where: {
tenant_id: tm.tenant_id,
is_enabled: true,
},
});
// 获取用户在该租户的自定义模块权限
const userModules = await prisma.user_modules.findMany({
where: {
user_id: userId,
tenant_id: tm.tenant_id,
is_enabled: true,
},
});
if (userModules.length > 0) {
// 有自定义权限,使用自定义权限
userModules.forEach(um => allAccessibleModules.add(um.module_code));
} else {
// 无自定义权限,继承租户所有模块
tenantModules.forEach(tm => allAccessibleModules.add(tm.module_code));
}
}
return Array.from(allAccessibleModules).sort();
}
/**
* 根据用户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();