Compare commits

..

No commits in common. "6618137da557843b0c30ebaebf06f8180a303392" and "6937f9ece03ece26a0ba36e684dbe45ac9f098c1" have entirely different histories.

48 changed files with 221 additions and 1332 deletions

View File

@ -1,18 +1,12 @@
# 复制为 .env 并按需修改 # 复制为 .env 并按需修改
# 数据库(MySQL 8) # 数据库(MySQL 8)
DATABASE_URL="mysql://root:请替换为数据库密码@127.0.0.1:3306/sysmgr" DATABASE_URL="mysql://root:Mysql.123@127.0.0.1:3306/sysmgr"
# 服务端口 # 服务端口
PORT=3000 PORT=3000
# 运行环境(production 会关闭 Swagger 并强校验 JWT_SECRET) # 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"

View File

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

View File

@ -26,8 +26,7 @@
"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",

View File

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

View File

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

View File

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

View File

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

View File

@ -17,10 +17,6 @@ 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")
@ -40,9 +36,6 @@ 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)
@ -220,7 +213,6 @@ 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)
@ -231,39 +223,9 @@ 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)

View File

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

View File

@ -23,8 +23,6 @@ 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';
@ -45,8 +43,6 @@ import { AppService } from './app.service.js';
ConfigsModule, ConfigsModule,
AuditLogsModule, AuditLogsModule,
LoginLogsModule, LoginLogsModule,
SessionsModule,
NoticesModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

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

View File

