diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e331c9d..0706d40 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -17,6 +17,7 @@ const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); const LOGIN_FAIL_PREFIX = 'loginFail:'; const MAX_LOGIN_FAILS = 5; +const MAX_GLOBAL_LOGIN_FAILS = 20; const LOGIN_LOCK_SECONDS = 900; export interface PermissionSet { @@ -125,28 +126,35 @@ export class AuthService { return { needSelectTenant: true, tenants: matched }; } - /** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期(按 IP + 用户名,防恶意锁他人账号)。 */ + /** 登录失败锁定:双层阈值(IP+用户名 低阈值防单点爆破,用户名全局 高阈值防换 IP 爆破)。 */ private async assertNotLocked(ip: string | undefined, username: string): Promise { - const count = (await this.redis.getJson(this.lockKey(ip, username))) ?? 0; - if (count >= MAX_LOGIN_FAILS) { + const perIp = (await this.redis.getJson(this.lockKey(ip, username))) ?? 0; + const global = (await this.redis.getJson(this.globalLockKey(username))) ?? 0; + if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) { throw new UnauthorizedException('登录失败次数过多,请稍后再试'); } } private async recordLoginFail(ip: string | undefined, username: string): Promise { - const key = this.lockKey(ip, username); - const count = ((await this.redis.getJson(key)) ?? 0) + 1; - await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS); + const perIpKey = this.lockKey(ip, username); + const globalKey = this.globalLockKey(username); + await this.redis.setJson(perIpKey, ((await this.redis.getJson(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS); + await this.redis.setJson(globalKey, ((await this.redis.getJson(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS); } private async clearLoginFail(ip: string | undefined, username: string): Promise { await this.redis.del(this.lockKey(ip, username)); + await this.redis.del(this.globalLockKey(username)); } private lockKey(ip: string | undefined, username: string): string { return `${LOGIN_FAIL_PREFIX}${ip ?? 'unknown'}:${username}`; } + private globalLockKey(username: string): string { + return `${LOGIN_FAIL_PREFIX}global:${username}`; + } + /** 指定租户登录(单租户逻辑)。 */ private async loginTenant( tenantCode: string, diff --git a/src/common/page.dto.ts b/src/common/page.dto.ts index 231efb0..c914a0f 100644 --- a/src/common/page.dto.ts +++ b/src/common/page.dto.ts @@ -1,6 +1,6 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; export class PageQuery { @ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 }) @@ -24,3 +24,11 @@ export class IdParam { @IsOptional() id!: string; } + +export class BatchDeleteDto { + @ApiProperty({ description: '待删除记录 ID 列表', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + ids!: string[]; +} diff --git a/src/orgs/orgs.controller.ts b/src/orgs/orgs.controller.ts index c3d1112..283a639 100644 --- a/src/orgs/orgs.controller.ts +++ b/src/orgs/orgs.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateOrgDto, UpdateOrgDto } from './dto.js'; +import { BatchDeleteDto } from '../common/page.dto.js'; import { OrgsService } from './orgs.service.js'; @Controller('orgs') @@ -40,4 +41,11 @@ export class OrgsController { remove(@Param('id') id: string) { return this.service.softRemove(id); } + + @Post('batch-delete') + @RequirePermissions(Permissions.org.delete) + @AuditLog('org', 'batch-delete') + batchDelete(@Body() dto: BatchDeleteDto) { + return this.service.batchDelete(dto.ids); + } } diff --git a/src/orgs/orgs.service.ts b/src/orgs/orgs.service.ts index 46d07e7..b086385 100644 --- a/src/orgs/orgs.service.ts +++ b/src/orgs/orgs.service.ts @@ -33,6 +33,19 @@ export class OrgsService extends BaseCrudService { return this.update(id, { ...dto }); } + /** 批量删除组织(软删 + 回收 code),收集失败项。 */ + async batchDelete(ids: string[]) { + const failed: string[] = []; + for (const id of ids) { + try { + await this.softRemove(id); + } catch { + failed.push(id); + } + } + return { total: ids.length, success: ids.length - failed.length, failed }; + } + /** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */ private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise { if (!parentCode) return; // 根节点 diff --git a/src/resources/resources.controller.ts b/src/resources/resources.controller.ts index 7a937c0..a9240db 100644 --- a/src/resources/resources.controller.ts +++ b/src/resources/resources.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateResourceDto, UpdateResourceDto } from './dto.js'; +import { BatchDeleteDto } from '../common/page.dto.js'; import { ResourcesService } from './resources.service.js'; @Controller('resources') @@ -40,4 +41,11 @@ export class ResourcesController { remove(@Param('id') id: string) { return this.service.softRemove(id); } + + @Post('batch-delete') + @RequirePermissions(Permissions.resource.delete) + @AuditLog('resource', 'batch-delete') + batchDelete(@Body() dto: BatchDeleteDto) { + return this.service.batchDelete(dto.ids); + } } diff --git a/src/resources/resources.service.ts b/src/resources/resources.service.ts index 7ef11ce..57eb2d4 100644 --- a/src/resources/resources.service.ts +++ b/src/resources/resources.service.ts @@ -32,6 +32,20 @@ export class ResourcesService extends BaseCrudService { return removed; } + /** 批量删除:逐个软删(回收 code),最后统一清权限缓存。 */ + async batchDelete(ids: string[]) { + const failed: string[] = []; + for (const id of ids) { + try { + await super.softRemove(id); + } catch { + failed.push(id); + } + } + await this.clearPermCache(); + return { total: ids.length, success: ids.length - failed.length, failed }; + } + /** 资源变更(含停用/删除)影响权限码聚合,清该租户全部权限缓存。 */ private async clearPermCache(): Promise { const ctx = getRequestContext(); diff --git a/src/roles/dto.ts b/src/roles/dto.ts index 8107610..4dcd9c0 100644 --- a/src/roles/dto.ts +++ b/src/roles/dto.ts @@ -54,6 +54,13 @@ export class AssignResourcesDto { resourceCodes!: string[]; } +export class AssignOrgsDto { + @ApiProperty({ description: '部门 code 列表(数据权限=自定义时)', type: [String] }) + @IsArray() + @IsString({ each: true }) + orgCodes!: string[]; +} + export class ListRoleQuery extends PageQuery { @ApiPropertyOptional({ description: '角色名称(模糊)' }) @IsOptional() diff --git a/src/roles/roles.controller.ts b/src/roles/roles.controller.ts index b10b3ce..91dff0e 100644 --- a/src/roles/roles.controller.ts +++ b/src/roles/roles.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; -import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; +import { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; import { RolesService } from './roles.service.js'; @Controller('roles') @@ -41,16 +41,29 @@ export class RolesController { return this.service.removeRole(id); } - @Get(':id/resources') + @Get(':roleCode/resources') @RequirePermissions(Permissions.role.list) - resources(@Param('id') id: string) { - return this.service.assignedResources(id); + resources(@Param('roleCode') roleCode: string) { + return this.service.assignedResources(roleCode); } - @Put(':id/resources') + @Put(':roleCode/resources') @RequirePermissions(Permissions.role.assignResource) @AuditLog('role', 'assign-resource') - assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) { - return this.service.assignResources(id, dto); + assign(@Param('roleCode') roleCode: string, @Body() dto: AssignResourcesDto) { + return this.service.assignResources(roleCode, dto); + } + + @Get(':roleCode/orgs') + @RequirePermissions(Permissions.role.list) + orgs(@Param('roleCode') roleCode: string) { + return this.service.assignedOrgs(roleCode); + } + + @Put(':roleCode/orgs') + @RequirePermissions(Permissions.role.update) + @AuditLog('role', 'assign-org') + assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) { + return this.service.assignOrgs(roleCode, dto); } } diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index d248ece..e6a492f 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { PLATFORM_PERMISSION_PREFIX, PLATFORM_TENANT_CODE, RESERVED_ROLE_CODES } from '../common/constants.js'; -import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; +import { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; @Injectable() export class RolesService extends BaseCrudService { @@ -67,6 +67,33 @@ export class RolesService extends BaseCrudService { return rows.map((r) => r.resourceCode); } + /** 角色已分配的数据权限部门 code 列表(data_scope=custom)。 */ + async assignedOrgs(roleCode: string) { + const ctx = getRequestContext()!; + const rows = await this.prisma.client.roleOrg.findMany({ + where: { tenantCode: ctx.tenantCode, roleCode }, + select: { orgCode: true }, + }); + return rows.map((r) => r.orgCode); + } + + /** 分配数据权限部门(事务化替换)。 */ + async assignOrgs(roleCode: string, dto: AssignOrgsDto) { + const ctx = getRequestContext()!; + const role = await this.db().findFirst({ where: { code: roleCode } }); + if (!role) throw new BadRequestException('角色不存在'); + await this.prisma.raw.$transaction(async (tx) => { + await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + if (dto.orgCodes.length > 0) { + await tx.roleOrg.createMany({ + data: dto.orgCodes.map((orgCode) => ({ tenantCode: ctx.tenantCode, roleCode, orgCode })), + }); + } + }); + await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); + return null; + } + async assignResources(roleCode: string, dto: AssignResourcesDto) { const ctx = getRequestContext()!; // 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离 @@ -76,6 +103,18 @@ export class RolesService extends BaseCrudService { : dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX)); const role = await this.db().findFirst({ where: { code: roleCode } }); if (!role) throw new BadRequestException('角色不存在'); + // 校验 resourceCodes 存在性(避免静默忽略错误码) + const existing = resourceCodes.length + ? await this.prisma.client.resource.findMany({ + where: { tenantCode: ctx.tenantCode, code: { in: resourceCodes }, deletedAt: null }, + select: { code: true }, + }) + : []; + const validCodes = existing.map((r) => r.code); + if (validCodes.length !== resourceCodes.length) { + const invalid = resourceCodes.filter((c) => !validCodes.includes(c)); + throw new BadRequestException(`资源不存在:${invalid.join(', ')}`); + } // 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空 await this.prisma.client.$transaction(async (tx) => { await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index a180b64..087240e 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -4,6 +4,7 @@ import type { PrismaClient } from '@prisma/client'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; +import { getRequestContext } from '../common/context/request-context.js'; import { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js'; import { initTenant } from '../common/tenant-template.js'; import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js'; @@ -19,11 +20,18 @@ export class TenantsService extends BaseCrudService { /** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */ async createTenant(dto: CreateTenantDto) { + const ctx = getRequestContext(); const code = dto.code; const adminPassword = dto.adminPassword ?? randomUUID().slice(0, 12); await this.prisma.raw.$transaction(async (tx) => { await tx.tenant.create({ - data: { code, name: dto.name, status: dto.status ?? 'enabled', remark: dto.remark }, + data: { + code, + name: dto.name, + status: dto.status ?? 'enabled', + remark: dto.remark, + createdBy: ctx?.userId ?? null, + }, }); await initTenant({ prisma: tx as unknown as PrismaClient, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index d4dac8a..59fb309 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@ import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; +import { BatchDeleteDto } from '../common/page.dto.js'; import { UsersService } from './users.service.js'; @Controller('users') @@ -60,4 +61,11 @@ export class UsersController { remove(@Param('id') id: string) { return this.service.softRemove(id); } + + @Post('batch-delete') + @RequirePermissions(Permissions.user.delete) + @AuditLog('user', 'batch-delete') + batchDelete(@Body() dto: BatchDeleteDto) { + return this.service.batchDelete(dto.ids); + } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index f7c3c12..23e759e 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -81,6 +81,19 @@ export class UsersService extends BaseCrudService { return this.omitPasswordHash(removed); } + /** 批量删除:逐个软删 + 吊销会话,收集失败项。 */ + async batchDelete(ids: string[]) { + const failed: string[] = []; + for (const id of ids) { + try { + await this.softRemove(id); + } catch { + failed.push(id); + } + } + return { total: ids.length, success: ids.length - failed.length, failed }; + } + /** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */ private async invalidateSessions(userId: string): Promise { const ctx = getRequestContext();