Compare commits

..

10 Commits

Author SHA1 Message Date
liuyun
6618137da5 feat(auth): 平台超管租户切换(super 标志 + switch-tenant)
- JWT 加 super 标志,sub 保持超管真实 userId,tenantCode 随切换
- isSuper 解析/checkUserActive/PermissionsGuard 支持跨租户超管
- POST /auth/switch-tenant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 00:00:38 +08:00
liuyun
477f35293e fix(security): 若干越权/健壮性修复
- assignOrgs 校验 orgCodes 存在性(对齐 assignResources)
- lock/kick 保护保留角色账号(super_admin/tenant_super_admin)
- BatchDeleteDto 加 @ArrayMaxSize(100)
- 待改密门禁显式放行 /auth/logout
- 分配部门改用独立权限码 role:assign-org

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 22:56:12 +08:00
liuyun
8eefa7fa44 feat(error): 业务错误码注册表(业务域+错误序)+ BusinessException
- error-codes 单一注册表 + BusinessException + 过滤器识别
- 迁移登录/锁定/待改密/用户名冲突/租户/会话等关键错误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 22:42:12 +08:00
liuyun
f747ad5442 feat(tenant): 租户品牌定制(brand_name/logo_url/theme_color/copyright + GET /auth/brand)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 22:32:14 +08:00
liuyun
bad8639175 feat(notice): 站内通知(公告 + 已读追踪)
- Notice/NoticeRead 实体;管理侧 CRUD + 用户侧 mine/unread-count/read/read-all
- 资源注册 notice 菜单 + 接口

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 22:25:19 +08:00
liuyun
9869974d4c feat(auth): 首登强制改密 + 待改密门禁
- User 新增 must_change_password;创建(默认强制)/重置/createTenant 置 true
- 登录响应返回 mustChangePassword,改密成功清除
- JwtAuthGuard 待改密门禁:仅放行 PATCH /auth/me/password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 21:54:38 +08:00
liuyun
86832ce32d feat(session): 在线会话强踢 + 账号锁定/解锁
- JWT 引入 sid,单会话精确强退(sid 黑名单)与按用户踢全部(tokenVersion)
- User 新增 lockedAt/lockReason,登录/刷新/守卫统一拒绝锁定
- sessions 模块:活跃会话列表 + 单会话强退;users 新增 lock/unlock/kick-sessions
- 资源注册 + syncTenantResources 存量租户补齐脚本

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 21:35:43 +08:00
liuyun
127707ffde feat(rbac): 批量删除 + 数据权限自定义闭环 + 若干修复
- users/orgs/resources 新增 POST /batch-delete(事务 + 失败汇总 + 用户踢下线)
- 角色数据权限自定义:GET/PUT /roles/:roleCode/orgs(roleOrg 写入路径)
- assignResources 校验 resourceCodes 存在性;角色 :id 语义改 :roleCode
- createTenant 补 createdBy;登录锁定双层阈值(IP+用户名 / 用户名全局)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 17:42:05 +08:00
liuyun
200cedeb59 fix: 第二轮残留问题
- 资源变更清权限缓存(H4 残留)
- 菜单聚合过滤已删角色(与 resolveAuth 一致)
- removeRole 事务化;removeTenant 清 Redis(userState/perm)
- createTenant 事务化 + 未提供密码时生成随机密码
- 登录失败锁定加 IP 维度;register 禁止平台租户
- refresh token 落 ip/userAgent;e2e 用独立账号避免锁死
- .env.example 补 CORS_ORIGIN/NODE_ENV、脱敏默认密码

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 17:22:10 +08:00
liuyun
9a9435e665 ci: 后端 Dockerfile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 17:02:18 +08:00
48 changed files with 1332 additions and 221 deletions

View File

