feat: harden CMPP delivery and platform workflows

This commit is contained in:
hectorzhao
2026-07-20 18:07:29 +08:00
parent 80fb5a8f53
commit f02c33cbb7
61 changed files with 1834 additions and 281 deletions
+86 -26
View File
@@ -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;