fix(security): P0 安全越权堵漏(C1/H1-H6/H10/H11/H13/M1/M4/M6/M8-M11/M22)
- 平台管理员模型:super_admin=平台超管、tenant_super_admin=租户顶层(保留码),tenant:* 仅平台可授予 - 软删改名回收唯一键 + P2002→409 - 删除角色级联清理关联 + 聚合过滤已删/停用角色资源 - tokenVersion 会话吊销(重置密码/停用/删除即时踢下线) - data_scope self 按 userId 过滤 + 详情数据权限校验 - 组织树防环、seed 不重置密码、日志脱敏补 phone/email - JWT 密钥强校验、pageSize 上限、DTO 枚举校验、Prisma 错误映射、授权事务化 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
73ed067a82
commit
513688a5e6
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `sys_user` ADD COLUMN `token_version` INT NOT NULL DEFAULT 0;
|
||||||
@ -35,6 +35,7 @@ model User {
|
|||||||
phone String? @db.VarChar(32)
|
phone String? @db.VarChar(32)
|
||||||
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")
|
||||||
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)
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { Permissions } from '../src/common/permissions.js';
|
import { Permissions } from '../src/common/permissions.js';
|
||||||
|
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const TENANT = 'touyun';
|
const TENANT = PLATFORM_TENANT_CODE;
|
||||||
const SUPER_ROLE = 'super_admin';
|
const SUPER_ROLE = PLATFORM_ROLE_CODE;
|
||||||
const ADMIN_USERNAME = 'touyun';
|
const ADMIN_USERNAME = 'touyun';
|
||||||
const ADMIN_PASSWORD = 'Touyun@123';
|
const ADMIN_PASSWORD = 'Touyun@123';
|
||||||
|
|
||||||
@ -141,11 +142,11 @@ async function main(): Promise<void> {
|
|||||||
create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' },
|
create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 管理员用户
|
// 4. 管理员用户(update 分支不重置密码/状态,避免重跑 seed 重置管理员密码或重新启用账号)
|
||||||
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } },
|
where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } },
|
||||||
update: { passwordHash, nickname: '管理员', status: 'enabled' },
|
update: { nickname: '管理员' },
|
||||||
create: {
|
create: {
|
||||||
tenantCode: TENANT,
|
tenantCode: TENANT,
|
||||||
username: ADMIN_USERNAME,
|
username: ADMIN_USERNAME,
|
||||||
|
|||||||
@ -5,11 +5,19 @@ import { AuthService } from './auth.service.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';
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (!jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET 环境变量未配置,拒绝启动');
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === 'production' && (jwtSecret === 'dev-secret-change-me' || jwtSecret.length < 32)) {
|
||||||
|
throw new Error('生产环境 JWT_SECRET 过弱,请配置至少 32 位强密钥');
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
global: true,
|
global: true,
|
||||||
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
|
secret: jwtSecret,
|
||||||
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
|
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
|
||||||
}),
|
}),
|
||||||
AuditLogsModule,
|
AuditLogsModule,
|
||||||
|
|||||||
@ -10,10 +10,10 @@ import type { DataScope } from '../common/enums.js';
|
|||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
||||||
import { LoginLogsService } from '../login-logs/login-logs.service.js';
|
import { LoginLogsService } from '../login-logs/login-logs.service.js';
|
||||||
|
import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } from '../common/constants.js';
|
||||||
|
|
||||||
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 SUPER_ROLE = 'super_admin';
|
|
||||||
|
|
||||||
export interface PermissionSet {
|
export interface PermissionSet {
|
||||||
super: boolean;
|
super: boolean;
|
||||||
@ -115,7 +115,7 @@ export class AuthService {
|
|||||||
ip: ip ?? null,
|
ip: ip ?? null,
|
||||||
userAgent: userAgent ?? null,
|
userAgent: userAgent ?? null,
|
||||||
});
|
});
|
||||||
return this.issue(dto.tenantCode, user.id, user.username);
|
return this.issue(dto.tenantCode, user.id, user.username, user.tokenVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||||
@ -134,7 +134,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);
|
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
|
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
|
||||||
@ -251,7 +251,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private permKey(tenantCode: string, userId: string): string {
|
private permKey(tenantCode: string, userId: string): string {
|
||||||
return `perm:${tenantCode}:${userId}`;
|
return permCacheKey(tenantCode, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
|
||||||
|
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
|
||||||
|
const key = userStateCacheKey(tenantCode, userId);
|
||||||
|
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
|
||||||
|
if (!state) {
|
||||||
|
const user = await this.prisma.client.user.findFirst({
|
||||||
|
where: { tenantCode, id: userId, deletedAt: null },
|
||||||
|
select: { status: true, tokenVersion: true },
|
||||||
|
});
|
||||||
|
if (!user) return false;
|
||||||
|
state = { status: user.status, tokenVersion: user.tokenVersion };
|
||||||
|
await this.redis.setJson(key, state, ACCESS_TTL);
|
||||||
|
}
|
||||||
|
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 聚合权限码与超管标志。 */
|
/** 聚合权限码与超管标志。 */
|
||||||
@ -261,16 +277,26 @@ export class AuthService {
|
|||||||
select: { roleCode: true },
|
select: { roleCode: true },
|
||||||
});
|
});
|
||||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||||
const isSuper = roleCodes.includes(SUPER_ROLE);
|
// 过滤已删除角色(软删角色不参与权限聚合)
|
||||||
const resourceRows = await this.prisma.client.roleResource.findMany({
|
const activeRoles = roleCodes.length
|
||||||
where: { tenantCode, roleCode: { in: roleCodes } },
|
? 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 = activeRoleCodes.includes(SUPER_ROLE);
|
||||||
|
const resourceRows = activeRoleCodes.length
|
||||||
|
? await this.prisma.client.roleResource.findMany({
|
||||||
|
where: { tenantCode, roleCode: { in: activeRoleCodes } },
|
||||||
select: { resourceCode: true },
|
select: { resourceCode: true },
|
||||||
});
|
})
|
||||||
|
: [];
|
||||||
const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))];
|
const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))];
|
||||||
const permissions: string[] = resourceCodes.length
|
const permissions: string[] = resourceCodes.length
|
||||||
? (
|
? (
|
||||||
await this.prisma.client.resource.findMany({
|
await this.prisma.client.resource.findMany({
|
||||||
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null },
|
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, status: 'enabled', deletedAt: null },
|
||||||
select: { code: true },
|
select: { code: true },
|
||||||
})
|
})
|
||||||
).map((r) => r.code)
|
).map((r) => r.code)
|
||||||
@ -320,8 +346,9 @@ export class AuthService {
|
|||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
username: string,
|
username: string,
|
||||||
|
tokenVersion: number,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||||
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username });
|
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
|
||||||
const jti = randomUUID();
|
const jti = randomUUID();
|
||||||
await this.prisma.client.refreshToken.create({
|
await this.prisma.client.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from
|
|||||||
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';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -14,6 +15,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
|
private readonly auth: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@ -30,10 +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 }>(token);
|
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
|
||||||
|
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
|
||||||
|
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
||||||
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
if (e instanceof UnauthorizedException) throw e;
|
||||||
throw new UnauthorizedException('token 无效或已过期');
|
throw new UnauthorizedException('token 无效或已过期');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { getRequestContext } from './context/request-context.js';
|
import { getRequestContext } from './context/request-context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软删时需「改名回收」唯一键的模型映射:modelName → 唯一字段及其列长。
|
||||||
|
* 目的:软删只置 deletedAt 不释放 @@unique,重建同名会 P2002;删除时把唯一字段改名
|
||||||
|
* 成「原值(截断) + #id」释放唯一键,展示名字段(name/label)保持不变。
|
||||||
|
*/
|
||||||
|
const RECYCLE_FIELDS: Record<string, { field: string; maxLen: number }> = {
|
||||||
|
user: { field: 'username', maxLen: 64 },
|
||||||
|
role: { field: 'code', maxLen: 64 },
|
||||||
|
resource: { field: 'code', maxLen: 128 },
|
||||||
|
org: { field: 'code', maxLen: 64 },
|
||||||
|
dictType: { field: 'code', maxLen: 64 },
|
||||||
|
dictItem: { field: 'value', maxLen: 64 },
|
||||||
|
config: { field: 'key', maxLen: 128 },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用 CRUD 基类:封装分页/创建/更新/软删。
|
* 通用 CRUD 基类:封装分页/创建/更新/软删。
|
||||||
* 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。
|
* 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。
|
||||||
@ -47,6 +62,18 @@ export class BaseCrudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async softRemove(id: string) {
|
async softRemove(id: string) {
|
||||||
|
const recycle = RECYCLE_FIELDS[this.modelName];
|
||||||
|
if (!recycle) {
|
||||||
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
|
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
|
||||||
}
|
}
|
||||||
|
// 先读原值,改名回收唯一键,再软删
|
||||||
|
const record = await this.findById(id);
|
||||||
|
const original = record?.[recycle.field];
|
||||||
|
if (original == null) {
|
||||||
|
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
|
||||||
|
}
|
||||||
|
const suffix = `#${id}`;
|
||||||
|
const recycled = `${String(original).slice(0, recycle.maxLen - suffix.length)}${suffix}`;
|
||||||
|
return this.db().update({ where: { id }, data: { [recycle.field]: recycled, deletedAt: new Date() } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/common/constants.ts
Normal file
24
src/common/constants.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 平台级常量:平台租户与保留角色码的单一事实源。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 平台租户(投云科技,根租户):唯一可维护租户表的租户。 */
|
||||||
|
export const PLATFORM_TENANT_CODE = 'touyun';
|
||||||
|
|
||||||
|
/** 平台超管角色码(保留):isSuper 全绕过 + 唯一拥有 tenant:* 权限。 */
|
||||||
|
export const PLATFORM_ROLE_CODE = 'super_admin';
|
||||||
|
|
||||||
|
/** 业务租户顶层管理员角色码(保留):本租户内除 tenant:* 外全权限。 */
|
||||||
|
export const TENANT_ADMIN_ROLE_CODE = 'tenant_super_admin';
|
||||||
|
|
||||||
|
/** 保留角色码:租户不可自行创建。 */
|
||||||
|
export const RESERVED_ROLE_CODES: readonly string[] = [PLATFORM_ROLE_CODE, TENANT_ADMIN_ROLE_CODE];
|
||||||
|
|
||||||
|
/** 平台级权限码前缀(仅平台租户可授予)。 */
|
||||||
|
export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
|
||||||
|
|
||||||
|
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
||||||
|
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
|
||||||
|
|
||||||
|
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
|
||||||
|
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;
|
||||||
@ -32,6 +32,8 @@ export async function resolveVisibleOrgs(opts: {
|
|||||||
case 'all':
|
case 'all':
|
||||||
return [];
|
return [];
|
||||||
case 'self':
|
case 'self':
|
||||||
|
// 仅本人:不走组织过滤,由调用方按 userId 过滤(返回哨兵避免误放大为全量)
|
||||||
|
return ['__none__'];
|
||||||
case 'dept':
|
case 'dept':
|
||||||
return orgCode ? [orgCode] : ['__none__'];
|
return orgCode ? [orgCode] : ['__none__'];
|
||||||
case 'dept_and_child':
|
case 'dept_and_child':
|
||||||
@ -47,14 +49,22 @@ export async function resolveVisibleOrgs(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收集某部门及其全部后代部门的 code(含自身)。 */
|
/** 收集某部门及其全部后代部门的 code(含自身),带 visited 防环。 */
|
||||||
async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise<string[]> {
|
async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise<string[]> {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
let frontier = [rootCode];
|
let frontier = [rootCode];
|
||||||
while (frontier.length > 0) {
|
while (frontier.length > 0) {
|
||||||
result.push(...frontier);
|
const next: string[] = [];
|
||||||
|
for (const code of frontier) {
|
||||||
|
if (visited.has(code)) continue;
|
||||||
|
visited.add(code);
|
||||||
|
result.push(code);
|
||||||
|
next.push(code);
|
||||||
|
}
|
||||||
|
if (next.length === 0) break;
|
||||||
const children = await prisma.org.findMany({
|
const children = await prisma.org.findMany({
|
||||||
where: { tenantCode, parentCode: { in: frontier }, deletedAt: null },
|
where: { tenantCode, parentCode: { in: next }, deletedAt: null },
|
||||||
select: { code: true },
|
select: { code: true },
|
||||||
});
|
});
|
||||||
frontier = children.map((c) => c.code);
|
frontier = children.map((c) => c.code);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
|
||||||
@ -13,14 +14,32 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
const body = exception instanceof HttpException ? exception.getResponse() : null;
|
let message: string;
|
||||||
const message =
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const body = exception.getResponse();
|
||||||
|
message =
|
||||||
typeof body === 'string'
|
typeof body === 'string'
|
||||||
? body
|
? body
|
||||||
: Array.isArray((body as { message?: unknown })?.message)
|
: Array.isArray((body as { message?: unknown })?.message)
|
||||||
? ((body as { message: string[] }).message.join('; '))
|
? ((body as { message: string[] }).message.join('; '))
|
||||||
: ((body as { message?: string })?.message ?? (exception as Error).message ?? 'Internal server error');
|
: ((body as { message?: string })?.message ?? exception.message);
|
||||||
|
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (exception.code === 'P2002') {
|
||||||
|
status = HttpStatus.CONFLICT;
|
||||||
|
message = '数据已存在(唯一约束冲突),请勿重复创建';
|
||||||
|
} else if (exception.code === 'P2025') {
|
||||||
|
status = HttpStatus.NOT_FOUND;
|
||||||
|
message = '记录不存在或已被删除';
|
||||||
|
} else {
|
||||||
|
message = '数据库操作失败';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未知异常:不向客户端泄漏内部错误细节
|
||||||
|
message = '系统内部错误';
|
||||||
|
}
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
this.logger.error({ err: exception }, message);
|
this.logger.error({ err: exception }, message);
|
||||||
|
|||||||
@ -9,6 +9,9 @@ const SENSITIVE_KEYS = new Set([
|
|||||||
'token',
|
'token',
|
||||||
'secret',
|
'secret',
|
||||||
'authorization',
|
'authorization',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'email',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function maskSensitive(value: unknown): unknown {
|
export function maskSensitive(value: unknown): unknown {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsOptional, 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 })
|
||||||
@ -15,6 +15,7 @@ export class PageQuery {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
pageSize = 20;
|
pageSize = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { CommonStatus } from '../common/enums.js';
|
||||||
|
|
||||||
/** 标签色(对应前端 TagType)。 */
|
/** 标签色(对应前端 TagType)。 */
|
||||||
const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const;
|
const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const;
|
||||||
@ -29,7 +30,7 @@ export class UpdateDictTypeDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '备注' })
|
@ApiPropertyOptional({ description: '备注' })
|
||||||
@ -83,7 +84,7 @@ export class UpdateDictItemDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
|
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { CommonStatus } from '../common/enums.js';
|
||||||
|
|
||||||
export class CreateOrgDto {
|
export class CreateOrgDto {
|
||||||
@ApiProperty({ description: '组织 code(租户内唯一)' })
|
@ApiProperty({ description: '组织 code(租户内唯一)' })
|
||||||
@ -51,6 +52,6 @@ export class UpdateOrgDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, 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 { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
||||||
@ -9,11 +9,35 @@ export class OrgsService extends BaseCrudService {
|
|||||||
super(prisma, 'org');
|
super(prisma, 'org');
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrg(dto: CreateOrgDto) {
|
async createOrg(dto: CreateOrgDto) {
|
||||||
|
await this.assertValidParent(dto.parentCode);
|
||||||
return this.create({ ...dto });
|
return this.create({ ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOrg(id: string, dto: UpdateOrgDto) {
|
async updateOrg(id: string, dto: UpdateOrgDto) {
|
||||||
|
if (dto.parentCode !== undefined) {
|
||||||
|
const current = await this.findById(id);
|
||||||
|
await this.assertValidParent(dto.parentCode, current?.code);
|
||||||
|
}
|
||||||
return this.update(id, { ...dto });
|
return this.update(id, { ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */
|
||||||
|
private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise<void> {
|
||||||
|
if (!parentCode) return; // 根节点
|
||||||
|
const parent = await this.db().findFirst({ where: { code: parentCode } });
|
||||||
|
if (!parent) throw new BadRequestException('父组织不存在');
|
||||||
|
if (!selfCode) return;
|
||||||
|
let cur: string | null = parentCode;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (cur && !visited.has(cur)) {
|
||||||
|
if (cur === selfCode) throw new BadRequestException('不能将组织挂到自身或其子组织下');
|
||||||
|
visited.add(cur);
|
||||||
|
const node: { parentCode: string | null } | null = await this.db().findFirst({
|
||||||
|
where: { code: cur },
|
||||||
|
select: { parentCode: true },
|
||||||
|
});
|
||||||
|
cur = node?.parentCode ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { CommonStatus, ResourceType } from '../common/enums.js';
|
||||||
|
|
||||||
export class CreateResourceDto {
|
export class CreateResourceDto {
|
||||||
@ApiProperty({ description: '类型:menu/button/api' })
|
@ApiProperty({ description: '类型:menu/button/api' })
|
||||||
@IsString()
|
@IsIn(Object.values(ResourceType))
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
@ -96,6 +97,6 @@ export class UpdateResourceDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
import { CommonStatus, DataScope } from '../common/enums.js';
|
||||||
|
|
||||||
export class CreateRoleDto {
|
export class CreateRoleDto {
|
||||||
@ApiProperty({ description: '角色编码' })
|
@ApiProperty({ description: '角色编码' })
|
||||||
@ -15,7 +16,7 @@ export class CreateRoleDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom', default: 'all' })
|
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom', default: 'all' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(DataScope))
|
||||||
dataScope?: string;
|
dataScope?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '备注' })
|
@ApiPropertyOptional({ description: '备注' })
|
||||||
@ -32,12 +33,12 @@ export class UpdateRoleDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom' })
|
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(DataScope))
|
||||||
dataScope?: string;
|
dataScope?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '备注' })
|
@ApiPropertyOptional({ description: '备注' })
|
||||||
@ -66,6 +67,6 @@ export class ListRoleQuery extends PageQuery {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export class RolesController {
|
|||||||
@RequirePermissions(Permissions.role.delete)
|
@RequirePermissions(Permissions.role.delete)
|
||||||
@AuditLog('role', 'delete')
|
@AuditLog('role', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.removeRole(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/resources')
|
@Get(':id/resources')
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, 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 { 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 { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
|
import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -15,6 +16,9 @@ export class RolesService extends BaseCrudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRole(dto: CreateRoleDto) {
|
createRole(dto: CreateRoleDto) {
|
||||||
|
if (RESERVED_ROLE_CODES.includes(dto.code)) {
|
||||||
|
throw new BadRequestException(`角色编码 ${dto.code} 为系统保留,不可创建`);
|
||||||
|
}
|
||||||
return this.create({ ...dto });
|
return this.create({ ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +34,23 @@ 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) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const role = await this.findById(id);
|
||||||
|
if (!role) throw new BadRequestException('角色不存在');
|
||||||
|
if (RESERVED_ROLE_CODES.includes(role.code)) {
|
||||||
|
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
|
||||||
|
}
|
||||||
|
const roleCode = role.code;
|
||||||
|
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
|
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
|
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
|
await this.softRemove(id);
|
||||||
|
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async assignedResources(roleCode: string) {
|
async assignedResources(roleCode: string) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const rows = await this.prisma.client.roleResource.findMany({
|
const rows = await this.prisma.client.roleResource.findMany({
|
||||||
@ -41,12 +62,22 @@ export class RolesService extends BaseCrudService {
|
|||||||
|
|
||||||
async assignResources(roleCode: string, dto: AssignResourcesDto) {
|
async assignResources(roleCode: string, dto: AssignResourcesDto) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
|
||||||
if (dto.resourceCodes.length > 0) {
|
const resourceCodes =
|
||||||
await this.prisma.client.roleResource.createMany({
|
ctx.tenantCode === PLATFORM_TENANT_CODE
|
||||||
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
|
? dto.resourceCodes
|
||||||
|
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
|
||||||
|
const role = await this.db().findFirst({ where: { code: roleCode } });
|
||||||
|
if (!role) throw new BadRequestException('角色不存在');
|
||||||
|
// 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空
|
||||||
|
await this.prisma.client.$transaction(async (tx) => {
|
||||||
|
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
|
if (resourceCodes.length > 0) {
|
||||||
|
await tx.roleResource.createMany({
|
||||||
|
data: resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
|
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
|
||||||
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
|
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
import { CommonStatus } from '../common/enums.js';
|
||||||
|
|
||||||
export class CreateTenantDto {
|
export class CreateTenantDto {
|
||||||
@ApiProperty({ description: '租户编码' })
|
@ApiProperty({ description: '租户编码' })
|
||||||
@ -15,7 +16,7 @@ export class CreateTenantDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '备注' })
|
@ApiPropertyOptional({ description: '备注' })
|
||||||
@ -32,7 +33,7 @@ export class UpdateTenantDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '备注' })
|
@ApiPropertyOptional({ description: '备注' })
|
||||||
@ -54,6 +55,6 @@ export class ListTenantQuery extends PageQuery {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(CommonStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
import { UserStatus } from '../common/enums.js';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@ApiProperty({ description: '用户名' })
|
@ApiProperty({ description: '用户名' })
|
||||||
@ -67,7 +68,7 @@ export class UpdateUserDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(UserStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +97,6 @@ export class ListUserQuery extends PageQuery {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
|
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(Object.values(UserStatus))
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
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 { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { permCacheKey, userStateCacheKey } from '../common/constants.js';
|
||||||
import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService extends BaseCrudService {
|
export class UsersService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
super(prisma, 'user');
|
super(prisma, 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,8 +30,10 @@ export class UsersService extends BaseCrudService {
|
|||||||
if (q.nickname) where.nickname = { contains: q.nickname };
|
if (q.nickname) where.nickname = { contains: q.nickname };
|
||||||
if (q.phone) where.phone = { contains: q.phone };
|
if (q.phone) where.phone = { contains: q.phone };
|
||||||
if (q.status) where.status = q.status;
|
if (q.status) where.status = q.status;
|
||||||
// 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
|
// 数据权限:仅本人按 userId 过滤;否则限制在可见部门集合(空数组=全部可见)
|
||||||
if (ctx && ctx.orgCodes.length > 0) {
|
if (ctx && ctx.dataScope === 'self') {
|
||||||
|
where.id = ctx.userId;
|
||||||
|
} else if (ctx && ctx.orgCodes.length > 0) {
|
||||||
where.orgCode = { in: ctx.orgCodes };
|
where.orgCode = { in: ctx.orgCodes };
|
||||||
}
|
}
|
||||||
const result = await this.paginate(q.page, q.pageSize, where);
|
const result = await this.paginate(q.page, q.pageSize, where);
|
||||||
@ -35,26 +42,55 @@ export class UsersService extends BaseCrudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string) {
|
async findById(id: string) {
|
||||||
|
await this.assertCanView(id);
|
||||||
const user = await super.findById(id);
|
const user = await super.findById(id);
|
||||||
return user ? this.omitPasswordHash(user) : user;
|
return user ? this.omitPasswordHash(user) : user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情查看的数据权限校验:越界直接 403。 */
|
||||||
|
private async assertCanView(userId: string): Promise<void> {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
if (!ctx || ctx.dataScope === 'all') return;
|
||||||
|
if (ctx.dataScope === 'self') {
|
||||||
|
if (userId !== ctx.userId) throw new ForbiddenException('无权查看该用户');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scopes = ctx.orgCodes.length ? ctx.orgCodes : ['__none__'];
|
||||||
|
const target = await this.db().findFirst({ where: { id: userId, orgCode: { in: scopes } } });
|
||||||
|
if (!target) throw new ForbiddenException('无权查看该用户');
|
||||||
|
}
|
||||||
|
|
||||||
async updateUser(id: string, dto: UpdateUserDto) {
|
async updateUser(id: string, dto: UpdateUserDto) {
|
||||||
const updated = await this.update(id, { ...dto });
|
const updated = await this.update(id, { ...dto });
|
||||||
|
if (dto.status && dto.status !== 'enabled') {
|
||||||
|
await this.invalidateSessions(id);
|
||||||
|
}
|
||||||
return this.omitPasswordHash(updated);
|
return this.omitPasswordHash(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(id: string, dto: ResetPasswordDto) {
|
async resetPassword(id: string, dto: ResetPasswordDto) {
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
const updated = await this.update(id, { passwordHash });
|
const updated = await this.update(id, { passwordHash });
|
||||||
|
await this.invalidateSessions(id);
|
||||||
return this.omitPasswordHash(updated);
|
return this.omitPasswordHash(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async softRemove(id: string) {
|
async softRemove(id: string) {
|
||||||
const removed = await super.softRemove(id);
|
const removed = await super.softRemove(id);
|
||||||
|
await this.invalidateSessions(id);
|
||||||
return this.omitPasswordHash(removed);
|
return this.omitPasswordHash(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
|
||||||
|
private async invalidateSessions(userId: string): Promise<void> {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
await this.db().updateMany({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
|
||||||
|
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } });
|
||||||
|
await this.redis.del(permCacheKey(ctx.tenantCode, userId));
|
||||||
|
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
|
||||||
|
}
|
||||||
|
|
||||||
/** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */
|
/** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */
|
||||||
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
|
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
|
||||||
const { passwordHash: _passwordHash, ...rest } = row;
|
const { passwordHash: _passwordHash, ...rest } = row;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user