@ -1,12 +1,18 @@
# 复制为 .env 并按需修改
# 数据库(MySQL 8)
DATABASE_URL="mysql://root:Mysql.123@127.0.0.1:3306/sysmgr"
DATABASE_URL="mysql://root:请替换为数据库密码@127.0.0.1:3306/sysmgr"
# 服务端口
PORT=3000
# JWT(生产务必替换 secret)
# 运行环境(production 会关闭 Swagger 并强校验 JWT_SECRET)
NODE_ENV="development"
# CORS 允许来源(逗号分隔;生产改为实际前端域名)
CORS_ORIGIN="http://localhost:5173"
# JWT(生产务必替换 secret,至少 32 位)
JWT_SECRET="dev-secret-change-me"
JWT_ACCESS_TTL="7200"
JWT_REFRESH_TTL="604800"

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec prisma generate && pnpm build
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@ -26,7 +26,8 @@
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
"seed": "prisma db seed"
"seed": "prisma db seed",
"sync:resources": "tsx prisma/sync-resources.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE `sys_refresh_token` ADD COLUMN `sid` VARCHAR(36) NULL;
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `lock_reason` VARCHAR(255) NULL,
ADD COLUMN `locked_at` DATETIME(3) NULL;
-- CreateIndex
CREATE INDEX `sys_refresh_token_sid_idx` ON `sys_refresh_token`(`sid`);

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `must_change_password` BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE `sys_notice` (
`id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`title` VARCHAR(128) NOT NULL,
`content` TEXT NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
`created_by` VARCHAR(36) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,
INDEX `sys_notice_tenant_code_idx`(`tenant_code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sys_notice_read` (
`id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`notice_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`read_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `sys_notice_read_tenant_code_user_id_idx`(`tenant_code`, `user_id`),
UNIQUE INDEX `sys_notice_read_tenant_code_notice_id_user_id_key`(`tenant_code`, `notice_id`, `user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE `sys_tenant` ADD COLUMN `brand_name` VARCHAR(64) NULL,
ADD COLUMN `copyright` VARCHAR(255) NULL,
ADD COLUMN `logo_url` VARCHAR(255) NULL,
ADD COLUMN `theme_color` VARCHAR(16) NULL;

View File

@ -12,35 +12,42 @@ datasource db {
/// 租户
model Tenant {
id String @id @default(uuid(7)) @db.VarChar(36)
code String @unique @db.VarChar(64)
name String @db.VarChar(128)
status String @default("enabled") @db.VarChar(16)
remark String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid(7)) @db.VarChar(36)
code String @unique @db.VarChar(64)
name String @db.VarChar(128)
status String @default("enabled") @db.VarChar(16)
remark String? @db.VarChar(255)
brandName String? @map("brand_name") @db.VarChar(64)
logoUrl String? @map("logo_url") @db.VarChar(255)
themeColor String? @map("theme_color") @db.VarChar(16)
copyright String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sys_tenant")
}
/// 用户
model User {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
username String @db.VarChar(64)
passwordHash String @map("password_hash") @db.VarChar(255)
nickname String? @db.VarChar(64)
email String? @db.VarChar(128)
phone String? @db.VarChar(32)
avatar String? @db.VarChar(255)
status String @default("enabled") @db.VarChar(16)
tokenVersion Int @default(0) @map("token_version")
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
username String @db.VarChar(64)
passwordHash String @map("password_hash") @db.VarChar(255)
nickname String? @db.VarChar(64)
email String? @db.VarChar(128)
phone String? @db.VarChar(32)
avatar String? @db.VarChar(255)
status String @default("enabled") @db.VarChar(16)
tokenVersion Int @default(0) @map("token_version")
lockedAt DateTime? @map("locked_at")
lockReason String? @map("lock_reason") @db.VarChar(255)
mustChangePassword Boolean @default(false) @map("must_change_password")
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@unique([tenantCode, username])
@@index([tenantCode])
@ -50,17 +57,17 @@ model User {
/// 角色
model Role {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
code String @db.VarChar(64)
name String @db.VarChar(64)
dataScope String @default("all") @map("data_scope") @db.VarChar(16)
status String @default("enabled") @db.VarChar(16)
remark String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
code String @db.VarChar(64)
name String @db.VarChar(64)
dataScope String @default("all") @map("data_scope") @db.VarChar(16)
status String @default("enabled") @db.VarChar(16)
remark String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@unique([tenantCode, code])
@@index([tenantCode])
@ -213,6 +220,7 @@ model RefreshToken {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
userId String @map("user_id") @db.VarChar(36)
sid String? @db.VarChar(36)
jti String @unique @db.VarChar(64)
tokenHash String @map("token_hash") @db.VarChar(255)
ip String? @db.VarChar(64)
@ -223,15 +231,45 @@ model RefreshToken {
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantCode, userId])
@@index([sid])
@@map("sys_refresh_token")
}
/// 通知/公告
model Notice {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
title String @db.VarChar(128)
content String @db.Text
status String @default("enabled") @db.VarChar(16)
createdBy String? @map("created_by") @db.VarChar(36)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([tenantCode])
@@map("sys_notice")
}
/// 通知已读追踪
model NoticeRead {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
noticeId String @map("notice_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
readAt DateTime @default(now()) @map("read_at")
@@unique([tenantCode, noticeId, userId])
@@index([tenantCode, userId])
@@map("sys_notice_read")
}
/// 用户-角色关联
model UserRole {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
userId String @map("user_id") @db.VarChar(36)
roleCode String @map("role_code") @db.VarChar(64)
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
userId String @map("user_id") @db.VarChar(36)
roleCode String @map("role_code") @db.VarChar(64)
@@unique([tenantCode, userId, roleCode])
@@index([tenantCode, userId])

30
prisma/sync-resources.ts Normal file
View File

@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE, TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js';
import { syncTenantResources } from '../src/common/tenant-template.js';
const prisma = new PrismaClient();
/** 为所有存量租户补齐资源/字典/授权(幂等;不触碰用户/密码)。 */
async function main(): Promise<void> {
const tenants = await prisma.tenant.findMany({ select: { code: true } });
for (const t of tenants) {
const isPlatform = t.code === PLATFORM_TENANT_CODE;
await syncTenantResources({
prisma,
tenantCode: t.code,
adminRoleCode: isPlatform ? PLATFORM_ROLE_CODE : TENANT_ADMIN_ROLE_CODE,
includePlatformPerms: isPlatform,
});
console.log(`已同步资源:${t.code}(${isPlatform ? '平台' : '业务'}租户)`);
}
console.log(`syncTenantResources 完成,共 ${tenants.length} 个租户。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -23,6 +23,8 @@ import { DictsModule } from './dicts/dicts.module.js';
import { ConfigsModule } from './configs/configs.module.js';
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
import { LoginLogsModule } from './login-logs/login-logs.module.js';
import { SessionsModule } from './sessions/sessions.module.js';
import { NoticesModule } from './notices/notices.module.js';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@ -43,6 +45,8 @@ import { AppService } from './app.service.js';
ConfigsModule,
AuditLogsModule,
LoginLogsModule,
SessionsModule,
NoticesModule,
],
controllers: [AppController],
providers: [

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto } from './dto.js';
import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto, SwitchTenantDto } from './dto.js';
import { Public } from './decorators.js';
import { getRequestContext } from '../common/context/request-context.js';
@ -38,7 +38,7 @@ export class AuthController {
menus() {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getMenus(ctx.tenantCode, ctx.userId);
return this.auth.getMenus(ctx.tenantCode, ctx.userId, ctx.super);
}
/** 当前用户权限码(含超管标志),供前端按钮/路由级权限控制(仅需登录)。 */
@ -46,7 +46,23 @@ export class AuthController {
permissions() {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId);
return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId, ctx.super);
}
/** 平台超管切换租户(仅需登录;内部校验调用者为平台超管)。 */
@Post('switch-tenant')
switchTenant(@Body() dto: SwitchTenantDto, @Req() req: Request) {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.switchTenant(ctx.userId, dto, req.ip, req.headers['user-agent']);
}
/** 当前租户品牌(仅需登录)。 */
@Get('brand')
brand() {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getBrand(ctx.tenantCode);
}
/** 修改本人密码(仅需登录)。 */

View File

