feat: complete login and user management flow

This commit is contained in:
hectorzhao
2026-07-02 16:28:48 +08:00
parent 9c639d54b9
commit e26d8324c3
22 changed files with 1320 additions and 300 deletions
+227 -12
View File
@@ -1,13 +1,41 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { BadRequestException, 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;
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 {
@@ -33,31 +61,159 @@ export interface AssignPermissionDto {
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) {}
list(tenantId?: string) {
list(tenantId?: string, roleCode?: string) {
return this.prisma.user.findMany({
where: tenantId ? { tenantId } : undefined,
include: { roles: { include: { role: true } } },
where: {
deletedAt: null,
...(tenantId ? { tenantId } : {}),
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
},
include: { tenant: true, roles: { include: { role: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
take: 200,
});
}
findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username } });
listClientUsers(tenantId?: string) {
if (!tenantId) {
throw new BadRequestException('tenantId is required for client user management');
}
return this.list(tenantId);
}
create(data: CreateUserDto) {
return this.prisma.user.create({
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.prisma.user.create({
data: {
tenantId: data.tenantId,
username: data.username,
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;
}
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;
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.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,
},
include: { tenant: true, roles: { include: { role: true } } },
});
});
await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username });
return updated;
}
async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: data.status },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
return 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 },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
return updated;
}
async remove(id: string, operatorId?: string, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: 'deleted', deletedAt: new Date() },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
return updated;
}
async recordLoginSuccess(id: string) {
return this.prisma.user.update({
where: { id },
data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
}
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,
},
});
}
@@ -104,6 +260,65 @@ export class UsersService {
create: data,
});
}
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 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 normalizeOptional(value?: string | null) {
const next = value?.trim();
return next ? next : null;
}
export function hashPassword(password: string) {