Compare commits
10 Commits
6937f9ece0
...
6618137da5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6618137da5 | ||
|
|
477f35293e | ||
|
|
8eefa7fa44 | ||
|
|
f747ad5442 | ||
|
|
bad8639175 | ||
|
|
9869974d4c | ||
|
|
86832ce32d | ||
|
|
127707ffde | ||
|
|
200cedeb59 | ||
|
|
9a9435e665 |
10
.env.example
10
.env.example
@ -1,12 +1,18 @@
|
|||||||
# 复制为 .env 并按需修改
|
# 复制为 .env 并按需修改
|
||||||
|
|
||||||
# 数据库(MySQL 8)
|
# 数据库(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
|
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_SECRET="dev-secret-change-me"
|
||||||
JWT_ACCESS_TTL="7200"
|
JWT_ACCESS_TTL="7200"
|
||||||
JWT_REFRESH_TTL="604800"
|
JWT_REFRESH_TTL="604800"
|
||||||
|
|||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal 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"]
|
||||||
@ -26,7 +26,8 @@
|
|||||||
"test:cov": "vitest run --coverage",
|
"test:cov": "vitest run --coverage",
|
||||||
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
||||||
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
|
|||||||
@ -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`);
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `sys_user` ADD COLUMN `must_change_password` BOOLEAN NOT NULL DEFAULT false;
|
||||||
28
prisma/migrations/20261011142232_notice/migration.sql
Normal file
28
prisma/migrations/20261011142232_notice/migration.sql
Normal 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;
|
||||||
@ -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;
|
||||||
@ -17,6 +17,10 @@ model Tenant {
|
|||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
status String @default("enabled") @db.VarChar(16)
|
status String @default("enabled") @db.VarChar(16)
|
||||||
remark String? @db.VarChar(255)
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String? @map("created_by") @db.VarChar(36)
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@ -36,6 +40,9 @@ model User {
|
|||||||
avatar String? @db.VarChar(255)
|
avatar String? @db.VarChar(255)
|
||||||
status String @default("enabled") @db.VarChar(16)
|
status String @default("enabled") @db.VarChar(16)
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
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)
|
orgCode String? @map("org_code") @db.VarChar(64)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String? @map("created_by") @db.VarChar(36)
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
@ -213,6 +220,7 @@ model RefreshToken {
|
|||||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
userId String @map("user_id") @db.VarChar(36)
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
|
sid String? @db.VarChar(36)
|
||||||
jti String @unique @db.VarChar(64)
|
jti String @unique @db.VarChar(64)
|
||||||
tokenHash String @map("token_hash") @db.VarChar(255)
|
tokenHash String @map("token_hash") @db.VarChar(255)
|
||||||
ip String? @db.VarChar(64)
|
ip String? @db.VarChar(64)
|
||||||
@ -223,9 +231,39 @@ model RefreshToken {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([tenantCode, userId])
|
@@index([tenantCode, userId])
|
||||||
|
@@index([sid])
|
||||||
@@map("sys_refresh_token")
|
@@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 {
|
model UserRole {
|
||||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
|||||||
30
prisma/sync-resources.ts
Normal file
30
prisma/sync-resources.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -23,6 +23,8 @@ import { DictsModule } from './dicts/dicts.module.js';
|
|||||||
import { ConfigsModule } from './configs/configs.module.js';
|
import { ConfigsModule } from './configs/configs.module.js';
|
||||||
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||||
import { LoginLogsModule } from './login-logs/login-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 { AppController } from './app.controller.js';
|
||||||
import { AppService } from './app.service.js';
|
import { AppService } from './app.service.js';
|
||||||
|
|
||||||
@ -43,6 +45,8 @@ import { AppService } from './app.service.js';
|
|||||||
ConfigsModule,
|
ConfigsModule,
|
||||||
AuditLogsModule,
|
AuditLogsModule,
|
||||||
LoginLogsModule,
|
LoginLogsModule,
|
||||||
|
SessionsModule,
|
||||||
|
NoticesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
|
import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuthService } from './auth.service.js';
|
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 { Public } from './decorators.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.js';
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ export class AuthController {
|
|||||||
menus() {
|
menus() {
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
if (!ctx) throw new UnauthorizedException('未登录');
|
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() {
|
permissions() {
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
if (!ctx) throw new UnauthorizedException('未登录');
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改本人密码(仅需登录)。 */
|
/** 修改本人密码(仅需登录)。 */
|
||||||
|
|||||||
@ -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 { JwtService } from '@nestjs/jwt';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { RedisService } from '../common/redis/redis.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 { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
|
||||||
import type { DataScope } from '../common/enums.js';
|
import type { DataScope } from '../common/enums.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
||||||
import { LoginLogsService } from '../login-logs/login-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 REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||||
|
|
||||||
const LOGIN_FAIL_PREFIX = 'loginFail:';
|
|
||||||
const MAX_LOGIN_FAILS = 5;
|
const MAX_LOGIN_FAILS = 5;
|
||||||
|
const MAX_GLOBAL_LOGIN_FAILS = 20;
|
||||||
const LOGIN_LOCK_SECONDS = 900;
|
const LOGIN_LOCK_SECONDS = 900;
|
||||||
|
|
||||||
export interface PermissionSet {
|
export interface PermissionSet {
|
||||||
@ -85,28 +87,28 @@ export class AuthService {
|
|||||||
ip?: string,
|
ip?: string,
|
||||||
userAgent?: string,
|
userAgent?: string,
|
||||||
): Promise<
|
): Promise<
|
||||||
| { accessToken: string; refreshToken: string; expiresIn: number }
|
| { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }
|
||||||
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
|
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
|
||||||
> {
|
> {
|
||||||
await this.assertNotLocked(dto.username);
|
await this.assertNotLocked(ip, dto.username);
|
||||||
if (dto.tenantCode) {
|
if (dto.tenantCode) {
|
||||||
try {
|
try {
|
||||||
const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
|
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;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username);
|
if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
||||||
const users = await this.prisma.client.user.findMany({
|
const users = await this.prisma.client.user.findMany({
|
||||||
where: { username: dto.username, deletedAt: null },
|
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 }[] = [];
|
const matched: { tenantCode: string; tenantName: string }[] = [];
|
||||||
for (const u of users) {
|
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 } });
|
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
|
||||||
if (!tenant || tenant.status !== 'enabled') continue;
|
if (!tenant || tenant.status !== 'enabled') continue;
|
||||||
if (await bcrypt.compare(dto.password, u.passwordHash)) {
|
if (await bcrypt.compare(dto.password, u.passwordHash)) {
|
||||||
@ -114,33 +116,44 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (matched.length === 0) {
|
if (matched.length === 0) {
|
||||||
await this.recordLoginFail(dto.username);
|
await this.recordLoginFail(ip, dto.username);
|
||||||
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
|
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) {
|
if (matched.length === 1) {
|
||||||
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
|
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
|
||||||
}
|
}
|
||||||
return { needSelectTenant: true, tenants: matched };
|
return { needSelectTenant: true, tenants: matched };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */
|
/** 登录失败锁定:双层阈值(IP+用户名 低阈值防单点爆破,用户名全局 高阈值防换 IP 爆破)。 */
|
||||||
private async assertNotLocked(username: string): Promise<void> {
|
private async assertNotLocked(ip: string | undefined, username: string): Promise<void> {
|
||||||
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
|
const perIp = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
|
||||||
if (count >= MAX_LOGIN_FAILS) {
|
const global = (await this.redis.getJson<number>(this.globalLockKey(username))) ?? 0;
|
||||||
|
if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) {
|
||||||
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
|
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async recordLoginFail(username: string): Promise<void> {
|
private async recordLoginFail(ip: string | undefined, username: string): Promise<void> {
|
||||||
const key = `${LOGIN_FAIL_PREFIX}${username}`;
|
const perIpKey = this.lockKey(ip, username);
|
||||||
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
|
const globalKey = this.globalLockKey(username);
|
||||||
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
|
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> {
|
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
|
||||||
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`);
|
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,
|
password: string,
|
||||||
ip?: string,
|
ip?: string,
|
||||||
userAgent?: 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 } });
|
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
|
||||||
if (!tenant || tenant.status !== 'enabled') {
|
if (!tenant || tenant.status !== 'enabled') {
|
||||||
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
|
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({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode, username, deletedAt: null },
|
where: { tenantCode, username, deletedAt: null },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
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') {
|
if (user.status !== 'enabled') {
|
||||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
|
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
|
||||||
throw new UnauthorizedException('账号已停用或锁定');
|
throw new BusinessException(ErrorCodes.AUTH_DISABLED, '账号已停用', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
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.writePermissionCache(tenantCode, user.id);
|
||||||
await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
|
await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
|
||||||
@ -184,17 +201,26 @@ export class AuthService {
|
|||||||
ip: ip ?? null,
|
ip: ip ?? null,
|
||||||
userAgent: userAgent ?? 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 }> {
|
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 } });
|
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
|
||||||
if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用');
|
if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用');
|
||||||
const exists = await this.prisma.client.user.findFirst({
|
const exists = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
|
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 passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
const user = await this.prisma.client.user.create({
|
const user = await this.prisma.client.user.create({
|
||||||
data: {
|
data: {
|
||||||
@ -216,11 +242,11 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
if (!user) throw new UnauthorizedException('账号不存在');
|
if (!user) throw new UnauthorizedException('账号不存在');
|
||||||
const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash);
|
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);
|
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||||
await this.prisma.client.user.updateMany({
|
await this.prisma.client.user.updateMany({
|
||||||
where: { tenantCode, id: userId },
|
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.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } });
|
||||||
await this.redis.del(permCacheKey(tenantCode, userId));
|
await this.redis.del(permCacheKey(tenantCode, userId));
|
||||||
@ -237,7 +263,7 @@ export class AuthService {
|
|||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
|
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,并重聚合权限集(权限变更在刷新时生效)
|
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
|
||||||
await this.prisma.client.refreshToken.update({
|
await this.prisma.client.refreshToken.update({
|
||||||
@ -245,7 +271,12 @@ export class AuthService {
|
|||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.writePermissionCache(record.tenantCode, user.id);
|
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> {
|
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
|
||||||
@ -272,20 +303,30 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */
|
/** 读取用户权限集;缓存缺失时重新聚合并回填(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));
|
const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId));
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return this.writePermissionCache(tenantCode, userId);
|
return this.writePermissionCache(tenantCode, userId, switched);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
|
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
|
||||||
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
|
async getMenus(tenantCode: string, userId: string, switched = false): Promise<MenuNode[]> {
|
||||||
const roleRows = await this.prisma.client.userRole.findMany({
|
const roleRows = switched
|
||||||
|
? []
|
||||||
|
: await this.prisma.client.userRole.findMany({
|
||||||
where: { tenantCode, userId },
|
where: { tenantCode, userId },
|
||||||
select: { roleCode: true },
|
select: { roleCode: true },
|
||||||
});
|
});
|
||||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
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
|
const rows = isSuper
|
||||||
? await this.prisma.client.resource.findMany({
|
? await this.prisma.client.resource.findMany({
|
||||||
@ -293,7 +334,7 @@ export class AuthService {
|
|||||||
orderBy: { sort: 'asc' },
|
orderBy: { sort: 'asc' },
|
||||||
select: MENU_SELECT,
|
select: MENU_SELECT,
|
||||||
})
|
})
|
||||||
: await this.menusForRoles(tenantCode, roleCodes);
|
: await this.menusForRoles(tenantCode, activeRoleCodes);
|
||||||
|
|
||||||
return buildMenuTree(rows as MenuRow[]);
|
return buildMenuTree(rows as MenuRow[]);
|
||||||
}
|
}
|
||||||
@ -329,19 +370,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
||||||
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
|
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({
|
const u = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
|
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
|
||||||
select: { orgCode: true },
|
select: { orgCode: true },
|
||||||
});
|
});
|
||||||
const orgCode = u?.orgCode ?? null;
|
orgCode = u?.orgCode ?? null;
|
||||||
|
|
||||||
const roleRows = await this.prisma.client.userRole.findMany({
|
const roleRows = await this.prisma.client.userRole.findMany({
|
||||||
where: { tenantCode: user.tenantCode, userId: user.userId },
|
where: { tenantCode: user.tenantCode, userId: user.userId },
|
||||||
select: { roleCode: true },
|
select: { roleCode: true },
|
||||||
});
|
});
|
||||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
roleCodes = roleRows.map((r) => r.roleCode);
|
||||||
const isSuper = roleCodes.includes(SUPER_ROLE);
|
}
|
||||||
|
const isSuper = switched || roleCodes.includes(SUPER_ROLE);
|
||||||
const roles = roleCodes.length
|
const roles = roleCodes.length
|
||||||
? await this.prisma.client.role.findMany({
|
? await this.prisma.client.role.findMany({
|
||||||
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
|
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
|
||||||
@ -359,32 +404,133 @@ export class AuthService {
|
|||||||
roleCodes,
|
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 {
|
private permKey(tenantCode: string, userId: string): string {
|
||||||
return permCacheKey(tenantCode, userId);
|
return permCacheKey(tenantCode, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
|
/** 校验用户当前是否可用(status=enabled、未锁定、tokenVersion 一致),并返回是否待改密。 */
|
||||||
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
|
async checkUserActive(
|
||||||
const key = userStateCacheKey(tenantCode, userId);
|
tenantCode: string,
|
||||||
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
|
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) {
|
if (!state) {
|
||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode, id: userId, deletedAt: null },
|
where: { tenantCode: homeTenant, id: userId, deletedAt: null },
|
||||||
select: { status: true, tokenVersion: true },
|
select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true },
|
||||||
});
|
});
|
||||||
if (!user) return false;
|
if (!user) return { active: false, mustChangePassword: false };
|
||||||
state = { status: user.status, tokenVersion: user.tokenVersion };
|
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);
|
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> {
|
private async resolveAuth(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
|
||||||
const roleRows = await this.prisma.client.userRole.findMany({
|
const roleRows = switched
|
||||||
|
? []
|
||||||
|
: await this.prisma.client.userRole.findMany({
|
||||||
where: { tenantCode, userId },
|
where: { tenantCode, userId },
|
||||||
select: { roleCode: true },
|
select: { roleCode: true },
|
||||||
});
|
});
|
||||||
@ -397,7 +543,7 @@ export class AuthService {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const activeRoleCodes = activeRoles.map((r) => r.code);
|
const activeRoleCodes = activeRoles.map((r) => r.code);
|
||||||
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
|
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
|
||||||
const resourceRows = activeRoleCodes.length
|
const resourceRows = activeRoleCodes.length
|
||||||
? await this.prisma.client.roleResource.findMany({
|
? await this.prisma.client.roleResource.findMany({
|
||||||
where: { tenantCode, roleCode: { in: activeRoleCodes } },
|
where: { tenantCode, roleCode: { in: activeRoleCodes } },
|
||||||
@ -417,8 +563,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 聚合并写入权限缓存(登录/刷新时调用)。 */
|
/** 聚合并写入权限缓存(登录/刷新时调用)。 */
|
||||||
private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> {
|
private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
|
||||||
const set = await this.resolveAuth(tenantCode, userId);
|
const set = await this.resolveAuth(tenantCode, userId, switched);
|
||||||
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
|
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
@ -459,15 +605,28 @@ export class AuthService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
username: string,
|
username: string,
|
||||||
tokenVersion: number,
|
tokenVersion: number,
|
||||||
|
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
|
||||||
|
switched = false,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
): 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();
|
const jti = randomUUID();
|
||||||
await this.prisma.client.refreshToken.create({
|
await this.prisma.client.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
tenantCode,
|
tenantCode,
|
||||||
userId,
|
userId,
|
||||||
|
sid: session.sid,
|
||||||
jti,
|
jti,
|
||||||
tokenHash: createHash('sha256').update(jti).digest('hex'),
|
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),
|
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -65,3 +65,10 @@ export class RefreshDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
refreshToken!: string;
|
refreshToken!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SwitchTenantDto {
|
||||||
|
@ApiProperty({ description: '目标租户编码' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
tenantCode!: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 { Reflector } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { IS_PUBLIC_KEY } from './decorators.js';
|
import { IS_PUBLIC_KEY } from './decorators.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { BusinessException } from '../common/business-exception.js';
|
||||||
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
tenantCode: string;
|
tenantCode: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
super?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -32,13 +35,24 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
const token = this.extractToken(request);
|
const token = this.extractToken(request);
|
||||||
if (!token) throw new UnauthorizedException('未登录');
|
if (!token) throw new UnauthorizedException('未登录');
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
|
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string; super?: boolean }>(token);
|
||||||
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
|
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0, payload.super ?? false);
|
||||||
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof UnauthorizedException) throw e;
|
if (e instanceof HttpException) throw e;
|
||||||
throw new UnauthorizedException('token 无效或已过期');
|
throw new UnauthorizedException('token 无效或已过期');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,4 +63,12 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
const [type, token] = auth.split(' ');
|
const [type, token] = auth.split(' ');
|
||||||
return type === 'Bearer' && token ? token : undefined;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,10 +18,10 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
if (!required || required.length === 0) return true;
|
if (!required || required.length === 0) return true;
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
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('无权限');
|
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 (isSuper) return true;
|
||||||
if (required.every((code) => permissions.includes(code))) return true;
|
if (required.every((code) => permissions.includes(code))) return true;
|
||||||
throw new ForbiddenException('无权限');
|
throw new ForbiddenException('无权限');
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export class RequestContextInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
const request = context.switchToHttp().getRequest();
|
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();
|
if (!user) return next.handle();
|
||||||
|
|
||||||
return from(this.auth.buildContext(user)).pipe(
|
return from(this.auth.buildContext(user)).pipe(
|
||||||
|
|||||||
8
src/common/business-exception.ts
Normal file
8
src/common/business-exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,5 +20,11 @@ export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
|
|||||||
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
||||||
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
|
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}`;
|
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:';
|
||||||
|
|||||||
@ -6,6 +6,8 @@ export interface RequestContext {
|
|||||||
tenantCode: string;
|
tenantCode: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** 是否平台超管(含跨租户切换后的超管)。 */
|
||||||
|
super: boolean;
|
||||||
dataScope: DataScope;
|
dataScope: DataScope;
|
||||||
orgCode: string | null;
|
orgCode: string | null;
|
||||||
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */
|
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */
|
||||||
|
|||||||
@ -12,7 +12,6 @@ export type CommonStatus = (typeof CommonStatus)[keyof typeof CommonStatus];
|
|||||||
export const UserStatus = {
|
export const UserStatus = {
|
||||||
ENABLED: 'enabled',
|
ENABLED: 'enabled',
|
||||||
DISABLED: 'disabled',
|
DISABLED: 'disabled',
|
||||||
LOCKED: 'locked',
|
|
||||||
} as const;
|
} as const;
|
||||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
||||||
|
|
||||||
|
|||||||
33
src/common/error-codes.ts
Normal file
33
src/common/error-codes.ts
Normal 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];
|
||||||
@ -2,9 +2,10 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||||
|
import { BusinessException } from '../business-exception.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局异常过滤器:错误码分段与 HTTP 状态对齐(400xx/401xx/403xx/404xx/409xx/500xx),并记录日志。
|
* 全局异常过滤器:业务异常用注册业务码,其余回退 `HTTP 状态 × 100`,并记录日志。
|
||||||
*/
|
*/
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
@ -16,8 +17,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let message: string;
|
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();
|
status = exception.getStatus();
|
||||||
const body = exception.getResponse();
|
const body = exception.getResponse();
|
||||||
message =
|
message =
|
||||||
@ -48,7 +55,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
code: status * 100,
|
code: code ?? status * 100,
|
||||||
message,
|
message,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class PageQuery {
|
||||||
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
|
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
|
||||||
@ -24,3 +24,12 @@ export class IdParam {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
id!: string;
|
id!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BatchDeleteDto {
|
||||||
|
@ApiProperty({ description: '待删除记录 ID 列表', type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ export const Permissions = {
|
|||||||
delete: 'user:delete',
|
delete: 'user:delete',
|
||||||
resetPwd: 'user:reset-pwd',
|
resetPwd: 'user:reset-pwd',
|
||||||
assignRole: 'user:assign-role',
|
assignRole: 'user:assign-role',
|
||||||
|
kickSessions: 'user:kick-sessions',
|
||||||
|
lock: 'user:lock',
|
||||||
|
unlock: 'user:unlock',
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
list: 'role:list',
|
list: 'role:list',
|
||||||
@ -23,6 +26,7 @@ export const Permissions = {
|
|||||||
update: 'role:update',
|
update: 'role:update',
|
||||||
delete: 'role:delete',
|
delete: 'role:delete',
|
||||||
assignResource: 'role:assign-resource',
|
assignResource: 'role:assign-resource',
|
||||||
|
assignOrg: 'role:assign-org',
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
list: 'resource:list',
|
list: 'resource:list',
|
||||||
@ -48,6 +52,13 @@ export const Permissions = {
|
|||||||
update: 'config:update',
|
update: 'config:update',
|
||||||
delete: 'config:delete',
|
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' },
|
audit: { list: 'audit:list' },
|
||||||
loginLog: { list: 'login-log:list' },
|
loginLog: { list: 'login-log:list' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy {
|
|||||||
|
|
||||||
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
|
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
|
||||||
async delByPrefix(prefix: string): Promise<void> {
|
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';
|
let cursor = '0';
|
||||||
do {
|
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;
|
cursor = next;
|
||||||
if (keys.length > 0) await this.client.del(...keys);
|
if (keys.length > 0) await this.client.del(...keys);
|
||||||
} while (cursor !== '0');
|
} while (cursor !== '0');
|
||||||
|
|||||||
@ -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: '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: '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: '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: '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: '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: '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 = 权限码)。 */
|
/** 接口资源(每个受控接口一个,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.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.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.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.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.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' },
|
||||||
{ code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', 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.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.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.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;
|
adminPassword: string;
|
||||||
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
|
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
|
||||||
includePlatformPerms: boolean;
|
includePlatformPerms: boolean;
|
||||||
|
/** 管理员是否需首登强制改密(随机密码场景为 true)。 */
|
||||||
|
forcePasswordChange?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
export interface SyncTenantResourcesOptions {
|
||||||
export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
prisma: PrismaClient;
|
||||||
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
|
tenantCode: string;
|
||||||
|
adminRoleCode: string;
|
||||||
|
includePlatformPerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 根组织
|
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */
|
||||||
await prisma.org.upsert({
|
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> {
|
||||||
where: { tenantCode_code: { tenantCode, code: tenantCode } },
|
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts;
|
||||||
update: {},
|
|
||||||
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 顶层角色
|
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
|
||||||
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:* 平台级接口)
|
|
||||||
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
|
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
|
||||||
const allResourceCodes: string[] = [];
|
const allResourceCodes: string[] = [];
|
||||||
for (const m of MENUS) {
|
for (const m of MENUS) {
|
||||||
@ -160,7 +159,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 内置字典
|
// 内置字典
|
||||||
for (const d of DICTS) {
|
for (const d of DICTS) {
|
||||||
await prisma.dictType.upsert({
|
await prisma.dictType.upsert({
|
||||||
where: { tenantCode_code: { tenantCode, code: d.code } },
|
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) {
|
for (const code of allResourceCodes) {
|
||||||
await prisma.roleResource.upsert({
|
await prisma.roleResource.upsert({
|
||||||
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
|
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
49
src/notices/dto.ts
Normal 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;
|
||||||
|
}
|
||||||
59
src/notices/notices.controller.ts
Normal file
59
src/notices/notices.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/notices/notices.module.ts
Normal file
10
src/notices/notices.module.ts
Normal 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 {}
|
||||||
84
src/notices/notices.service.ts
Normal file
84
src/notices/notices.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo
|
|||||||
import { Permissions } from '../common/permissions.js';
|
import { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
||||||
|
import { BatchDeleteDto } from '../common/page.dto.js';
|
||||||
import { OrgsService } from './orgs.service.js';
|
import { OrgsService } from './orgs.service.js';
|
||||||
|
|
||||||
@Controller('orgs')
|
@Controller('orgs')
|
||||||
@ -40,4 +41,11 @@ export class OrgsController {
|
|||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,19 @@ export class OrgsService extends BaseCrudService {
|
|||||||
return this.update(id, { ...dto });
|
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> {
|
private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise<void> {
|
||||||
if (!parentCode) return; // 根节点
|
if (!parentCode) return; // 根节点
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
|
|||||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||||
const TENANT_FREE = new Set(['Tenant']);
|
const TENANT_FREE = new Set(['Tenant']);
|
||||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
/** 软删模型:读操作统一注入 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']);
|
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
||||||
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
||||||
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo
|
|||||||
import { Permissions } from '../common/permissions.js';
|
import { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
||||||
|
import { BatchDeleteDto } from '../common/page.dto.js';
|
||||||
import { ResourcesService } from './resources.service.js';
|
import { ResourcesService } from './resources.service.js';
|
||||||
|
|
||||||
@Controller('resources')
|
@Controller('resources')
|
||||||
@ -40,4 +41,11 @@ export class ResourcesController {
|
|||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,54 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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';
|
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResourcesService extends BaseCrudService {
|
export class ResourcesService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
super(prisma, 'resource');
|
super(prisma, 'resource');
|
||||||
}
|
}
|
||||||
|
|
||||||
createResource(dto: CreateResourceDto) {
|
async createResource(dto: CreateResourceDto) {
|
||||||
return this.create({ ...dto });
|
const created = await this.create({ ...dto });
|
||||||
|
await this.clearPermCache();
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateResource(id: string, dto: UpdateResourceDto) {
|
async updateResource(id: string, dto: UpdateResourceDto) {
|
||||||
return this.update(id, { ...dto });
|
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}:`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,13 @@ export class AssignResourcesDto {
|
|||||||
resourceCodes!: string[];
|
resourceCodes!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssignOrgsDto {
|
||||||
|
@ApiProperty({ description: '部门 code 列表(数据权限=自定义时)', type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
orgCodes!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class ListRoleQuery extends PageQuery {
|
export class ListRoleQuery extends PageQuery {
|
||||||
@ApiPropertyOptional({ description: '角色名称(模糊)' })
|
@ApiPropertyOptional({ description: '角色名称(模糊)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||||
import { Permissions } from '../common/permissions.js';
|
import { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.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';
|
import { RolesService } from './roles.service.js';
|
||||||
|
|
||||||
@Controller('roles')
|
@Controller('roles')
|
||||||
@ -41,16 +41,29 @@ export class RolesController {
|
|||||||
return this.service.removeRole(id);
|
return this.service.removeRole(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/resources')
|
@Get(':roleCode/resources')
|
||||||
@RequirePermissions(Permissions.role.list)
|
@RequirePermissions(Permissions.role.list)
|
||||||
resources(@Param('id') id: string) {
|
resources(@Param('roleCode') roleCode: string) {
|
||||||
return this.service.assignedResources(id);
|
return this.service.assignedResources(roleCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/resources')
|
@Put(':roleCode/resources')
|
||||||
@RequirePermissions(Permissions.role.assignResource)
|
@RequirePermissions(Permissions.role.assignResource)
|
||||||
@AuditLog('role', 'assign-resource')
|
@AuditLog('role', 'assign-resource')
|
||||||
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
|
assign(@Param('roleCode') roleCode: string, @Body() dto: AssignResourcesDto) {
|
||||||
return this.service.assignResources(id, dto);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service.js';
|
|||||||
import { RedisService } from '../common/redis/redis.service.js';
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.js';
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
import { PLATFORM_PERMISSION_PREFIX, PLATFORM_TENANT_CODE, RESERVED_ROLE_CODES } from '../common/constants.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()
|
@Injectable()
|
||||||
export class RolesService extends BaseCrudService {
|
export class RolesService extends BaseCrudService {
|
||||||
@ -34,7 +34,7 @@ export class RolesService extends BaseCrudService {
|
|||||||
return this.paginate(q.page, q.pageSize, where);
|
return this.paginate(q.page, q.pageSize, where);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */
|
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删),事务化避免半删。 */
|
||||||
async removeRole(id: string) {
|
async removeRole(id: string) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const role = await this.findById(id);
|
const role = await this.findById(id);
|
||||||
@ -43,10 +43,17 @@ export class RolesService extends BaseCrudService {
|
|||||||
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
|
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
|
||||||
}
|
}
|
||||||
const roleCode = role.code;
|
const roleCode = role.code;
|
||||||
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
await this.prisma.raw.$transaction(async (tx) => {
|
||||||
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
await this.softRemove(id);
|
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}:`);
|
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -60,6 +67,45 @@ export class RolesService extends BaseCrudService {
|
|||||||
return rows.map((r) => r.resourceCode);
|
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) {
|
async assignResources(roleCode: string, dto: AssignResourcesDto) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
|
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
|
||||||
@ -69,6 +115,18 @@ export class RolesService extends BaseCrudService {
|
|||||||
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
|
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
|
||||||
const role = await this.db().findFirst({ where: { code: roleCode } });
|
const role = await this.db().findFirst({ where: { code: roleCode } });
|
||||||
if (!role) throw new BadRequestException('角色不存在');
|
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 this.prisma.client.$transaction(async (tx) => {
|
||||||
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
|
|||||||
15
src/sessions/dto.ts
Normal file
15
src/sessions/dto.ts
Normal 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;
|
||||||
|
}
|
||||||
23
src/sessions/sessions.controller.ts
Normal file
23
src/sessions/sessions.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/sessions/sessions.module.ts
Normal file
10
src/sessions/sessions.module.ts
Normal 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 {}
|
||||||
116
src/sessions/sessions.service.ts
Normal file
116
src/sessions/sessions.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,26 @@ export class CreateTenantDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
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 {
|
export class UpdateTenantDto {
|
||||||
@ -50,6 +70,26 @@ export class UpdateTenantDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
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 {
|
export class ListTenantQuery extends PageQuery {
|
||||||
|
|||||||
@ -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 { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { 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 { initTenant } from '../common/tenant-template.js';
|
||||||
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
|
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantsService extends BaseCrudService {
|
export class TenantsService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
super(prisma, 'tenant');
|
super(prisma, 'tenant');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */
|
/** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */
|
||||||
async createTenant(dto: CreateTenantDto) {
|
async createTenant(dto: CreateTenantDto) {
|
||||||
const tenant = await this.create({
|
const ctx = getRequestContext();
|
||||||
code: dto.code,
|
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,
|
name: dto.name,
|
||||||
status: dto.status ?? 'enabled',
|
status: dto.status ?? 'enabled',
|
||||||
remark: dto.remark,
|
remark: dto.remark,
|
||||||
|
brandName: dto.brandName,
|
||||||
|
logoUrl: dto.logoUrl,
|
||||||
|
themeColor: dto.themeColor,
|
||||||
|
copyright: dto.copyright,
|
||||||
|
createdBy: ctx?.userId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await initTenant({
|
await initTenant({
|
||||||
prisma: this.prisma.raw,
|
prisma: tx as unknown as PrismaClient,
|
||||||
tenantCode: dto.code,
|
tenantCode: code,
|
||||||
tenantName: dto.name,
|
tenantName: dto.name,
|
||||||
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
|
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
|
||||||
adminRoleName: '租户管理员',
|
adminRoleName: '租户管理员',
|
||||||
adminUsername: dto.adminUsername ?? 'admin',
|
adminUsername: dto.adminUsername ?? 'admin',
|
||||||
adminPassword: dto.adminPassword ?? 'Admin@123456',
|
adminPassword,
|
||||||
includePlatformPerms: false,
|
includePlatformPerms: false,
|
||||||
|
forcePasswordChange: true,
|
||||||
});
|
});
|
||||||
return tenant;
|
});
|
||||||
|
const tenant = await this.db().findUnique({ where: { code } });
|
||||||
|
return dto.adminPassword ? tenant : { ...tenant, adminPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
listTenants(q: ListTenantQuery) {
|
listTenants(q: ListTenantQuery) {
|
||||||
@ -42,14 +65,14 @@ export class TenantsService extends BaseCrudService {
|
|||||||
|
|
||||||
async updateTenant(id: string, dto: UpdateTenantDto) {
|
async updateTenant(id: string, dto: UpdateTenantDto) {
|
||||||
const existing = await this.findById(id);
|
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 });
|
return this.update(id, { ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除租户并级联清理其全部业务数据。 */
|
/** 删除租户并级联清理全部业务数据与 Redis 缓存。 */
|
||||||
async removeTenant(id: string) {
|
async removeTenant(id: string) {
|
||||||
const existing = await this.findById(id);
|
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 code = existing.code;
|
||||||
const db = this.prisma.raw;
|
const db = this.prisma.raw;
|
||||||
await db.$transaction([
|
await db.$transaction([
|
||||||
@ -68,6 +91,9 @@ export class TenantsService extends BaseCrudService {
|
|||||||
db.user.deleteMany({ where: { tenantCode: code } }),
|
db.user.deleteMany({ where: { tenantCode: code } }),
|
||||||
db.tenant.delete({ where: { id } }),
|
db.tenant.delete({ where: { id } }),
|
||||||
]);
|
]);
|
||||||
|
// 清该租户的权限缓存与用户状态缓存(旧 token 立即失效)
|
||||||
|
await this.redis.delByPrefix(`perm:${code}:`);
|
||||||
|
await this.redis.delByPrefix(`user:${code}:`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 { PageQuery } from '../common/page.dto.js';
|
||||||
import { UserStatus } from '../common/enums.js';
|
import { UserStatus } from '../common/enums.js';
|
||||||
|
|
||||||
@ -15,6 +15,11 @@ export class CreateUserDto {
|
|||||||
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
@MinLength(6, { message: '密码长度不能少于 6 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '首登强制改密(默认 true)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '昵称' })
|
@ApiPropertyOptional({ description: '昵称' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -88,6 +93,13 @@ export class AssignRolesDto {
|
|||||||
roleCodes!: string[];
|
roleCodes!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LockUserDto {
|
||||||
|
@ApiPropertyOptional({ description: '锁定原因' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ListUserQuery extends PageQuery {
|
export class ListUserQuery extends PageQuery {
|
||||||
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||||
import { Permissions } from '../common/permissions.js';
|
import { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.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';
|
import { UsersService } from './users.service.js';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@ -60,4 +61,32 @@ export class UsersController {
|
|||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { BaseCrudService } from '../common/base-crud.service.js';
|
|||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { RedisService } from '../common/redis/redis.service.js';
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.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';
|
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -18,8 +18,8 @@ export class UsersService extends BaseCrudService {
|
|||||||
|
|
||||||
async createUser(dto: CreateUserDto) {
|
async createUser(dto: CreateUserDto) {
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
const { password: _pw, ...rest } = dto;
|
const { password: _pw, mustChangePassword = true, ...rest } = dto;
|
||||||
const created = await this.create({ ...rest, passwordHash });
|
const created = await this.create({ ...rest, passwordHash, mustChangePassword });
|
||||||
return this.omitPasswordHash(created);
|
return this.omitPasswordHash(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ export class UsersService extends BaseCrudService {
|
|||||||
|
|
||||||
async resetPassword(id: string, dto: ResetPasswordDto) {
|
async resetPassword(id: string, dto: ResetPasswordDto) {
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
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);
|
await this.invalidateSessions(id);
|
||||||
return this.omitPasswordHash(updated);
|
return this.omitPasswordHash(updated);
|
||||||
}
|
}
|
||||||
@ -81,6 +81,56 @@ export class UsersService extends BaseCrudService {
|
|||||||
return this.omitPasswordHash(removed);
|
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 + 清缓存。 */
|
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
|
||||||
private async invalidateSessions(userId: string): Promise<void> {
|
private async invalidateSessions(userId: string): Promise<void> {
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types.js';
|
import { App } from 'supertest/types.js';
|
||||||
import { AppModule } from './../src/app.module.js';
|
import { AppModule } from './../src/app.module.js';
|
||||||
|
import { ErrorCodes } from './../src/common/error-codes.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录链路 e2e:需要已启动的 MySQL/Redis 且已执行 pnpm seed(默认账号 touyun / Touyun@123)。
|
* 登录链路 e2e:需要已启动的 MySQL/Redis 且已执行 pnpm seed(默认账号 touyun / Touyun@123)。
|
||||||
@ -34,9 +35,9 @@ describe('Auth (e2e)', () => {
|
|||||||
it('密码错误返回 401', async () => {
|
it('密码错误返回 401', async () => {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/login')
|
.post('/api/v1/auth/login')
|
||||||
.send({ username: 'touyun', password: 'wrong-password' })
|
.send({ username: 'e2e_wrong_user', password: 'wrong-password' })
|
||||||
.expect(401);
|
.expect(401);
|
||||||
expect(res.body.code).toBe(40100);
|
expect(res.body.code).toBe(ErrorCodes.AUTH_CREDENTIALS);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user