@ -1,22 +1,24 @@
import { BadRequestException, ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, ConflictException, ForbiddenException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } from './dto.js';
import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto, SwitchTenantDto } from './dto.js';
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { LoginLogsService } from '../login-logs/login-logs.service.js';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } from '../common/constants.js';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, userStateCacheKey } from '../common/constants.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
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 {
@ -85,28 +87,28 @@ export class AuthService {
ip?: string,
userAgent?: string,
): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number }
| { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> {
await this.assertNotLocked(dto.username);
await this.assertNotLocked(ip, dto.username);
if (dto.tenantCode) {
try {
const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
await this.clearLoginFail(dto.username);
await this.clearLoginFail(ip, dto.username);
return result;
} catch (e) {
if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username);
if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username);
throw e;
}
}
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
const users = await this.prisma.client.user.findMany({
where: { username: dto.username, deletedAt: null },
select: { tenantCode: true, passwordHash: true, status: true },
select: { tenantCode: true, passwordHash: true, status: true, lockedAt: true },
});
const matched: { tenantCode: string; tenantName: string }[] = [];
for (const u of users) {
if (u.status !== 'enabled') continue;
if (u.status !== 'enabled' || u.lockedAt) continue;
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
if (!tenant || tenant.status !== 'enabled') continue;
if (await bcrypt.compare(dto.password, u.passwordHash)) {
@ -114,33 +116,44 @@ export class AuthService {
}
}
if (matched.length === 0) {
await this.recordLoginFail(dto.username);
await this.recordLoginFail(ip, dto.username);
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
}
await this.clearLoginFail(dto.username);
await this.clearLoginFail(ip, dto.username);
if (matched.length === 1) {
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
}
return { needSelectTenant: true, tenants: matched };
}
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */
private async assertNotLocked(username: string): Promise<void> {
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
if (count >= MAX_LOGIN_FAILS) {
/** 登录失败锁定:双层阈值(IP+用户名 低阈值防单点爆破,用户名全局 高阈值防换 IP 爆破)。 */
private async assertNotLocked(ip: string | undefined, username: string): Promise<void> {
const perIp = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
const global = (await this.redis.getJson<number>(this.globalLockKey(username))) ?? 0;
if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
}
}
private async recordLoginFail(username: string): Promise<void> {
const key = `${LOGIN_FAIL_PREFIX}${username}`;
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
private async recordLoginFail(ip: string | undefined, username: string): Promise<void> {
const perIpKey = this.lockKey(ip, username);
const globalKey = this.globalLockKey(username);
await this.redis.setJson(perIpKey, ((await this.redis.getJson<number>(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
await this.redis.setJson(globalKey, ((await this.redis.getJson<number>(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
}
private async clearLoginFail(username: string): Promise<void> {
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`);
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
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}`;
}
/** 指定租户登录(单租户逻辑)。 */
@ -150,27 +163,31 @@ export class AuthService {
password: string,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
if (!tenant || tenant.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
throw new UnauthorizedException('租户不存在或已停用');
throw new BusinessException(ErrorCodes.TENANT_DISABLED, '租户不存在或已停用', HttpStatus.UNAUTHORIZED);
}
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, username, deletedAt: null },
});
if (!user) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
}
if (user.lockedAt) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent);
throw new BusinessException(ErrorCodes.AUTH_LOCKED, '账号已锁定', HttpStatus.UNAUTHORIZED);
}
if (user.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
throw new UnauthorizedException('账号已停用或锁定');
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
throw new BusinessException(ErrorCodes.AUTH_DISABLED, '账号已停用', HttpStatus.UNAUTHORIZED);
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
}
await this.writePermissionCache(tenantCode, user.id);
await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
@ -184,17 +201,26 @@ export class AuthService {
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return this.issue(tenantCode, user.id, user.username, user.tokenVersion);
const tokens = await this.issue(tenantCode, user.id, user.username, user.tokenVersion, {
sid: randomUUID(),
loginAt: new Date(),
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return { ...tokens, mustChangePassword: user.mustChangePassword };
}
/** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
async register(dto: RegisterDto): Promise<{ id: string; username: string }> {
if (dto.tenantCode === PLATFORM_TENANT_CODE) {
throw new BadRequestException('平台租户不允许注册');
}
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用');
const exists = await this.prisma.client.user.findFirst({
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
});
if (exists) throw new ConflictException('用户名已存在');
if (exists) throw new BusinessException(ErrorCodes.USER_USERNAME_EXISTS, '用户名已存在', HttpStatus.CONFLICT);
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.client.user.create({
data: {
@ -216,11 +242,11 @@ export class AuthService {
});
if (!user) throw new UnauthorizedException('账号不存在');
const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash);
if (!ok) throw new BadRequestException('原密码错误');
if (!ok) throw new BusinessException(ErrorCodes.AUTH_OLD_PWD_WRONG, '原密码错误', HttpStatus.BAD_REQUEST);
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.client.user.updateMany({
where: { tenantCode, id: userId },
data: { passwordHash, tokenVersion: { increment: 1 } },
data: { passwordHash, mustChangePassword: false, tokenVersion: { increment: 1 } },
});
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } });
await this.redis.del(permCacheKey(tenantCode, userId));
@ -237,7 +263,7 @@ export class AuthService {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
});
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
if (!user || user.status !== 'enabled' || user.lockedAt) throw new UnauthorizedException('账号已失效');
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
await this.prisma.client.refreshToken.update({
@ -245,7 +271,12 @@ export class AuthService {
data: { revokedAt: new Date() },
});
await this.writePermissionCache(record.tenantCode, user.id);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion, {
sid: record.sid ?? randomUUID(),
loginAt: record.loginAt,
ip: record.ip,
userAgent: record.userAgent,
});
}
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
@ -272,20 +303,30 @@ export class AuthService {
}
/** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */
async getPermissionSet(tenantCode: string, userId: string): Promise<PermissionSet> {
async getPermissionSet(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId));
if (cached) return cached;
return this.writePermissionCache(tenantCode, userId);
return this.writePermissionCache(tenantCode, userId, switched);
}
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
async getMenus(tenantCode: string, userId: string, switched = false): Promise<MenuNode[]> {
const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
// 过滤已删除角色(与 resolveAuth 保持一致)
const activeRoles = roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode, code: { in: roleCodes }, deletedAt: null },
select: { code: true },
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const rows = isSuper
? await this.prisma.client.resource.findMany({
@ -293,7 +334,7 @@ export class AuthService {
orderBy: { sort: 'asc' },
select: MENU_SELECT,
})
: await this.menusForRoles(tenantCode, roleCodes);
: await this.menusForRoles(tenantCode, activeRoleCodes);
return buildMenuTree(rows as MenuRow[]);
}
@ -329,19 +370,23 @@ export class AuthService {
}
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
const u = await this.prisma.client.user.findFirst({
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
select: { orgCode: true },
});
const orgCode = u?.orgCode ?? null;
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode: user.tenantCode, userId: user.userId },
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
async buildContext(user: { userId: string; tenantCode: string; username: string; super?: boolean }): Promise<RequestContext> {
const switched = user.super ?? false;
let orgCode: string | null = null;
let roleCodes: string[] = [];
if (!switched) {
const u = await this.prisma.client.user.findFirst({
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
select: { orgCode: true },
});
orgCode = u?.orgCode ?? null;
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode: user.tenantCode, userId: user.userId },
select: { roleCode: true },
});
roleCodes = roleRows.map((r) => r.roleCode);
}
const isSuper = switched || roleCodes.includes(SUPER_ROLE);
const roles = roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
@ -359,35 +404,136 @@ export class AuthService {
roleCodes,
});
return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes };
return {
tenantCode: user.tenantCode,
userId: user.userId,
username: user.username,
super: isSuper,
dataScope,
orgCode,
orgCodes,
};
}
private permKey(tenantCode: string, userId: string): string {
return permCacheKey(tenantCode, userId);
}
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
const key = userStateCacheKey(tenantCode, userId);
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
/** 校验用户当前是否可用(status=enabled、未锁定、tokenVersion 一致),并返回是否待改密。 */
async checkUserActive(
tenantCode: string,
userId: string,
tokenVersion: number,
switched = false,
): Promise<{ active: boolean; mustChangePassword: boolean }> {
const homeTenant = switched ? PLATFORM_TENANT_CODE : tenantCode;
const key = userStateCacheKey(homeTenant, userId);
let state = await this.redis.getJson<{
status: string;
tokenVersion: number;
lockedAt: string | null;
mustChangePassword: boolean;
}>(key);
if (!state) {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true },
where: { tenantCode: homeTenant, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true },
});
if (!user) return false;
state = { status: user.status, tokenVersion: user.tokenVersion };
if (!user) return { active: false, mustChangePassword: false };
state = {
status: user.status,
tokenVersion: user.tokenVersion,
lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null,
mustChangePassword: user.mustChangePassword,
};
await this.redis.setJson(key, state, ACCESS_TTL);
}
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
const active = state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt;
return { active, mustChangePassword: state.mustChangePassword };
}
/** 判断会话是否已被强退(sid 黑名单)。 */
async isSessionRevoked(sid: string): Promise<boolean> {
return (await this.redis.getJson(sessionRevokedKey(sid))) != null;
}
/** 当前租户品牌(登录后前端拉取应用)。 */
async getBrand(tenantCode: string): Promise<{
name: string;
brandName: string | null;
logoUrl: string | null;
themeColor: string | null;
copyright: string | null;
}> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
if (!tenant) throw new UnauthorizedException('租户不存在');
return {
name: tenant.name,
brandName: tenant.brandName,
logoUrl: tenant.logoUrl,
themeColor: tenant.themeColor,
copyright: tenant.copyright,
};
}
/** 平台超管是否持有 super_admin 角色(查平台租户,不随切换租户变化)。 */
private async isPlatformSuperAdmin(userId: string): Promise<boolean> {
const row = await this.prisma.raw.userRole.findFirst({
where: { tenantCode: PLATFORM_TENANT_CODE, userId, roleCode: SUPER_ROLE },
select: { userId: true },
});
return row != null;
}
/** 平台超管切入任意租户:签发 super=true 的新 token(sub 保持超管真实 userId)。 */
async switchTenant(
userId: string,
dto: SwitchTenantDto,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; tenantCode: string; tenantName: string }> {
if (!(await this.isPlatformSuperAdmin(userId))) {
throw new ForbiddenException('仅平台超级管理员可切换租户');
}
const admin = await this.prisma.raw.user.findFirst({
where: { tenantCode: PLATFORM_TENANT_CODE, id: userId, deletedAt: null },
});
if (!admin || admin.status !== 'enabled' || admin.lockedAt) {
throw new BusinessException(ErrorCodes.AUTH_DISABLED, '平台超管账号不可用', HttpStatus.UNAUTHORIZED);
}
const tenant = await this.prisma.raw.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') {
throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '目标租户不存在或已停用', HttpStatus.NOT_FOUND);
}
const tokens = await this.issue(
dto.tenantCode,
userId,
admin.username,
admin.tokenVersion,
{ sid: randomUUID(), loginAt: new Date(), ip: ip ?? null, userAgent: userAgent ?? null },
true,
);
await this.recordAudit({
tenantCode: dto.tenantCode,
userId,
username: admin.username,
module: 'auth',
action: 'switch-tenant',
status: 'success',
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return { ...tokens, tenantCode: dto.tenantCode, tenantName: tenant.name };
}
/** 聚合权限码与超管标志。 */
private async resolveAuth(tenantCode: string, userId: string): Promise<PermissionSet> {
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
private async resolveAuth(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
// 过滤已删除角色(软删角色不参与权限聚合)
const activeRoles = roleCodes.length
@ -397,7 +543,7 @@ export class AuthService {
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const resourceRows = activeRoleCodes.length
? await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: activeRoleCodes } },
@ -417,8 +563,8 @@ export class AuthService {
}
/** 聚合并写入权限缓存(登录/刷新时调用)。 */
private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId);
private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId, switched);
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
return set;
}
@ -459,15 +605,28 @@ export class AuthService {
userId: string,
username: string,
tokenVersion: number,
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
switched = false,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
const accessToken = this.jwt.sign({
sub: userId,
tenantCode,
username,
ver: tokenVersion,
sid: session.sid,
...(switched ? { super: true } : {}),
});
const jti = randomUUID();
await this.prisma.client.refreshToken.create({
data: {
tenantCode,
userId,
sid: session.sid,
jti,
tokenHash: createHash('sha256').update(jti).digest('hex'),
ip: session.ip ?? null,
userAgent: session.userAgent ?? null,
loginAt: session.loginAt,
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
},
});

View File

@ -65,3 +65,10 @@ export class RefreshDto {
@IsNotEmpty()
refreshToken!: string;
}
export class SwitchTenantDto {
@ApiProperty({ description: '目标租户编码' })
@IsString()
@IsNotEmpty()
tenantCode!: string;
}

View File

@ -1,13 +1,16 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from './decorators.js';
import { AuthService } from './auth.service.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
export interface AuthUser {
userId: string;
tenantCode: string;
username: string;
super?: boolean;
}
@Injectable()
@ -32,13 +35,24 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('未登录');
try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string; super?: boolean }>(token);
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0, payload.super ?? false);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED);
}
if (mustChangePassword && !this.isAllowedWhileMustChange(request)) {
throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN);
}
request.user = {
userId: payload.sub,
tenantCode: payload.tenantCode,
username: payload.username,
super: payload.super ?? false,
} satisfies AuthUser;
return true;
} catch (e) {
if (e instanceof UnauthorizedException) throw e;
if (e instanceof HttpException) throw e;
throw new UnauthorizedException('token 无效或已过期');
}
}
@ -49,4 +63,12 @@ export class JwtAuthGuard implements CanActivate {
const [type, token] = auth.split(' ');
return type === 'Bearer' && token ? token : undefined;
}
/** 待改密门禁白名单:修改本人密码 + 登出(登出目前 @Public,此处兜底防未来改注解)。 */
private isAllowedWhileMustChange(request: { method?: string; originalUrl?: string; url?: string }): boolean {
const url: string = (request.originalUrl ?? request.url ?? '').split('?')[0];
if (request.method === 'PATCH' && url.endsWith('/auth/me/password')) return true;
if (request.method === 'POST' && url.endsWith('/auth/logout')) return true;
return false;
}
}