@ -1,24 +1,22 @@
import { BadRequestException, ConflictException, ForbiddenException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ConflictException, 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 { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto, SwitchTenantDto } from './dto.js'; import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } 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, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, userStateCacheKey } from '../common/constants.js'; import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, 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 {
@ -87,28 +85,28 @@ export class AuthService {
ip?: string, ip?: string,
userAgent?: string, userAgent?: string,
): Promise< ): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean } | { accessToken: string; refreshToken: string; expiresIn: number }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> { > {
await this.assertNotLocked(ip, dto.username); await this.assertNotLocked(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(ip, dto.username); await this.clearLoginFail(dto.username);
return result; return result;
} catch (e) { } catch (e) {
if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username); if (e instanceof UnauthorizedException) await this.recordLoginFail(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, lockedAt: true }, select: { tenantCode: true, passwordHash: true, status: 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' || u.lockedAt) continue; if (u.status !== 'enabled') 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)) {
@ -116,44 +114,33 @@ export class AuthService {
} }
} }
if (matched.length === 0) { if (matched.length === 0) {
await this.recordLoginFail(ip, dto.username); await this.recordLoginFail(dto.username);
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent); await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); throw new UnauthorizedException('账号或密码错误');
} }
await this.clearLoginFail(ip, dto.username); await this.clearLoginFail(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(ip: string | undefined, username: string): Promise<void> { private async assertNotLocked(username: string): Promise<void> {
const perIp = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0; const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
const global = (await this.redis.getJson<number>(this.globalLockKey(username))) ?? 0; if (count >= MAX_LOGIN_FAILS) {
if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试'); throw new UnauthorizedException('登录失败次数过多,请稍后再试');
} }
} }
private async recordLoginFail(ip: string | undefined, username: string): Promise<void> { private async recordLoginFail(username: string): Promise<void> {
const perIpKey = this.lockKey(ip, username); const key = `${LOGIN_FAIL_PREFIX}${username}`;
const globalKey = this.globalLockKey(username); const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(perIpKey, ((await this.redis.getJson<number>(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS); await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
await this.redis.setJson(globalKey, ((await this.redis.getJson<number>(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
} }
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> { private async clearLoginFail(username: string): Promise<void> {
await this.redis.del(this.lockKey(ip, username)); await this.redis.del(`${LOGIN_FAIL_PREFIX}${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}`;
} }
/** 指定租户登录(单租户逻辑)。 */ /** 指定租户登录(单租户逻辑)。 */
@ -163,31 +150,27 @@ export class AuthService {
password: string, password: string,
ip?: string, ip?: string,
userAgent?: string, userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }> { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
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 BusinessException(ErrorCodes.TENANT_DISABLED, '租户不存在或已停用', HttpStatus.UNAUTHORIZED); throw new UnauthorizedException('租户不存在或已停用');
} }
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 BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); throw new UnauthorizedException('账号或密码错误');
}
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 BusinessException(ErrorCodes.AUTH_DISABLED, '账号已停用', HttpStatus.UNAUTHORIZED); throw new UnauthorizedException('账号已停用或锁定');
} }
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 BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); throw new UnauthorizedException('账号或密码错误');
} }
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);
@ -201,26 +184,17 @@ export class AuthService {
ip: ip ?? null, ip: ip ?? null,
userAgent: userAgent ?? null, userAgent: userAgent ?? null,
}); });
const tokens = await this.issue(tenantCode, user.id, user.username, user.tokenVersion, { return 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 BusinessException(ErrorCodes.USER_USERNAME_EXISTS, '用户名已存在', HttpStatus.CONFLICT); if (exists) throw new ConflictException('用户名已存在');
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: {
@ -242,11 +216,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 BusinessException(ErrorCodes.AUTH_OLD_PWD_WRONG, '原密码错误', HttpStatus.BAD_REQUEST); if (!ok) throw new BadRequestException('原密码错误');
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, mustChangePassword: false, tokenVersion: { increment: 1 } }, data: { passwordHash, 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));
@ -263,7 +237,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' || user.lockedAt) throw new UnauthorizedException('账号已失效'); if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效) // 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
await this.prisma.client.refreshToken.update({ await this.prisma.client.refreshToken.update({
@ -271,12 +245,7 @@ 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> {
@ -303,30 +272,20 @@ export class AuthService {
} }
/** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */ /** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */
async getPermissionSet(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> { async getPermissionSet(tenantCode: string, userId: string): 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, switched); return this.writePermissionCache(tenantCode, userId);
} }
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */ /** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
async getMenus(tenantCode: string, userId: string, switched = false): Promise<MenuNode[]> { async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
const roleRows = switched const roleRows = await this.prisma.client.userRole.findMany({
? []
: 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);
// 过滤已删除角色(与 resolveAuth 保持一致) const isSuper = roleCodes.includes(SUPER_ROLE);
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({
@ -334,7 +293,7 @@ export class AuthService {
orderBy: { sort: 'asc' }, orderBy: { sort: 'asc' },
select: MENU_SELECT, select: MENU_SELECT,
}) })
: await this.menusForRoles(tenantCode, activeRoleCodes); : await this.menusForRoles(tenantCode, roleCodes);
return buildMenuTree(rows as MenuRow[]); return buildMenuTree(rows as MenuRow[]);
} }
@ -370,23 +329,19 @@ export class AuthService {
} }
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */ /** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string; super?: boolean }): Promise<RequestContext> { async buildContext(user: { userId: string; tenantCode: string; username: string }): 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 },
}); });
orgCode = u?.orgCode ?? null; const 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 },
}); });
roleCodes = roleRows.map((r) => r.roleCode); const 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 },
@ -404,133 +359,32 @@ export class AuthService {
roleCodes, roleCodes,
}); });
return { return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes };
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( async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
tenantCode: string, const key = userStateCacheKey(tenantCode, userId);
userId: string, let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
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: homeTenant, id: userId, deletedAt: null }, where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true }, select: { status: true, tokenVersion: true },
}); });
if (!user) return { active: false, mustChangePassword: false }; if (!user) return false;
state = { state = { status: user.status, tokenVersion: user.tokenVersion };
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);
} }
const active = state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt; return state.status === 'enabled' && state.tokenVersion === tokenVersion;
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, switched = false): Promise<PermissionSet> { private async resolveAuth(tenantCode: string, userId: string): Promise<PermissionSet> {
const roleRows = switched const roleRows = await this.prisma.client.userRole.findMany({
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId }, where: { tenantCode, userId },
select: { roleCode: true }, select: { roleCode: true },
}); });
@ -543,7 +397,7 @@ export class AuthService {
}) })
: []; : [];
const activeRoleCodes = activeRoles.map((r) => r.code); const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE); const isSuper = 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 } },
@ -563,8 +417,8 @@ export class AuthService {
} }
/** 聚合并写入权限缓存(登录/刷新时调用)。 */ /** 聚合并写入权限缓存(登录/刷新时调用)。 */
private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> { private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId, switched); const set = await this.resolveAuth(tenantCode, userId);
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;
} }
@ -605,28 +459,15 @@ 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({ const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
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),
}, },
}); });

View File

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

View File

@ -1,16 +1,13 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { CanActivate, ExecutionContext, 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()
@ -35,24 +32,13 @@ 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; sid?: string; super?: boolean }>(token); const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0, payload.super ?? false); const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效'); if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) { request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
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 HttpException) throw e; if (e instanceof UnauthorizedException) throw e;
throw new UnauthorizedException('token 无效或已过期'); throw new UnauthorizedException('token 无效或已过期');
} }
} }
@ -63,12 +49,4 @@ 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;
}
} }

View File

