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
492 lines
12 KiB
TypeScript
492 lines
12 KiB
TypeScript
/**
|
||
* 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();
|
||
|