View File

@ -18,10 +18,10 @@ export class PermissionsGuard implements CanActivate {
if (!required || required.length === 0) return true;
const request = context.switchToHttp().getRequest();
const user = request.user as { userId: string; tenantCode: string } | undefined;
const user = request.user as { userId: string; tenantCode: string; super?: boolean } | undefined;
if (!user) throw new ForbiddenException('无权限');
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId);
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId, user.super ?? false);
if (isSuper) return true;
if (required.every((code) => permissions.includes(code))) return true;
throw new ForbiddenException('无权限');

View File

@ -13,7 +13,7 @@ export class RequestContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const user = request.user as { userId: string; tenantCode: string; username: string } | undefined;
const user = request.user as { userId: string; tenantCode: string; username: string; super?: boolean } | undefined;
if (!user) return next.handle();
return from(this.auth.buildContext(user)).pipe(

View File

@ -0,0 +1,8 @@
import { HttpException, HttpStatus } from '@nestjs/common';
/** 业务异常:携带 error-codes 注册表内的业务码(与 HTTP 状态解耦)。 */
export class BusinessException extends HttpException {
constructor(code: number, message: string, status: HttpStatus = HttpStatus.BAD_REQUEST) {
super({ code, message }, status);
}
}

View File

@ -20,5 +20,11 @@ export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
/** 用户状态缓存 key(Redis,status + tokenVersion + lockedAt,供 JwtAuthGuard 每请求校验)。 */
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;
/** 会话吊销黑名单 key(Redis,sid → 强退标记,供 JwtAuthGuard 每请求校验)。 */
export const sessionRevokedKey = (sid: string): string => `sessionRevoked:${sid}`;
/** 登录失败计数前缀(Redis)。 */
export const LOGIN_FAIL_PREFIX = 'loginFail:';

View File

@ -6,6 +6,8 @@ export interface RequestContext {
tenantCode: string;
userId: string;
username: string;
/** 是否平台超管(含跨租户切换后的超管)。 */
super: boolean;
dataScope: DataScope;
orgCode: string | null;
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */

View File

@ -12,7 +12,6 @@ export type CommonStatus = (typeof CommonStatus)[keyof typeof CommonStatus];
export const UserStatus = {
ENABLED: 'enabled',
DISABLED: 'disabled',
LOCKED: 'locked',
} as const;
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];

33
src/common/error-codes.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* 业务错误码单一事实源:5 位数字 [域码 2 位][序 3 位]。
*
* 域码:00 通用 · 10 认证 · 11 用户 · 12 角色 · 13 资源 · 14 组织 · 15 租户
* 16 字典 · 17 配置 · 18 审计 · 19 登录 · 20 会话 · 21 通知。
*
* 用法:先在此登记,再 `throw new BusinessException(ErrorCodes.XXX, '...', HttpStatus.XXX)`。
* 未使用业务码的异常由 AllExceptionsFilter 回退为 `HTTP 状态 × 100`。
*/
export const ErrorCodes = {
// 认证(10)
AUTH_CREDENTIALS: 10001, // 账号或密码错误(401)
AUTH_LOCKED: 10002, // 账号已锁定(401)
AUTH_DISABLED: 10003, // 账号已停用(401)
AUTH_MUST_CHANGE_PWD: 10004, // 需先修改初始密码(403)
AUTH_OLD_PWD_WRONG: 10005, // 原密码错误(400)
AUTH_TOKEN_INVALID: 10006, // token 无效或已过期(401)
AUTH_SESSION_REVOKED: 10007, // 会话已失效(401)
// 用户(11)
USER_NOT_FOUND: 11001, // 用户不存在(404)
USER_USERNAME_EXISTS: 11002, // 用户名已存在(409)
// 租户(15)
TENANT_NOT_FOUND: 15001, // 租户不存在(404)
TENANT_EXISTS: 15002, // 租户已存在(409)
TENANT_DISABLED: 15003, // 租户不存在或已停用(401)
// 会话(20)
SESSION_NOT_FOUND: 20001, // 会话不存在或已下线(404)
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];

View File

@ -2,9 +2,10 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { BusinessException } from '../business-exception.js';
/**
* 全局异常过滤器:错误码分段与 HTTP 状态对齐(400xx/401xx/403xx/404xx/409xx/500xx),并记录日志。
* 全局异常过滤器:业务异常用注册业务码,其余回退 `HTTP 状态 × 100`,并记录日志。
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
@ -16,8 +17,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string;
let code: number | null = null;
if (exception instanceof HttpException) {
if (exception instanceof BusinessException) {
status = exception.getStatus();
const body = exception.getResponse() as { code: number; message: string };
code = body.code;
message = body.message;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
message =
@ -48,7 +55,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
}
response.status(status).json({
code: status * 100,
code: code ?? status * 100,
message,
data: null,
});

View File

@ -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 { ArrayMaxSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class PageQuery {
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
@ -24,3 +24,12 @@ export class IdParam {
@IsOptional()
id!: string;
}
export class BatchDeleteDto {
@ApiProperty({ description: '待删除记录 ID 列表', type: [String] })
@IsArray()
@IsString({ each: true })
@IsNotEmpty()
@ArrayMaxSize(100)
ids!: string[];
}

View File

@ -16,6 +16,9 @@ export const Permissions = {
delete: 'user:delete',
resetPwd: 'user:reset-pwd',
assignRole: 'user:assign-role',
kickSessions: 'user:kick-sessions',
lock: 'user:lock',
unlock: 'user:unlock',
},
role: {
list: 'role:list',
@ -23,6 +26,7 @@ export const Permissions = {
update: 'role:update',
delete: 'role:delete',
assignResource: 'role:assign-resource',
assignOrg: 'role:assign-org',
},
resource: {
list: 'resource:list',
@ -48,6 +52,13 @@ export const Permissions = {
update: 'config:update',
delete: 'config:delete',
},
session: { list: 'session:list', kick: 'session:kick' },
notice: {
list: 'notice:list',
create: 'notice:create',
update: 'notice:update',
delete: 'notice:delete',
},
audit: { list: 'audit:list' },
loginLog: { list: 'login-log:list' },
} as const;

View File

@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy {
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
async delByPrefix(prefix: string): Promise<void> {
const fullPrefix = this.prefix + prefix;
await this.delByPattern(`${prefix}*`);
}
/** 按 glob 模式删除(SCAN 而非 KEYS,避免阻塞);pattern 会自动加统一前缀。 */
async delByPattern(pattern: string): Promise<void> {
const fullPattern = this.prefix + pattern;
let cursor = '0';
do {
const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, 'COUNT', 200);
const [next, keys] = await this.client.scan(cursor, 'MATCH', fullPattern, 'COUNT', 200);
cursor = next;
if (keys.length > 0) await this.client.del(...keys);
} while (cursor !== '0');

View File

@ -14,9 +14,11 @@ export const MENUS = [
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 },
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 },
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 },
{ code: 'notice', name: '通知管理', parent: 'system', path: '/system/notice', component: 'system/notice/index', icon: 'mdi:bell-ring', sort: 4 },
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
{ code: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
];
/** 接口资源(每个受控接口一个,code = 权限码)。 */
@ -36,6 +38,7 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' },
{ code: Permissions.role.delete, name: '删除角色', method: 'DELETE', path: '/api/v1/roles/:id', parent: 'role' },
{ code: Permissions.role.assignResource, name: '分配资源', method: 'PUT', path: '/api/v1/roles/:id/resources', parent: 'role' },
{ code: Permissions.role.assignOrg, name: '分配数据权限部门', method: 'PUT', path: '/api/v1/roles/:id/orgs', parent: 'role' },
{ code: Permissions.resource.list, name: '资源列表', method: 'GET', path: '/api/v1/resources', parent: 'resource' },
{ code: Permissions.resource.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' },
{ code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', parent: 'resource' },
@ -54,6 +57,15 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' },
{ code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' },
{ code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' },
{ code: Permissions.session.list, name: '会话列表', method: 'GET', path: '/api/v1/sessions', parent: 'session' },
{ code: Permissions.session.kick, name: '强退会话', method: 'POST', path: '/api/v1/sessions/:sid/kick', parent: 'session' },
{ code: Permissions.user.kickSessions, name: '踢全部会话', method: 'POST', path: '/api/v1/users/:id/kick-sessions', parent: 'user' },
{ code: Permissions.user.lock, name: '锁定用户', method: 'POST', path: '/api/v1/users/:id/lock', parent: 'user' },
{ code: Permissions.user.unlock, name: '解锁用户', method: 'POST', path: '/api/v1/users/:id/unlock', parent: 'user' },
{ code: Permissions.notice.list, name: '通知列表', method: 'GET', path: '/api/v1/notices', parent: 'notice' },
{ code: Permissions.notice.create, name: '创建通知', method: 'POST', path: '/api/v1/notices', parent: 'notice' },
{ code: Permissions.notice.update, name: '更新通知', method: 'PATCH', path: '/api/v1/notices/:id', parent: 'notice' },
{ code: Permissions.notice.delete, name: '删除通知', method: 'DELETE', path: '/api/v1/notices/:id', parent: 'notice' },
];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
@ -104,35 +116,22 @@ export interface InitTenantOptions {
adminPassword: string;
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
includePlatformPerms: boolean;
/** 管理员是否需首登强制改密(随机密码场景为 true)。 */
forcePasswordChange?: boolean;
}
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
export interface SyncTenantResourcesOptions {
prisma: PrismaClient;
tenantCode: string;
adminRoleCode: string;
includePlatformPerms: boolean;
}
// 1. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> {
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts;
// 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
});
// 4. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
const allResourceCodes: string[] = [];
for (const m of MENUS) {
@ -160,7 +159,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
});
}
// 5. 内置字典
// 内置字典
for (const d of DICTS) {
await prisma.dictType.upsert({
where: { tenantCode_code: { tenantCode, code: d.code } },
@ -176,14 +175,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
}
}
// 6. 绑定管理员到顶层角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
update: {},
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
});
// 7. 顶层角色全量授权
// 顶层角色全量授权
for (const code of allResourceCodes) {
await prisma.roleResource.upsert({
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
@ -192,3 +184,40 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
});
}
}
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms, forcePasswordChange = false } = opts;
// 1. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
// 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode, mustChangePassword: forcePasswordChange },
});
// 4. 绑定管理员到顶层角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
update: {},
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
});
// 5. 资源 + 内置字典 + 全量授权
await syncTenantResources({ prisma, tenantCode, adminRoleCode, includePlatformPerms });
}