@ -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; super?: boolean } | undefined; const user = request.user as { userId: string; tenantCode: string } | undefined;
if (!user) throw new ForbiddenException('无权限'); if (!user) throw new ForbiddenException('无权限');
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId, user.super ?? false); const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId);
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('无权限');

View File

@ -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; super?: boolean } | undefined; const user = request.user as { userId: string; tenantCode: string; username: string } | 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(

View File

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

View File

@ -20,11 +20,5 @@ 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 + lockedAt,供 JwtAuthGuard 每请求校验)。 */ /** 用户状态缓存 key(Redis,status + tokenVersion,供 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:';

View File

@ -6,8 +6,6 @@ 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 集合;空数组表示「全部可见」 */

View File

@ -12,6 +12,7 @@ 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];

View File

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

View File

@ -2,10 +2,9 @@ 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 状态 × 100`,并记录日志。 * 全局异常过滤器:错误码分段与 HTTP 状态对齐(400xx/401xx/403xx/404xx/409xx/500xx),并记录日志。
*/ */
@Catch() @Catch()
export class AllExceptionsFilter implements ExceptionFilter { export class AllExceptionsFilter implements ExceptionFilter {
@ -17,14 +16,8 @@ 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 BusinessException) { if (exception instanceof HttpException) {
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 =
@ -55,7 +48,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
} }
response.status(status).json({ response.status(status).json({
code: code ?? status * 100, code: status * 100,
message, message,
data: null, data: null,
}); });

View File

@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PageQuery { export class PageQuery {
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 }) @ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
@ -24,12 +24,3 @@ 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[];
}

View File

@ -16,9 +16,6 @@ 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',
@ -26,7 +23,6 @@ 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',
@ -52,13 +48,6 @@ 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;

View File

@ -41,15 +41,10 @@ export class RedisService implements OnModuleDestroy {
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */ /** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
async delByPrefix(prefix: string): Promise<void> { async delByPrefix(prefix: string): Promise<void> {
await this.delByPattern(`${prefix}*`); const fullPrefix = this.prefix + 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', fullPattern, 'COUNT', 200); const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, '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');

View File

@ -14,11 +14,9 @@ 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 = 权限码)。 */
@ -38,7 +36,6 @@ 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' },
@ -57,15 +54,6 @@ 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' },
]; ];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */ /** 按钮资源(纯前端按钮,无独立受控接口)。 */
@ -116,22 +104,35 @@ export interface InitTenantOptions {
adminPassword: string; adminPassword: string;
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */ /** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
includePlatformPerms: boolean; includePlatformPerms: boolean;
/** 管理员是否需首登强制改密(随机密码场景为 true)。 */
forcePasswordChange?: boolean;
} }
export interface SyncTenantResourcesOptions { /** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
prisma: PrismaClient; export async function initTenant(opts: InitTenantOptions): Promise<void> {
tenantCode: string; const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
adminRoleCode: string;
includePlatformPerms: boolean;
}
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */ // 1. 根组织
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> { await prisma.org.upsert({
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts; where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口) // 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
});
// 4. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
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) {
@ -159,7 +160,7 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
}); });
} }
// 内置字典 // 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 } },
@ -175,7 +176,14 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
} }
} }
// 顶层角色全量授权 // 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 } },
@ -184,40 +192,3 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
}); });
} }
} }
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
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 });
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ 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')
@ -41,11 +40,4 @@ 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);
}
} }

View File

@ -33,19 +33,6 @@ 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; // 根节点

View File

@ -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', 'Notice']); const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config']);
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']);

View File

@ -2,7 +2,6 @@ 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')
@ -41,11 +40,4 @@ 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);
}
} }

View File

@ -1,54 +1,19 @@
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( constructor(prisma: PrismaService) {
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'resource'); super(prisma, 'resource');
} }
async createResource(dto: CreateResourceDto) { createResource(dto: CreateResourceDto) {
const created = await this.create({ ...dto }); return this.create({ ...dto });
await this.clearPermCache();
return created;
} }
async updateResource(id: string, dto: UpdateResourceDto) { updateResource(id: string, dto: UpdateResourceDto) {
const updated = await this.update(id, { ...dto }); return this.update(id, { ...dto });
await this.clearPermCache();
return updated;
}
async softRemove(id: string) {
const removed = await super.softRemove(id);
await this.clearPermCache();
return removed;
}
/** 批量删除:逐个软删(回收 code),最后统一清权限缓存。 */
async batchDelete(ids: string[]) {
const failed: string[] = [];
for (const id of ids) {
try {
await super.softRemove(id);
} catch {
failed.push(id);
}
}
await this.clearPermCache();
return { total: ids.length, success: ids.length - failed.length, failed };
}
/** 资源变更(含停用/删除)影响权限码聚合,清该租户全部权限缓存。 */
private async clearPermCache(): Promise<void> {
const ctx = getRequestContext();
if (ctx) await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
} }
} }

View File

@ -54,13 +54,6 @@ 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()

View File

@ -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 { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
import { RolesService } from './roles.service.js'; import { RolesService } from './roles.service.js';
@Controller('roles') @Controller('roles')
@ -41,29 +41,16 @@ export class RolesController {
return this.service.removeRole(id); return this.service.removeRole(id);
} }
@Get(':roleCode/resources') @Get(':id/resources')
@RequirePermissions(Permissions.role.list) @RequirePermissions(Permissions.role.list)
resources(@Param('roleCode') roleCode: string) { resources(@Param('id') id: string) {
return this.service.assignedResources(roleCode); return this.service.assignedResources(id);
} }
@Put(':roleCode/resources') @Put(':id/resources')
@RequirePermissions(Permissions.role.assignResource) @RequirePermissions(Permissions.role.assignResource)
@AuditLog('role', 'assign-resource') @AuditLog('role', 'assign-resource')
assign(@Param('roleCode') roleCode: string, @Body() dto: AssignResourcesDto) { assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
return this.service.assignResources(roleCode, dto); return this.service.assignResources(id, dto);
}
@Get(':roleCode/orgs')
@RequirePermissions(Permissions.role.list)
orgs(@Param('roleCode') roleCode: string) {
return this.service.assignedOrgs(roleCode);
}
@Put(':roleCode/orgs')
@RequirePermissions(Permissions.role.assignOrg)
@AuditLog('role', 'assign-org')
assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) {
return this.service.assignOrgs(roleCode, dto);
} }
} }

View File

@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js'; import { 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 { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; import { 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,17 +43,10 @@ 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.raw.$transaction(async (tx) => { await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await tx.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await this.softRemove(id);
// 软删 + 回收 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;
} }
@ -67,45 +60,6 @@ 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:*)仅平台租户可授予,非平台租户一律剥离
@ -115,18 +69,6 @@ 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 } });

View File

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

View File

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

View File

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

View File

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

View File

@ -33,26 +33,6 @@ 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 {
@ -70,26 +50,6 @@ 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 {

View File

@ -1,58 +1,35 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } 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( constructor(prisma: PrismaService) {
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'tenant'); super(prisma, 'tenant');
} }
/** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */ /** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */
async createTenant(dto: CreateTenantDto) { async createTenant(dto: CreateTenantDto) {
const ctx = getRequestContext(); const tenant = await this.create({
const code = dto.code; 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: tx as unknown as PrismaClient, prisma: this.prisma.raw,
tenantCode: code, tenantCode: dto.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, adminPassword: dto.adminPassword ?? 'Admin@123456',
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) {
@ -65,14 +42,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 BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND); if (!existing) throw new NotFoundException('租户不存在');
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 BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND); if (!existing) throw new NotFoundException('租户不存在');
const code = existing.code; const code = existing.code;
const db = this.prisma.raw; const db = this.prisma.raw;
await db.$transaction([ await db.$transaction([
@ -91,9 +68,6 @@ 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;
} }
} }

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; import { IsArray, 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,11 +15,6 @@ 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()
@ -93,13 +88,6 @@ 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()

View File

@ -1,8 +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 { AssignRolesDto, CreateUserDto, ListUserQuery, LockUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js'; import { AssignRolesDto, CreateUserDto, ListUserQuery, 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')
@ -61,32 +60,4 @@ 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);
}
} }

View File

@ -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 { LOGIN_FAIL_PREFIX, permCacheKey, RESERVED_ROLE_CODES, userStateCacheKey } from '../common/constants.js'; import { permCacheKey, 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, mustChangePassword = true, ...rest } = dto; const { password: _pw, ...rest } = dto;
const created = await this.create({ ...rest, passwordHash, mustChangePassword }); const created = await this.create({ ...rest, passwordHash });
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, mustChangePassword: true }); const updated = await this.update(id, { passwordHash });
await this.invalidateSessions(id); await this.invalidateSessions(id);
return this.omitPasswordHash(updated); return this.omitPasswordHash(updated);
} }
@ -81,56 +81,6 @@ 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();

View File

@ -3,7 +3,6 @@ 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)。
@ -35,9 +34,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: 'e2e_wrong_user', password: 'wrong-password' }) .send({ username: 'touyun', password: 'wrong-password' })
.expect(401); .expect(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_CREDENTIALS); expect(res.body.code).toBe(40100);
}); });
afterAll(async () => { afterAll(async () => {