feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -70,8 +70,8 @@ const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, roleCode?: string) {
|
||||
return this.prisma.user.findMany({
|
||||
async list(tenantId?: string, roleCode?: string) {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
@@ -80,6 +80,7 @@ export class UsersService {
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return users.map(publicUser);
|
||||
}
|
||||
|
||||
listClientUsers(tenantId?: string) {
|
||||
@@ -108,21 +109,21 @@ export class UsersService {
|
||||
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
|
||||
const tenantId = scopeTenantId ?? data.tenantId;
|
||||
const role = await this.ensureRole(roleCode);
|
||||
const user = await 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 } } },
|
||||
});
|
||||
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 user;
|
||||
return publicUser(user);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateUserDto, scopeTenantId?: string) {
|
||||
@@ -130,6 +131,7 @@ export class UsersService {
|
||||
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,
|
||||
@@ -141,7 +143,7 @@ export class UsersService {
|
||||
}, scopeTenantId, false);
|
||||
const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId;
|
||||
const role = roleCode ? await this.ensureRole(roleCode) : null;
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
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 } });
|
||||
@@ -159,20 +161,24 @@ export class UsersService {
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
});
|
||||
}));
|
||||
await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username });
|
||||
return updated;
|
||||
return publicUser(updated);
|
||||
}
|
||||
|
||||
async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) {
|
||||
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 updated;
|
||||
return publicUser(updated);
|
||||
}
|
||||
|
||||
async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) {
|
||||
@@ -186,25 +192,31 @@ export class UsersService {
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
||||
return updated;
|
||||
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 updated;
|
||||
return publicUser(updated);
|
||||
}
|
||||
|
||||
async recordLoginSuccess(id: string) {
|
||||
return this.prisma.user.update({
|
||||
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) {
|
||||
@@ -234,7 +246,7 @@ export class UsersService {
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
|
||||
return updated;
|
||||
return publicUser(updated);
|
||||
}
|
||||
|
||||
async verifyCurrentPassword(id: string, password: string) {
|
||||
@@ -339,6 +351,49 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -353,6 +408,11 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user