49
src/notices/dto.ts Normal file
View File

@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class CreateNoticeDto {
@ApiProperty({ description: '标题' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: '内容' })
@IsString()
@IsNotEmpty()
content!: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
@IsOptional()
@IsIn(['enabled', 'disabled'])
status?: string;
}
export class UpdateNoticeDto {
@ApiPropertyOptional({ description: '标题' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: '内容' })
@IsOptional()
@IsString()
content?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(['enabled', 'disabled'])
status?: string;
}
export class ListNoticeQuery extends PageQuery {
@ApiPropertyOptional({ description: '标题(模糊)' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: '状态' })
@IsOptional()
@IsString()
status?: string;
}

View File

@ -0,0 +1,59 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateNoticeDto, ListNoticeQuery, UpdateNoticeDto } from './dto.js';
import { NoticesService } from './notices.service.js';
@Controller('notices')
export class NoticesController {
constructor(private readonly service: NoticesService) {}
@Get()
@RequirePermissions(Permissions.notice.list)
list(@Query() q: ListNoticeQuery) {
return this.service.listNotices(q);
}
@Post()
@RequirePermissions(Permissions.notice.create)
@AuditLog('notice', 'create')
create(@Body() dto: CreateNoticeDto) {
return this.service.createNotice(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.notice.update)
@AuditLog('notice', 'update')
update(@Param('id') id: string, @Body() dto: UpdateNoticeDto) {
return this.service.updateNotice(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.notice.delete)
@AuditLog('notice', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
// ---- 用户侧(仅登录态,无权限码) ----
@Get('mine')
mine(@Query() q: ListNoticeQuery) {
return this.service.mine(q);
}
@Get('unread-count')
unreadCount() {
return this.service.unreadCount();
}
@Post(':id/read')
read(@Param('id') id: string) {
return this.service.read(id);
}
@Post('read-all')
readAll() {
return this.service.readAll();
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NoticesController } from './notices.controller.js';
import { NoticesService } from './notices.service.js';
@Module({
controllers: [NoticesController],
providers: [NoticesService],
exports: [NoticesService],
})
export class NoticesModule {}

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { CreateNoticeDto, ListNoticeQuery, UpdateNoticeDto } from './dto.js';
@Injectable()
export class NoticesService extends BaseCrudService {
constructor(prisma: PrismaService) {
super(prisma, 'notice');
}
listNotices(q: ListNoticeQuery) {
const where: Record<string, unknown> = {};
if (q.title) where.title = { contains: q.title };
if (q.status) where.status = q.status;
return this.paginate(q.page, q.pageSize, where);
}
createNotice(dto: CreateNoticeDto) {
return this.create({ ...dto });
}
updateNotice(id: string, dto: UpdateNoticeDto) {
return this.update(id, { ...dto });
}
/** 当前用户可见通知(enabled)+ 已读状态。 */
async mine(q: ListNoticeQuery) {
const ctx = getRequestContext()!;
const [rows, total] = await Promise.all([
this.prisma.client.notice.findMany({
where: { status: 'enabled' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, content: true, status: true, createdAt: true },
}),
this.prisma.client.notice.count({ where: { status: 'enabled' } }),
]);
const ids = rows.map((r) => r.id);
const readRows = ids.length
? await this.prisma.client.noticeRead.findMany({
where: { userId: ctx.userId, noticeId: { in: ids } },
select: { noticeId: true, readAt: true },
})
: [];
const readMap = new Map(readRows.map((r) => [r.noticeId, r.readAt]));
const list = rows.map((r) => ({ ...r, readAt: readMap.get(r.id) ?? null }));
return { list, total, page: q.page, pageSize: q.pageSize };
}
/** 未读数 = enabled 通知数 − 当前用户已读数(O(enabled 通知数),公告量小可接受)。 */
async unreadCount(): Promise<number> {
const ctx = getRequestContext()!;
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
const enabledIds = enabled.map((n) => n.id);
const readCount = enabledIds.length
? await this.prisma.client.noticeRead.count({ where: { userId: ctx.userId, noticeId: { in: enabledIds } } })
: 0;
return Math.max(0, enabled.length - readCount);
}
async read(id: string): Promise<null> {
const ctx = getRequestContext()!;
await this.prisma.client.noticeRead.upsert({
where: { tenantCode_noticeId_userId: { tenantCode: ctx.tenantCode, noticeId: id, userId: ctx.userId } },
update: {},
create: { tenantCode: ctx.tenantCode, noticeId: id, userId: ctx.userId },
});
return null;
}
async readAll(): Promise<null> {
const ctx = getRequestContext()!;
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
if (enabled.length === 0) return null;
await this.prisma.client.noticeRead.createMany({
data: enabled.map((n) => ({ tenantCode: ctx.tenantCode, noticeId: n.id, userId: ctx.userId })),
skipDuplicates: true,
});
return null;
}
}

View File

@ -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);
}
}

View File

@ -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<void> {
if (!parentCode) return; // 根节点

View File

@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
/** 不带 tenantCode 的模型(租户表自身)。 */
const TENANT_FREE = new Set(['Tenant']);
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config']);
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice']);
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
const NO_WHERE_OPS = new Set(['create', 'createMany']);

View File

@ -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);
}
}

