import { createHash } from 'node:crypto'; import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; export type UserRoleCode = 'platform_admin' | 'enterprise_admin'; export interface CreateUserDto { tenantId?: string; username?: string; email?: string; phone?: string; displayName: string; password: string; status?: string; roleCode: UserRoleCode; operatorId?: string; } export interface UpdateUserDto { tenantId?: string | null; username?: string; email?: string | null; phone?: string | null; displayName?: string; status?: string; roleCode?: UserRoleCode; operatorId?: string; } export interface ChangeUserStatusDto { status: 'active' | 'disabled'; operatorId?: string; } export interface ChangePasswordDto { password: string; operatorId?: string; } export interface CreateRoleDto { code: string; name: string; scope?: string; description?: string; } export interface CreatePermissionDto { code: string; name: string; description?: string; } export interface AssignRoleDto { userId: string; roleId: string; } export interface AssignPermissionDto { roleId: string; permissionId: string; } const roleNames: Record = { platform_admin: { name: '平台管理员', scope: 'platform' }, enterprise_admin: { name: '企业管理员', scope: 'tenant' }, }; @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} async list(tenantId?: string, roleCode?: string) { const users = await this.prisma.user.findMany({ where: { deletedAt: null, ...(tenantId ? { tenantId } : {}), ...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}), }, include: { tenant: true, roles: { include: { role: true } } }, orderBy: { createdAt: 'desc' }, }); return users.map(publicUser); } listClientUsers(tenantId?: string) { if (!tenantId) { throw new BadRequestException('tenantId is required for client user management'); } return this.list(tenantId); } findByUsername(username: string) { return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } }); } findByLogin(login: string) { return this.prisma.user.findFirst({ where: { deletedAt: null, OR: [{ username: login }, { email: login }, { phone: login }], }, include: { tenant: true, roles: { include: { role: true } } }, }); } async create(data: CreateUserDto, scopeTenantId?: string) { const roleCode = data.roleCode; this.assertUserInput({ ...data, roleCode }, scopeTenantId, true); const tenantId = scopeTenantId ?? data.tenantId; const role = await this.ensureRole(roleCode); const user = await this.mapUniqueConflict(() => this.prisma.user.create({ data: { tenantId, username: data.username ?? data.email ?? data.phone ?? '', email: normalizeOptional(data.email), phone: normalizeOptional(data.phone), displayName: data.displayName, passwordHash: hashPassword(data.password), status: data.status ?? 'active', roles: { create: [{ roleId: role.id }] }, }, include: { tenant: true, roles: { include: { role: true } } }, })); await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username }); return publicUser(user); } async update(id: string, data: UpdateUserDto, scopeTenantId?: string) { const current = await this.getExisting(id, scopeTenantId); const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined; const roleChanged = data.roleCode !== undefined && data.roleCode !== current.roles[0]?.role.code; const tenantChanged = data.tenantId !== undefined && data.tenantId !== current.tenantId; await this.assertAdminContinuity(current, data.status ?? current.status, roleCode, scopeTenantId ?? data.tenantId ?? current.tenantId); this.assertUserInput({ tenantId: data.tenantId ?? current.tenantId ?? undefined, username: data.username ?? current.username, email: data.email === undefined ? current.email ?? undefined : data.email ?? undefined, phone: data.phone === undefined ? current.phone ?? undefined : data.phone ?? undefined, displayName: data.displayName ?? current.displayName, password: 'unchanged-password', roleCode: roleCode ?? 'enterprise_admin', }, scopeTenantId, false); const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId; const role = roleCode ? await this.ensureRole(roleCode) : null; const updated = await this.mapUniqueConflict(() => this.prisma.$transaction(async (tx) => { if (role) { await tx.userRole.deleteMany({ where: { userId: id } }); await tx.userRole.create({ data: { userId: id, roleId: role.id } }); } return tx.user.update({ where: { id }, data: { tenantId, username: data.username ?? current.username, email: data.email === undefined ? undefined : normalizeOptional(data.email), phone: data.phone === undefined ? undefined : normalizeOptional(data.phone), displayName: data.displayName ?? current.displayName, status: data.status ?? current.status, ...((data.status === 'disabled' && current.status !== 'disabled') || roleChanged || tenantChanged ? { sessionVersion: { increment: 1 } } : {}), }, include: { tenant: true, roles: { include: { role: true } } }, }); })); await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username }); return publicUser(updated); } async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string, currentUserId?: string) { const current = await this.getExisting(id, scopeTenantId); if (data.status === 'disabled' && currentUserId === id) { throw new ForbiddenException({ code: 'CANNOT_DISABLE_SELF', message: '不能禁用当前登录用户' }); } await this.assertAdminContinuity(current, data.status, current.roles[0]?.role.code, current.tenantId); const updated = await this.prisma.user.update({ where: { id }, data: { status: data.status, ...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}) }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username }); return publicUser(updated); } async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) { if (!data.password || data.password.length < 6) { throw new BadRequestException('password must be at least 6 characters'); } const current = await this.getExisting(id, scopeTenantId); const updated = await this.prisma.user.update({ where: { id }, data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username }); return publicUser(updated); } async remove(id: string, operatorId?: string, scopeTenantId?: string) { const current = await this.getExisting(id, scopeTenantId); if (operatorId === id) { throw new ForbiddenException({ code: 'CANNOT_DELETE_SELF', message: '不能删除当前登录用户' }); } await this.assertAdminContinuity(current, 'deleted', current.roles[0]?.role.code, current.tenantId); const updated = await this.prisma.user.update({ where: { id }, data: { status: 'deleted', deletedAt: new Date(), sessionVersion: { increment: 1 } }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username }); return publicUser(updated); } async recordLoginSuccess(id: string) { const updated = await this.prisma.user.update({ where: { id }, data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() }, }); await this.writeLog(updated.tenantId, updated.id, 'auth.login_success', updated.id, { username: updated.username }); return publicUser(updated); } async recordLoginFailure(id: string) { const current = await this.prisma.user.findUnique({ where: { id } }); if (!current) return null; const failedLoginCount = current.failedLoginCount + 1; return this.prisma.user.update({ where: { id }, data: { failedLoginCount, lockedUntil: failedLoginCount >= 5 ? new Date(Date.now() + 24 * 60 * 60 * 1000) : current.lockedUntil, }, }); } async changeOwnPassword(id: string, currentPassword: string, password: string) { if (!currentPassword || !password || password.length < 6) { throw new BadRequestException('currentPassword and a password of at least 6 characters are required'); } const current = await this.getExisting(id); if (current.passwordHash !== hashPassword(currentPassword)) { throw new BadRequestException('当前密码不正确'); } const updated = await this.prisma.user.update({ where: { id }, data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username }); return publicUser(updated); } async verifyCurrentPassword(id: string, password: string) { if (!password) { throw new BadRequestException('请输入当前密码'); } const current = await this.getExisting(id); if (current.passwordHash !== hashPassword(password)) { throw new BadRequestException('当前密码不正确'); } return current; } listRoles() { return this.prisma.role.findMany({ include: { permissions: { include: { permission: true } } }, orderBy: { createdAt: 'desc' }, }); } createRole(data: CreateRoleDto) { return this.prisma.role.create({ data: { code: data.code, name: data.name, scope: data.scope ?? 'platform', description: data.description, }, }); } listPermissions() { return this.prisma.permission.findMany({ orderBy: { createdAt: 'desc' } }); } createPermission(data: CreatePermissionDto) { return this.prisma.permission.create({ data }); } assignRole(data: AssignRoleDto) { return this.prisma.$transaction(async (tx) => { const assignment = await tx.userRole.upsert({ where: { userId_roleId: { userId: data.userId, roleId: data.roleId } }, update: {}, create: data, }); await tx.user.update({ where: { id: data.userId }, data: { sessionVersion: { increment: 1 } } }); return assignment; }); } assignPermission(data: AssignPermissionDto) { return this.prisma.$transaction(async (tx) => { const assignment = await tx.rolePermission.upsert({ where: { roleId_permissionId: { roleId: data.roleId, permissionId: data.permissionId } }, update: {}, create: data, }); await tx.user.updateMany({ where: { roles: { some: { roleId: data.roleId } } }, data: { sessionVersion: { increment: 1 } } }); return assignment; }); } private async ensureRole(code: UserRoleCode) { const info = roleNames[code]; return this.prisma.role.upsert({ where: { code }, update: { name: info.name, scope: info.scope }, create: { code, name: info.name, scope: info.scope }, }); } private async getExisting(id: string, scopeTenantId?: string) { const user = await this.prisma.user.findFirst({ where: { id, deletedAt: null, ...(scopeTenantId ? { tenantId: scopeTenantId } : {}) }, include: { tenant: true, roles: { include: { role: true } } }, }); if (!user) { throw new NotFoundException('User not found'); } return user; } private assertUserInput(data: CreateUserDto, scopeTenantId: string | undefined, requirePassword: boolean) { if (!data.displayName?.trim()) { throw new BadRequestException('displayName is required'); } if (requirePassword && (!data.password || data.password.length < 6)) { throw new BadRequestException('password must be at least 6 characters'); } if (!data.email && !data.phone) { throw new BadRequestException('email or phone is required'); } if (data.roleCode === 'platform_admin' && (scopeTenantId || data.tenantId)) { throw new BadRequestException('platform_admin must not be linked to a tenant'); } if (data.roleCode === 'enterprise_admin' && !(scopeTenantId || data.tenantId)) { throw new BadRequestException('enterprise_admin must be linked to a tenant'); } if (scopeTenantId && data.roleCode === 'platform_admin') { throw new BadRequestException('client user management cannot create platform admins'); } } private async assertAdminContinuity( current: { id: string; tenantId: string | null; status: string; roles: Array<{ role: { code: string } }> }, nextStatus: string, nextRoleCode?: string, nextTenantId?: string | null, ) { const currentRole = current.roles[0]?.role.code; if (current.status !== 'active' || !['platform_admin', 'enterprise_admin'].includes(currentRole)) return; const remainsSameAdmin = nextStatus === 'active' && nextRoleCode === currentRole && (currentRole !== 'enterprise_admin' || nextTenantId === current.tenantId); if (remainsSameAdmin) return; const activeCount = await this.prisma.user.count({ where: { deletedAt: null, status: 'active', tenantId: currentRole === 'platform_admin' ? null : current.tenantId, roles: { some: { role: { code: currentRole } } }, }, }); if (activeCount <= 1) { throw new ConflictException({ code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN', message: currentRole === 'platform_admin' ? '不能删除、禁用或降权最后一个平台管理员' : '不能删除、禁用或降权最后一个企业管理员', }); } } private async mapUniqueConflict(operation: () => Promise): Promise { try { return await operation(); } catch (error) { if ((error as { code?: string }).code !== 'P2002') throw error; const target = (error as { meta?: { target?: string[] | string } }).meta?.target; const field = Array.isArray(target) ? target[0] : target; throw new ConflictException({ code: 'USER_DUPLICATE', field: field ?? 'login', message: `用户${field ? `字段 ${field}` : '登录标识'}已存在;逻辑删除后仍永久保留以维持审计关联`, }); } } private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { tenantId: tenantId ?? undefined, userId, action, resource: 'user', resourceId, detail: detail as Prisma.InputJsonObject, }, }); } } function publicUser>(user: T) { const { passwordHash: _passwordHash, sessionVersion: _sessionVersion, failedLoginCount: _failedLoginCount, ...safe } = user; return safe; } function normalizeOptional(value?: string | null) { const next = value?.trim(); return next ? next : null; } export function hashPassword(password: string) { return createHash('sha256').update(password).digest('hex'); }