424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
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<UserRoleCode, { name: string; scope: string }> = {
|
|
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<T>(operation: () => Promise<T>): Promise<T> {
|
|
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<string, unknown>) {
|
|
return this.prisma.operationLog.create({
|
|
data: {
|
|
tenantId: tenantId ?? undefined,
|
|
userId,
|
|
action,
|
|
resource: 'user',
|
|
resourceId,
|
|
detail: detail as Prisma.InputJsonObject,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function publicUser<T extends Record<string, any>>(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');
|
|
}
|