View File

@ -1,19 +1,54 @@
import { Injectable } from '@nestjs/common';
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 { CreateResourceDto, UpdateResourceDto } from './dto.js';
@Injectable()
export class ResourcesService extends BaseCrudService {
constructor(prisma: PrismaService) {
constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'resource');
}
createResource(dto: CreateResourceDto) {
return this.create({ ...dto });
async createResource(dto: CreateResourceDto) {
const created = await this.create({ ...dto });
await this.clearPermCache();
return created;
}
updateResource(id: string, dto: UpdateResourceDto) {
return this.update(id, { ...dto });
async updateResource(id: string, dto: UpdateResourceDto) {
const updated = await this.update(id, { ...dto });
await this.clearPermCache();
return updated;
}
async softRemove(id: string) {
const removed = await super.softRemove(id);
await this.clearPermCache();
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<void> {
const ctx = getRequestContext();
if (ctx) await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
}
}

View File

@ -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()

View File

@ -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.assignOrg)
@AuditLog('role', 'assign-org')
assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) {
return this.service.assignOrgs(roleCode, dto);
}
}

View File

@ -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 {
@ -34,7 +34,7 @@ export class RolesService extends BaseCrudService {
return this.paginate(q.page, q.pageSize, where);
}
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删),事务化避免半删。 */
async removeRole(id: string) {
const ctx = getRequestContext()!;
const role = await this.findById(id);
@ -43,10 +43,17 @@ export class RolesService extends BaseCrudService {
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
}
const roleCode = role.code;
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.softRemove(id);
await this.prisma.raw.$transaction(async (tx) => {
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await tx.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
// 软删 + 回收 code 唯一键
const suffix = `#${id}`;
await tx.role.update({
where: { id },
data: { code: `${roleCode.slice(0, 64 - suffix.length)}${suffix}`, deletedAt: new Date() },
});
});
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;
}
@ -60,6 +67,45 @@ 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('角色不存在');
// 校验 orgCodes 存在性(避免静默写入不存在/已删的组织)
const existing = dto.orgCodes.length
? await this.prisma.client.org.findMany({
where: { tenantCode: ctx.tenantCode, code: { in: dto.orgCodes }, deletedAt: null },
select: { code: true },
})
: [];
const validOrgCodes = existing.map((r) => r.code);
if (validOrgCodes.length !== dto.orgCodes.length) {
const invalid = dto.orgCodes.filter((c) => !validOrgCodes.includes(c));
throw new BadRequestException(`组织不存在:${invalid.join(', ')}`);
}
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:*)仅平台租户可授予,非平台租户一律剥离
@ -69,6 +115,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 } });

15
src/sessions/dto.ts Normal file
View File

@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class ListSessionQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ description: '设备 IP(模糊)' })
@IsOptional()
@IsString()
ip?: string;
}

View File

@ -0,0 +1,23 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ListSessionQuery } from './dto.js';
import { SessionsService } from './sessions.service.js';
@Controller('sessions')
export class SessionsController {
constructor(private readonly service: SessionsService) {}
@Get()
@RequirePermissions(Permissions.session.list)
list(@Query() q: ListSessionQuery) {
return this.service.listSessions(q);
}
@Post(':sid/kick')
@RequirePermissions(Permissions.session.kick)
@AuditLog('session', 'kick')
kick(@Param('sid') sid: string) {
return this.service.kickSession(sid);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SessionsController } from './sessions.controller.js';
import { SessionsService } from './sessions.service.js';
@Module({
controllers: [SessionsController],
providers: [SessionsService],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@ -0,0 +1,116 @@
import { ForbiddenException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { RESERVED_ROLE_CODES, sessionRevokedKey } from '../common/constants.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { ListSessionQuery } from './dto.js';
/** 与 auth.service 的 access token 有效时长对齐(黑名单 TTL)。 */
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
interface SessionRow {
sid: string | null;
userId: string;
ip: string | null;
userAgent: string | null;
loginAt: Date;
expireAt: Date;
}
@Injectable()
export class SessionsService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
/** 活跃会话分页:数据权限过滤可见用户 + 用户名/IP 筛选。 */
async listSessions(q: ListSessionQuery) {
const ctx = getRequestContext()!;
// 1. 数据权限 → 允许的 userId 集合(null=不限)
let userIds: string[] | null = null;
if (ctx.dataScope === 'self') {
userIds = [ctx.userId];
} else if (ctx.orgCodes.length > 0) {
const users = await this.prisma.client.user.findMany({
where: { orgCode: { in: ctx.orgCodes } },
select: { id: true },
});
userIds = users.map((u) => u.id);
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
}
// 2. 用户名筛选 → 进一步收窄 userId 集合
if (q.username) {
const where: Record<string, unknown> = { username: { contains: q.username } };
if (userIds) where.id = { in: userIds };
const users = await this.prisma.client.user.findMany({ where, select: { id: true } });
userIds = users.map((u) => u.id);
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
}
// 3. refresh token 查询(tenantCode 由 Prisma 扩展自动注入)
const where: Record<string, unknown> = { revokedAt: null, expireAt: { gt: new Date() } };
if (userIds) where.userId = { in: userIds };
if (q.ip) where.ip = { contains: q.ip };
const [rows, total] = await Promise.all([
this.prisma.client.refreshToken.findMany({
where,
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
orderBy: { loginAt: 'desc' },
select: { sid: true, userId: true, ip: true, userAgent: true, loginAt: true, expireAt: true },
}),
this.prisma.client.refreshToken.count({ where }),
]);
// 4. 补用户名/昵称
const ids = [...new Set((rows as SessionRow[]).map((r) => r.userId))];
const users = ids.length
? await this.prisma.client.user.findMany({ where: { id: { in: ids } }, select: { id: true, username: true, nickname: true } })
: [];
const userMap = new Map(users.map((u) => [u.id, u]));
const list = (rows as SessionRow[]).map((r) => {
const u = userMap.get(r.userId);
return {
sid: r.sid,
userId: r.userId,
username: u?.username ?? '',
nickname: u?.nickname ?? null,
ip: r.ip,
userAgent: r.userAgent,
loginAt: r.loginAt,
expireAt: r.expireAt,
};
});
return { list, total, page: q.page, pageSize: q.pageSize };
}
/** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */
async kickSession(sid: string): Promise<null> {
const ctx = getRequestContext()!;
const session = await this.prisma.client.refreshToken.findFirst({
where: { sid, revokedAt: null },
select: { userId: true },
});
if (!session) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND);
const roles = await this.prisma.client.userRole.findMany({
where: { tenantCode: ctx.tenantCode, userId: session.userId },
select: { roleCode: true },
});
if (roles.some((r) => RESERVED_ROLE_CODES.includes(r.roleCode))) {
throw new ForbiddenException('保留角色账号的会话不可强退');
}
const result = await this.prisma.client.refreshToken.updateMany({
where: { sid, revokedAt: null },
data: { revokedAt: new Date() },
});
if (result.count === 0) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND);
await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL);
return null;
}
}

View File

@ -33,6 +33,26 @@ export class CreateTenantDto {
@IsOptional()
@IsString()
remark?: string;
@ApiPropertyOptional({ description: '品牌名' })
@IsOptional()
@IsString()
brandName?: string;
@ApiPropertyOptional({ description: 'Logo 地址' })
@IsOptional()
@IsString()
logoUrl?: string;
@ApiPropertyOptional({ description: '主色(如 #2E7FA3)' })
@IsOptional()
@IsString()
themeColor?: string;
@ApiPropertyOptional({ description: '版权文案' })
@IsOptional()
@IsString()
copyright?: string;
}
export class UpdateTenantDto {
@ -50,6 +70,26 @@ export class UpdateTenantDto {
@IsOptional()
@IsString()
remark?: string;
@ApiPropertyOptional({ description: '品牌名' })
@IsOptional()
@IsString()
brandName?: string;
@ApiPropertyOptional({ description: 'Logo 地址' })
@IsOptional()
@IsString()
logoUrl?: string;
@ApiPropertyOptional({ description: '主色(如 #2E7FA3)' })
@IsOptional()
@IsString()
themeColor?: string;
@ApiPropertyOptional({ description: '版权文案' })
@IsOptional()
@IsString()
copyright?: string;
}
export class ListTenantQuery extends PageQuery {

View File

@ -1,35 +1,58 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
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 { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { initTenant } from '../common/tenant-template.js';
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
@Injectable()
export class TenantsService extends BaseCrudService {
constructor(prisma: PrismaService) {
constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'tenant');
}
/** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */
/** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */
async createTenant(dto: CreateTenantDto) {
const tenant = await this.create({
code: dto.code,
name: dto.name,
status: dto.status ?? 'enabled',
remark: dto.remark,
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,
brandName: dto.brandName,
logoUrl: dto.logoUrl,
themeColor: dto.themeColor,
copyright: dto.copyright,
createdBy: ctx?.userId ?? null,
},
});
await initTenant({
prisma: tx as unknown as PrismaClient,
tenantCode: code,
tenantName: dto.name,
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
adminRoleName: '租户管理员',
adminUsername: dto.adminUsername ?? 'admin',
adminPassword,
includePlatformPerms: false,
forcePasswordChange: true,
});
});
await initTenant({
prisma: this.prisma.raw,
tenantCode: dto.code,
tenantName: dto.name,
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
adminRoleName: '租户管理员',
adminUsername: dto.adminUsername ?? 'admin',
adminPassword: dto.adminPassword ?? 'Admin@123456',
includePlatformPerms: false,
});
return tenant;
const tenant = await this.db().findUnique({ where: { code } });
return dto.adminPassword ? tenant : { ...tenant, adminPassword };
}
listTenants(q: ListTenantQuery) {
@ -42,14 +65,14 @@ export class TenantsService extends BaseCrudService {
async updateTenant(id: string, dto: UpdateTenantDto) {
const existing = await this.findById(id);
if (!existing) throw new NotFoundException('租户不存在');
if (!existing) throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND);
return this.update(id, { ...dto });
}
/** 删除租户并级联清理其全部业务数据。 */
/** 删除租户并级联清理全部业务数据与 Redis 缓存。 */
async removeTenant(id: string) {
const existing = await this.findById(id);
if (!existing) throw new NotFoundException('租户不存在');
if (!existing) throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND);
const code = existing.code;
const db = this.prisma.raw;
await db.$transaction([
@ -68,6 +91,9 @@ export class TenantsService extends BaseCrudService {
db.user.deleteMany({ where: { tenantCode: code } }),
db.tenant.delete({ where: { id } }),
]);
// 清该租户的权限缓存与用户状态缓存(旧 token 立即失效)
await this.redis.delByPrefix(`perm:${code}:`);
await this.redis.delByPrefix(`user:${code}:`);
return null;
}
}

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { UserStatus } from '../common/enums.js';
@ -15,6 +15,11 @@ export class CreateUserDto {
@MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string;
@ApiPropertyOptional({ description: '首登强制改密(默认 true)' })
@IsOptional()
@IsBoolean()
mustChangePassword?: boolean;
@ApiPropertyOptional({ description: '昵称' })
@IsOptional()
@IsString()
@ -88,6 +93,13 @@ export class AssignRolesDto {
roleCodes!: string[];
}
export class LockUserDto {
@ApiPropertyOptional({ description: '锁定原因' })
@IsOptional()
@IsString()
reason?: string;
}
export class ListUserQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()

View File

@ -1,7 +1,8 @@
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 { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, LockUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { BatchDeleteDto } from '../common/page.dto.js';
import { UsersService } from './users.service.js';
@Controller('users')
@ -60,4 +61,32 @@ 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);
}
@Post(':id/lock')
@RequirePermissions(Permissions.user.lock)
@AuditLog('user', 'lock')
lock(@Param('id') id: string, @Body() dto: LockUserDto) {
return this.service.lock(id, dto.reason);
}
@Post(':id/unlock')
@RequirePermissions(Permissions.user.unlock)
@AuditLog('user', 'unlock')
unlock(@Param('id') id: string) {
return this.service.unlock(id);
}
@Post(':id/kick-sessions')
@RequirePermissions(Permissions.user.kickSessions)
@AuditLog('user', 'kick-sessions')
kickSessions(@Param('id') id: string) {
return this.service.kickSessions(id);
}
}

View File

@ -4,7 +4,7 @@ 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 { permCacheKey, userStateCacheKey } from '../common/constants.js';
import { LOGIN_FAIL_PREFIX, permCacheKey, RESERVED_ROLE_CODES, userStateCacheKey } from '../common/constants.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
@Injectable()
@ -18,8 +18,8 @@ export class UsersService extends BaseCrudService {
async createUser(dto: CreateUserDto) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const { password: _pw, ...rest } = dto;
const created = await this.create({ ...rest, passwordHash });
const { password: _pw, mustChangePassword = true, ...rest } = dto;
const created = await this.create({ ...rest, passwordHash, mustChangePassword });
return this.omitPasswordHash(created);
}
@ -70,7 +70,7 @@ export class UsersService extends BaseCrudService {
async resetPassword(id: string, dto: ResetPasswordDto) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const updated = await this.update(id, { passwordHash });
const updated = await this.update(id, { passwordHash, mustChangePassword: true });
await this.invalidateSessions(id);
return this.omitPasswordHash(updated);
}
@ -81,6 +81,56 @@ 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 };
}
/** 锁定用户:记录锁定时间/原因并踢出全部会话。 */
async lock(userId: string, reason?: string): Promise<Record<string, unknown>> {
if (await this.hasReservedRole(userId)) throw new ForbiddenException('保留角色账号不可锁定');
await this.db().update({ where: { id: userId }, data: { lockedAt: new Date(), lockReason: reason ?? null } });
await this.invalidateSessions(userId);
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
}
/** 解锁用户:清空锁定并清除登录失败计数。 */
async unlock(userId: string): Promise<Record<string, unknown>> {
const ctx = getRequestContext()!;
const user = await this.db().findFirst({ where: { id: userId }, select: { username: true } });
await this.db().update({ where: { id: userId }, data: { lockedAt: null, lockReason: null } });
if (user?.username) {
await this.redis.delByPattern(`${LOGIN_FAIL_PREFIX}*:${user.username}`);
}
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
}
/** 踢出用户全部会话。 */
async kickSessions(userId: string): Promise<null> {
if (await this.hasReservedRole(userId)) throw new ForbiddenException('保留角色账号不可踢出');
await this.invalidateSessions(userId);
return null;
}
/** 目标用户是否持有保留角色(super_admin/tenant_super_admin)。 */
private async hasReservedRole(userId: string): Promise<boolean> {
const ctx = getRequestContext()!;
const rows = await this.prisma.client.userRole.findMany({
where: { tenantCode: ctx.tenantCode, userId },
select: { roleCode: true },
});
return rows.some((r) => RESERVED_ROLE_CODES.includes(r.roleCode));
}
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
private async invalidateSessions(userId: string): Promise<void> {
const ctx = getRequestContext();

View File

@ -3,6 +3,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
import { ErrorCodes } from './../src/common/error-codes.js';
/**
* 登录链路 e2e:需要已启动的 MySQL/Redis 且已执行 pnpm seed(默认账号 touyun / Touyun@123)。
@ -34,9 +35,9 @@ describe('Auth (e2e)', () => {
it('密码错误返回 401', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ username: 'touyun', password: 'wrong-password' })
.send({ username: 'e2e_wrong_user', password: 'wrong-password' })
.expect(401);
expect(res.body.code).toBe(40100);
expect(res.body.code).toBe(ErrorCodes.AUTH_CREDENTIALS);
});
afterAll(async () => {