feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。 改动: - shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。 - prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。 - api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。 - web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。 - 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。 验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
This commit is contained in:
1 parent
f97f97b0cd
commit
61894df401
80 files changed
+14380
-237
No files matched your search
@@ -11,6 +11,7 @@ import { tap } from 'rxjs';
|
||||
|
||||
import { AUDIT_ACTION_KEY } from './decorators';
|
||||
import {
|
||||
type AuditDetail,
|
||||
buildRequestContext,
|
||||
type RequestContext,
|
||||
RequestContextService,
|
||||
@@ -26,7 +27,16 @@ interface AuditPayload {
|
||||
result: 'success' | 'failure';
|
||||
actorId: string | null;
|
||||
account: string | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
changes?: unknown;
|
||||
affectedUserCount?: number;
|
||||
affectedUserIds?: string[];
|
||||
reason?: string;
|
||||
traceId: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
@@ -39,7 +49,10 @@ function actionOf(context: ExecutionContext): string | null {
|
||||
|
||||
/**
|
||||
* 全局请求上下文 + @Audit 结构化日志。
|
||||
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
|
||||
*
|
||||
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
|
||||
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
|
||||
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
@@ -80,15 +93,27 @@ export class AuditInterceptor implements NestInterceptor {
|
||||
ctx: RequestContext,
|
||||
action: string,
|
||||
result: AuditPayload['result'],
|
||||
targetId: string | null,
|
||||
defaultTargetId: string | null,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const detail: AuditDetail = ctx.audit ?? {};
|
||||
const payload: AuditPayload = {
|
||||
action,
|
||||
result,
|
||||
actorId: ctx.userId,
|
||||
account: ctx.account,
|
||||
targetId,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
targetType: detail.targetType ?? null,
|
||||
targetId: detail.targetId ?? defaultTargetId,
|
||||
...(detail.before === undefined ? {} : { before: detail.before }),
|
||||
...(detail.after === undefined ? {} : { after: detail.after }),
|
||||
...(detail.changes === undefined ? {} : { changes: detail.changes }),
|
||||
...(detail.affectedUserCount === undefined
|
||||
? {}
|
||||
: { affectedUserCount: detail.affectedUserCount }),
|
||||
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
|
||||
...(detail.reason === undefined ? {} : { reason: detail.reason }),
|
||||
traceId: ctx.traceId,
|
||||
durationMs: Date.now() - ctx.startedAt,
|
||||
...(error === undefined
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import type { ScopeContext } from './scope/scope-context';
|
||||
|
||||
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
|
||||
export interface JwtAccessPayload {
|
||||
sub: string;
|
||||
@@ -22,12 +24,22 @@ export interface AuthContext {
|
||||
status: UserStatus;
|
||||
roles: RoleSummary[];
|
||||
permissions: string[];
|
||||
dataScope: DataScope;
|
||||
/**
|
||||
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
|
||||
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
|
||||
*/
|
||||
dataScopes: DataScope[];
|
||||
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
|
||||
departmentIds: string[];
|
||||
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
|
||||
isSuperAdmin: boolean;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends FastifyRequest {
|
||||
auth?: AuthContext;
|
||||
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
|
||||
scope?: ScopeContext;
|
||||
}
|
||||
|
||||
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PERMISSION_CATALOG, SPECIAL_PERMISSIONS } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PermissionCatalogService } from './permission-catalog.service';
|
||||
|
||||
function makeService(): {
|
||||
service: PermissionCatalogService;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
const prisma = { permission: { upsert } };
|
||||
return {
|
||||
service: new PermissionCatalogService(prisma as never),
|
||||
upsert,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PermissionCatalogService', () => {
|
||||
it('启动同步:30 个权限点逐个幂等 upsert(不删除历史行)', async () => {
|
||||
const { service, upsert } = makeService();
|
||||
const count = await service.sync();
|
||||
expect(count).toBe(30);
|
||||
expect(upsert).toHaveBeenCalledTimes(30);
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { code: 'role:view' },
|
||||
}),
|
||||
);
|
||||
expect(upsert.mock.calls[0]?.[0]).toEqual({
|
||||
where: { code: PERMISSION_CATALOG[0]?.code },
|
||||
update: expect.objectContaining({ module: PERMISSION_CATALOG[0]?.module }),
|
||||
create: expect.objectContaining({ code: PERMISSION_CATALOG[0]?.code }),
|
||||
});
|
||||
});
|
||||
|
||||
it('启动同步失败只告警不阻断(OpenAPI 导出 / 无 DB 命令可继续)', async () => {
|
||||
const { service, upsert } = makeService();
|
||||
upsert.mockRejectedValueOnce(new Error('db unavailable'));
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('权限树:10 模块(与 UI 原型顺序一致)+ 6 特殊权限 + 3 对 SoD', () => {
|
||||
const { service } = makeService();
|
||||
const tree = service.buildTree();
|
||||
expect(tree.totalPermissions).toBe(30);
|
||||
expect(tree.modules).toHaveLength(10);
|
||||
expect(tree.modules.map((module) => module.module)).toEqual([
|
||||
'script',
|
||||
'material',
|
||||
'asset',
|
||||
'delivery',
|
||||
'customer',
|
||||
'customer_group',
|
||||
'identity',
|
||||
'industry',
|
||||
'platform',
|
||||
'audit',
|
||||
]);
|
||||
expect(tree.modules.map((module) => module.moduleLabel)).toEqual([
|
||||
'脚本',
|
||||
'素材',
|
||||
'资产',
|
||||
'导出交付',
|
||||
'客户',
|
||||
'客户组',
|
||||
'组织与角色',
|
||||
'行业包',
|
||||
'平台配置',
|
||||
'审计',
|
||||
]);
|
||||
expect(tree.modules.flatMap((module) => module.permissions)).toHaveLength(30);
|
||||
expect(tree.specialPermissions).toHaveLength(6);
|
||||
expect(tree.specialPermissions.map((item) => item.code).sort()).toEqual(
|
||||
[...SPECIAL_PERMISSIONS].sort(),
|
||||
);
|
||||
expect(tree.sodPairs).toHaveLength(3);
|
||||
expect(tree.sodPairs[0]?.permissions).toEqual(['script:write', 'script:review']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
type PermissionModule,
|
||||
permissionsByModule,
|
||||
type PermissionTree,
|
||||
SOD_PAIRS,
|
||||
SPECIAL_PERMISSIONS,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
|
||||
/**
|
||||
* 权限点注册与同步(A2-BE-02)。
|
||||
*
|
||||
* 唯一事实源是 `@lingxi/shared` 的 `PERMISSION_CATALOG`:启动时把目录幂等 upsert 进
|
||||
* `permissions` 表(只增不删),供管理端检索 / 审计 / 后续统计 join;权限树本身仍从
|
||||
* 代码目录生成(避免「DB 里有、代码里没有」的幽灵权限点)。
|
||||
*
|
||||
* 启动同步失败只告警不阻断(OpenAPI 导出、无 DB 的本地命令不应因此失败);
|
||||
* 生产健康检查与告警另行覆盖。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionCatalogService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger('PermissionCatalog');
|
||||
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const count = await this.sync();
|
||||
this.logger.log(`权限点目录已同步:${String(count)} 个`);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`权限点目录同步失败(不阻断启动):${error instanceof Error ? error.message : '未知错误'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 幂等 upsert:更新元数据、补新增,不删除目录之外的历史行(只增不删) */
|
||||
async sync(): Promise<number> {
|
||||
for (const item of PERMISSION_CATALOG) {
|
||||
await this.prisma.permission.upsert({
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
module: item.module,
|
||||
moduleLabel: item.moduleLabel,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: item.isSpecial,
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
create: {
|
||||
code: item.code,
|
||||
module: item.module,
|
||||
moduleLabel: item.moduleLabel,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: item.isSpecial,
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
return PERMISSION_CATALOG.length;
|
||||
}
|
||||
|
||||
/** 权限树(10 模块 + 特殊权限汇总 + SoD 对):直接由代码目录生成 */
|
||||
buildTree(): PermissionTree {
|
||||
const modules: {
|
||||
module: PermissionModule;
|
||||
moduleLabel: string;
|
||||
sortOrder: number;
|
||||
permissions: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSpecial: boolean;
|
||||
}[];
|
||||
}[] = permissionsByModule().map((group) => ({
|
||||
module: group.module,
|
||||
moduleLabel: group.moduleLabel,
|
||||
sortOrder: group.sortOrder,
|
||||
permissions: group.permissions.map((item) => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: item.isSpecial,
|
||||
})),
|
||||
}));
|
||||
const specialCodeSet = new Set<string>(SPECIAL_PERMISSIONS);
|
||||
const specialPermissions = PERMISSION_CATALOG.filter((item) =>
|
||||
specialCodeSet.has(item.code),
|
||||
).map((item) => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: true,
|
||||
}));
|
||||
return {
|
||||
modules,
|
||||
specialPermissions,
|
||||
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||
id: pair.id,
|
||||
label: pair.label,
|
||||
description: pair.description,
|
||||
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||
})),
|
||||
totalPermissions: PERMISSION_CATALOG.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import type { ScopeContext } from './scope/scope-context';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'auth:public';
|
||||
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
|
||||
@@ -23,3 +24,11 @@ export const CurrentUser = createParamDecorator(
|
||||
return request.auth ?? null;
|
||||
},
|
||||
);
|
||||
|
||||
/** 取 ScopeGuard 解析后的数据范围上下文;未登录 / 未解析返回 null */
|
||||
export const CurrentScope = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): ScopeContext | null => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.scope ?? null;
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
|
||||
@@ -123,9 +124,9 @@ export class PermissionGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = normal.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
|
||||
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
|
||||
if (!normal.some((code) => auth.permissions.includes(code))) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -147,11 +148,10 @@ export class SpecialPermissionGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = special.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
if (!special.some((code) => auth.permissions.includes(code))) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
details: { missing },
|
||||
details: { required: special },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
@@ -159,16 +159,32 @@ export class SpecialPermissionGuard implements CanActivate {
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
|
||||
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
|
||||
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
|
||||
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
|
||||
*
|
||||
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
|
||||
* 公开接口(登录 / 健康检查)不解析。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScopeGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const auth = authOf(context);
|
||||
if (auth !== null && auth.dataScope.type !== 'all') {
|
||||
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(ScopeResolver) private readonly resolver: ScopeResolver,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const auth = authOf(context);
|
||||
if (auth === null) {
|
||||
return true;
|
||||
}
|
||||
const scope = await this.resolver.resolve(auth);
|
||||
request.scope = scope;
|
||||
this.requestContext.patch({ scope });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import {
|
||||
DATA_SCOPE_TYPES,
|
||||
type DataScope,
|
||||
type DataScopeType,
|
||||
isPermissionCode,
|
||||
PERMISSION_CODES,
|
||||
type RoleSummary,
|
||||
type UserStatus,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
@@ -6,17 +14,19 @@ import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import type { AuthContext } from './auth.types';
|
||||
|
||||
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
|
||||
/** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
|
||||
export type UserAuthRecord = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
department: { include: { parent: true } };
|
||||
roles: { include: { role: { include: { permissions: true } } } };
|
||||
department: { include: { parent: true; children: true } };
|
||||
roles: {
|
||||
include: { role: { include: { permissions: true; dataScopes: true } } };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
|
||||
type RoleEntity = UserAuthRecord['roles'][number]['role'];
|
||||
|
||||
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
|
||||
function toRoleSummary(role: RoleEntity): RoleSummary {
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
@@ -26,32 +36,59 @@ function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSumma
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
|
||||
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
|
||||
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
|
||||
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
|
||||
}
|
||||
|
||||
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
|
||||
return user.roles.filter((item) => item.role.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多角色的数据范围声明:同一类型取并集(targetIds 去重排序),按六类固定顺序输出。
|
||||
* 一个角色只允许一种类型(A2 契约),但历史数据可能有多行(customer_groups 多客户组),
|
||||
* 因此按 scope_type 聚合后再跨角色合并。
|
||||
*/
|
||||
export function mergeRoleDataScopes(roles: readonly RoleEntity[]): DataScope[] {
|
||||
const byType = new Map<DataScopeType, Set<string>>();
|
||||
for (const role of roles) {
|
||||
for (const scope of role.dataScopes) {
|
||||
const type = scope.scopeType as DataScopeType;
|
||||
const targets = byType.get(type) ?? new Set<string>();
|
||||
if (scope.targetId !== null) {
|
||||
targets.add(scope.targetId);
|
||||
}
|
||||
byType.set(type, targets);
|
||||
}
|
||||
}
|
||||
const ordered: DataScope[] = [];
|
||||
for (const type of DATA_SCOPE_TYPES) {
|
||||
const targets = byType.get(type);
|
||||
if (targets === undefined) {
|
||||
continue;
|
||||
}
|
||||
ordered.push({
|
||||
type,
|
||||
...(type === 'customer_groups' ? { targetIds: [...targets].sort() } : {}),
|
||||
});
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** 用户部门子树 id(自身在前,最多两级):scope 解析需要「本部门 / 本部门及下级」 */
|
||||
export function departmentSubtreeIds(user: UserAuthRecord): string[] {
|
||||
return [user.department.id, ...user.department.children.map((child) => child.id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
|
||||
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
|
||||
* 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
|
||||
*
|
||||
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
|
||||
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveAuth(userId: string): Promise<AuthContext | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
const user = await this.findAuthRecord(userId);
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -62,25 +99,35 @@ export class PermissionService {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
department: { include: { parent: true, children: true } },
|
||||
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAuthContext(user: UserAuthRecord): AuthContext {
|
||||
const roles = activeRolesOf(user);
|
||||
const permissions = [
|
||||
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
|
||||
].sort();
|
||||
const roleEntities = roles.map((item) => item.role);
|
||||
const isSuperAdmin = roleEntities.some((role) => role.isSystem);
|
||||
const permissions = isSuperAdmin
|
||||
? [...PERMISSION_CODES]
|
||||
: [
|
||||
...new Set(
|
||||
roleEntities
|
||||
.flatMap((role) => role.permissions.map((item) => item.permissionCode))
|
||||
.filter((code) => isPermissionCode(code)),
|
||||
),
|
||||
].sort();
|
||||
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
|
||||
return {
|
||||
userId: user.id,
|
||||
account: user.account,
|
||||
status,
|
||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||
roles: roleEntities.map((role) => toRoleSummary(role)),
|
||||
permissions,
|
||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
||||
dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
|
||||
departmentIds: departmentSubtreeIds(user),
|
||||
isSuperAdmin,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,13 +4,35 @@ import { ulid } from '@lingxi/shared';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedRequest } from './auth.types';
|
||||
import type { ScopeContext } from './scope/scope-context';
|
||||
|
||||
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
|
||||
/**
|
||||
* 变更审计明细(A2-BE-05):由业务 Service 通过 `RequestContextService.patch({ audit })` 写入,
|
||||
* AuditInterceptor 在请求结束时统一输出结构化 Pino 日志(A5 再落审计表)。
|
||||
*
|
||||
* 权限 / 角色类变更必须带:操作人(context.userId)、IP(context.ip)、
|
||||
* 变更前后值(before / after)、影响用户数(affectedUserCount / affectedUserIds)。
|
||||
*/
|
||||
export interface AuditDetail {
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
affectedUserCount?: number;
|
||||
affectedUserIds?: string[];
|
||||
reason?: string;
|
||||
/** 追加的变更摘要(如权限点 diff),与 before / after 并存 */
|
||||
changes?: unknown;
|
||||
}
|
||||
|
||||
/** 单次请求上下文:trace_id / 当前用户 / 权限快照 / 数据范围 / 审计明细 */
|
||||
export interface RequestContext {
|
||||
traceId: string;
|
||||
userId: string | null;
|
||||
account: string | null;
|
||||
permissions: string[] | null;
|
||||
scope: ScopeContext | null;
|
||||
audit: AuditDetail | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
method: string;
|
||||
@@ -32,6 +54,8 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
||||
userId: auth?.userId ?? null,
|
||||
account: auth?.account ?? null,
|
||||
permissions: auth?.permissions ?? null,
|
||||
scope: request.scope ?? null,
|
||||
audit: null,
|
||||
ip: request.ip.length > 0 ? request.ip : null,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
method: request.method,
|
||||
@@ -42,7 +66,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
|
||||
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
|
||||
* 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
@@ -63,4 +87,13 @@ export class RequestContextService {
|
||||
}
|
||||
Object.assign(store, partial);
|
||||
}
|
||||
|
||||
/** 追加审计明细(多次 patch 时浅合并,便于业务 Service 逐步补充) */
|
||||
patchAudit(detail: Partial<AuditDetail>): void {
|
||||
const store = this.storage.getStore();
|
||||
if (store === undefined) {
|
||||
return;
|
||||
}
|
||||
store.audit = { ...(store.audit ?? {}), ...detail };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 客户目录端口(A2-BE-04 / 技术架构 §5.5)。
|
||||
*
|
||||
* 数据范围的可见客户集合由「客户目录」解析;A2 只有角色与范围声明,客户 / 客户组 /
|
||||
* 客户负责人属 A3(client 模块)。为避免 A2 依赖未建表的客户模块,这里定义可替换端口:
|
||||
* - A2 注册 `EmptyCustomerDirectory`:所有方法返回空集(不越权,不需要降级);
|
||||
* - A3 落地后用真实实现替换 provider(注入 token 不变),ScopeResolver 无需修改。
|
||||
*
|
||||
* 约定:端口只读;返回客户 id 列表,由 ScopeResolver 做去重与并集。
|
||||
*/
|
||||
export const CUSTOMER_DIRECTORY = Symbol('CUSTOMER_DIRECTORY');
|
||||
|
||||
export interface CustomerDirectory {
|
||||
/** 「负责客户」范围:客户负责人 = 本人 */
|
||||
findOwnedCustomerIds(userId: string): Promise<string[]>;
|
||||
/** 「指定客户组」范围:客户组下全部客户 */
|
||||
findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]>;
|
||||
/** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */
|
||||
findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]>;
|
||||
/** 「本人创建」范围:创建人 = 本人 */
|
||||
findCreatedCustomerIds(userId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。
|
||||
* 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmptyCustomerDirectory implements CustomerDirectory {
|
||||
findOwnedCustomerIds(_userId: string): Promise<string[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise<string[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise<string[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
findCreatedCustomerIds(_userId: string): Promise<string[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type DataScope, ErrorCode } from '@lingxi/shared';
|
||||
|
||||
import { AppException } from '../../errors/app-exception';
|
||||
|
||||
/**
|
||||
* 请求作用域上下文(技术架构 §5.5):登录用户 + 多角色并集后的可见客户集合。
|
||||
*
|
||||
* 解析顺序(技术架构 §7.1):
|
||||
* 1. JWT / 账号状态由 JwtAuthGuard 校验;
|
||||
* 2. 角色数据范围 → 客户集合(六类,CustomerDirectory 端口解析);
|
||||
* 3. 多角色取并集;任一角色为「全部客户」即全部;
|
||||
* 4. 单个资源请求显式指定客户时,必须落在集合内。
|
||||
*
|
||||
* 失败处理:解析失败一律 403(40302),**不降级为全部客户**。
|
||||
*/
|
||||
export interface ScopeContext {
|
||||
userId: string;
|
||||
/** 是否拥有 is_system 超管角色(隐式全部数据范围) */
|
||||
isSuperAdmin: boolean;
|
||||
/** 解析后的数据范围(每个元素带 customerIds;超管为 [{ type: 'all' }]) */
|
||||
dataScopes: DataScope[];
|
||||
/** null = 全部客户可见;否则为可见客户 id 集合(已排序去重;A3 前为空数组) */
|
||||
customerIds: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository 客户维度过滤参数(Prisma where 片段)。
|
||||
* 使用方必须把返回值展开进 where:`where: { ...customerScopeWhere(ctx), ... }`,
|
||||
* 禁止「先查全量再内存过滤」(AGENTS §3.5)。
|
||||
*/
|
||||
export interface CustomerScopeWhere {
|
||||
customerId?: { in: string[] };
|
||||
}
|
||||
|
||||
/** 是否可见全部客户 */
|
||||
export function isAllCustomers(ctx: ScopeContext): boolean {
|
||||
return ctx.customerIds === null;
|
||||
}
|
||||
|
||||
/** 构造客户维度强制过滤片段:全部客户返回空对象,受限范围返回 `{ customerId: { in } }` */
|
||||
export function customerScopeWhere(ctx: ScopeContext): CustomerScopeWhere {
|
||||
return ctx.customerIds === null ? {} : { customerId: { in: [...ctx.customerIds] } };
|
||||
}
|
||||
|
||||
/** 判断单个客户是否在范围内(用于单条资源的 404 / 403 语义选择) */
|
||||
export function isCustomerVisible(ctx: ScopeContext, customerId: string): boolean {
|
||||
return ctx.customerIds === null || ctx.customerIds.includes(customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言对指定客户有写权限;范围外抛 40302(不泄露资源是否存在)。
|
||||
* 单条**读**接口按 §10.3 应返回 404(避免探测存在性),调用方可先用 isCustomerVisible 判定。
|
||||
*/
|
||||
export function assertCustomerAccess(ctx: ScopeContext, customerId: string): void {
|
||||
if (!isCustomerVisible(ctx, customerId)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
message: '客户不在当前数据范围之内',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 断言存在客户维度上下文(Repository 方法入口守卫,防止漏注入) */
|
||||
export function requireScopeContext(ctx: ScopeContext | null | undefined): ScopeContext {
|
||||
if (ctx === null || ctx === undefined) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
message: '缺少数据范围上下文,已拒绝访问',
|
||||
});
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { DATA_SCOPE_TYPES, type DataScope, type DataScopeType, ErrorCode } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../errors/app-exception';
|
||||
import type { AuthContext } from '../auth.types';
|
||||
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
|
||||
import type { ScopeContext } from './scope-context';
|
||||
|
||||
/** 范围声明去重后的内部结构:一个角色一种类型,但多角色可能带来同类型的多个 targetIds */
|
||||
interface ScopeDeclaration {
|
||||
type: DataScopeType;
|
||||
targetIds: string[];
|
||||
}
|
||||
|
||||
function mergeDeclarations(scopes: readonly DataScope[]): ScopeDeclaration[] {
|
||||
const byType = new Map<DataScopeType, Set<string>>();
|
||||
for (const scope of scopes) {
|
||||
const existing = byType.get(scope.type) ?? new Set<string>();
|
||||
for (const targetId of scope.targetIds ?? []) {
|
||||
existing.add(targetId);
|
||||
}
|
||||
byType.set(scope.type, existing);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, targetIdSet]) => ({
|
||||
type,
|
||||
targetIds: [...targetIdSet].sort(),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) => DATA_SCOPE_TYPES.indexOf(left.type) - DATA_SCOPE_TYPES.indexOf(right.type),
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueSorted(ids: readonly string[]): string[] {
|
||||
return [...new Set(ids)].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围解析器(A2-BE-04):把角色声明的六类范围解析为 `ScopeContext`。
|
||||
*
|
||||
* - 超管(is_system 角色)隐式全集:`[{ type: 'all' }]`;
|
||||
* - 任一角色为「全部客户」→ 并集为全部客户;
|
||||
* - 多个角色取并集(customerIds 去重排序);
|
||||
* - 没有任何范围声明时按最小权限「本人创建」解析,不放行全部;
|
||||
* - 端口调用失败 → 40302(不降级)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScopeResolver {
|
||||
constructor(@Inject(CUSTOMER_DIRECTORY) private readonly customers: CustomerDirectory) {}
|
||||
|
||||
async resolve(
|
||||
auth: Pick<AuthContext, 'userId' | 'isSuperAdmin' | 'dataScopes' | 'departmentIds'>,
|
||||
): Promise<ScopeContext> {
|
||||
if (auth.isSuperAdmin) {
|
||||
return {
|
||||
userId: auth.userId,
|
||||
isSuperAdmin: true,
|
||||
dataScopes: [{ type: 'all' }],
|
||||
customerIds: null,
|
||||
};
|
||||
}
|
||||
const declarations =
|
||||
mergeDeclarations(auth.dataScopes).length > 0
|
||||
? mergeDeclarations(auth.dataScopes)
|
||||
: [{ type: 'created_by_me' as const, targetIds: [] }];
|
||||
if (declarations.some((declaration) => declaration.type === 'all')) {
|
||||
return {
|
||||
userId: auth.userId,
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [{ type: 'all' }],
|
||||
customerIds: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await this.buildScopedContext(auth, declarations);
|
||||
} catch (error) {
|
||||
if (error instanceof AppException) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppException({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
message: '数据范围解析失败,已拒绝访问',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async buildScopedContext(
|
||||
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
|
||||
declarations: readonly ScopeDeclaration[],
|
||||
): Promise<ScopeContext> {
|
||||
const union = new Set<string>();
|
||||
const resolved: DataScope[] = [];
|
||||
for (const declaration of declarations) {
|
||||
const ids = uniqueSorted(await this.customerIdsFor(declaration, auth));
|
||||
for (const id of ids) {
|
||||
union.add(id);
|
||||
}
|
||||
resolved.push({
|
||||
type: declaration.type,
|
||||
...(declaration.targetIds.length > 0 ? { targetIds: declaration.targetIds } : {}),
|
||||
customerIds: ids,
|
||||
});
|
||||
}
|
||||
return {
|
||||
userId: auth.userId,
|
||||
isSuperAdmin: false,
|
||||
dataScopes: resolved,
|
||||
customerIds: [...union].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
private async customerIdsFor(
|
||||
declaration: ScopeDeclaration,
|
||||
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
|
||||
): Promise<string[]> {
|
||||
switch (declaration.type) {
|
||||
case 'all':
|
||||
return [];
|
||||
case 'customer_groups':
|
||||
return this.customers.findCustomerGroupCustomerIds(declaration.targetIds);
|
||||
case 'owned_customers':
|
||||
return this.customers.findOwnedCustomerIds(auth.userId);
|
||||
case 'created_by_me':
|
||||
return this.customers.findCreatedCustomerIds(auth.userId);
|
||||
case 'department': {
|
||||
const ownDepartmentId = auth.departmentIds[0];
|
||||
return ownDepartmentId === undefined
|
||||
? []
|
||||
: this.customers.findDepartmentCustomerIds([ownDepartmentId]);
|
||||
}
|
||||
case 'department_and_children':
|
||||
return this.customers.findDepartmentCustomerIds(auth.departmentIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../errors/app-exception';
|
||||
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
|
||||
import {
|
||||
assertCustomerAccess,
|
||||
customerScopeWhere,
|
||||
isAllCustomers,
|
||||
isCustomerVisible,
|
||||
requireScopeContext,
|
||||
type ScopeContext,
|
||||
} from './scope-context';
|
||||
import { ScopeResolver } from './scope-resolver.service';
|
||||
|
||||
function directoryMock(): {
|
||||
directory: CustomerDirectory;
|
||||
owned: ReturnType<typeof vi.fn>;
|
||||
groups: ReturnType<typeof vi.fn>;
|
||||
departments: ReturnType<typeof vi.fn>;
|
||||
created: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const owned = vi.fn().mockResolvedValue(['c-owned']);
|
||||
const groups = vi.fn().mockResolvedValue(['c-group-1']);
|
||||
const departments = vi.fn().mockResolvedValue(['c-dept']);
|
||||
const created = vi.fn().mockResolvedValue(['c-created']);
|
||||
return {
|
||||
directory: {
|
||||
findOwnedCustomerIds: owned,
|
||||
findCustomerGroupCustomerIds: groups,
|
||||
findDepartmentCustomerIds: departments,
|
||||
findCreatedCustomerIds: created,
|
||||
},
|
||||
owned,
|
||||
groups,
|
||||
departments,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
function contextOf(overrides: Partial<ScopeContext> = {}): ScopeContext {
|
||||
return {
|
||||
userId: 'u1',
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [],
|
||||
customerIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('ScopeResolver', () => {
|
||||
const baseAuth = {
|
||||
userId: 'u1',
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [],
|
||||
departmentIds: ['d1', 'd1-child'],
|
||||
};
|
||||
|
||||
it('超管隐式全集:[{ type: all }],不调用客户目录', async () => {
|
||||
const { directory, owned } = directoryMock();
|
||||
const resolver = new ScopeResolver(directory);
|
||||
const scope = await resolver.resolve({ ...baseAuth, isSuperAdmin: true });
|
||||
expect(scope.customerIds).toBeNull();
|
||||
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
|
||||
expect(owned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('任一角色为全部客户 → 并集为全部客户', async () => {
|
||||
const { directory } = directoryMock();
|
||||
const resolver = new ScopeResolver(directory);
|
||||
const scope = await resolver.resolve({
|
||||
...baseAuth,
|
||||
dataScopes: [{ type: 'owned_customers' }, { type: 'all' }],
|
||||
});
|
||||
expect(scope.customerIds).toBeNull();
|
||||
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
|
||||
});
|
||||
|
||||
it('多角色并集:同一类型 targetIds 合并、customerIds 去重排序、按六类固定顺序输出', async () => {
|
||||
const { directory, groups, departments } = directoryMock();
|
||||
departments.mockResolvedValue(['c-dept', 'c-dept-2']);
|
||||
groups.mockResolvedValue(['c-group-1', 'c-group-2']);
|
||||
const resolver = new ScopeResolver(directory);
|
||||
const scope = await resolver.resolve({
|
||||
...baseAuth,
|
||||
dataScopes: [
|
||||
{ type: 'customer_groups', targetIds: ['g2'] },
|
||||
{ type: 'department_and_children' },
|
||||
{ type: 'customer_groups', targetIds: ['g1'] },
|
||||
],
|
||||
});
|
||||
expect(scope.customerIds).toEqual(['c-dept', 'c-dept-2', 'c-group-1', 'c-group-2']);
|
||||
expect(scope.dataScopes).toEqual([
|
||||
{ type: 'department_and_children', customerIds: ['c-dept', 'c-dept-2'] },
|
||||
{
|
||||
type: 'customer_groups',
|
||||
targetIds: ['g1', 'g2'],
|
||||
customerIds: ['c-group-1', 'c-group-2'],
|
||||
},
|
||||
]);
|
||||
expect(departments).toHaveBeenCalledWith(['d1', 'd1-child']);
|
||||
});
|
||||
|
||||
it('「本部门」只用用户本部门;「指定客户组」透传 targetIds;「负责客户」「本人创建」按用户解析', async () => {
|
||||
const { directory, groups, departments, owned, created } = directoryMock();
|
||||
const resolver = new ScopeResolver(directory);
|
||||
const scope = await resolver.resolve({
|
||||
...baseAuth,
|
||||
dataScopes: [{ type: 'department' }, { type: 'customer_groups', targetIds: ['g1'] }],
|
||||
});
|
||||
expect(departments).toHaveBeenCalledWith(['d1']);
|
||||
expect(groups).toHaveBeenCalledWith(['g1']);
|
||||
expect(scope.dataScopes[0]?.type).toBe('department');
|
||||
|
||||
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] });
|
||||
expect(owned).toHaveBeenCalledWith('u1');
|
||||
created.mockResolvedValue([]);
|
||||
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'created_by_me' }] });
|
||||
expect(created).toHaveBeenCalledWith('u1');
|
||||
});
|
||||
|
||||
it('没有任何范围声明 → 按最小权限「本人创建」解析,不分全量', async () => {
|
||||
const { directory } = directoryMock();
|
||||
const resolver = new ScopeResolver(directory);
|
||||
const scope = await resolver.resolve({ ...baseAuth, dataScopes: [] });
|
||||
expect(scope.customerIds).toEqual(['c-created']);
|
||||
expect(scope.dataScopes[0]?.type).toBe('created_by_me');
|
||||
});
|
||||
|
||||
it('客户目录端口异常 → 40302,不降级为全部客户', async () => {
|
||||
const { directory, owned } = directoryMock();
|
||||
owned.mockRejectedValueOnce(new Error('db down'));
|
||||
const resolver = new ScopeResolver(directory);
|
||||
await expectRejected(
|
||||
resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] }),
|
||||
ErrorCode.OUT_OF_SCOPE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope-context 帮助函数', () => {
|
||||
it('customerScopeWhere:全部客户返回空片段,受限范围返回 customerId IN', () => {
|
||||
expect(customerScopeWhere(contextOf({ customerIds: null }))).toEqual({});
|
||||
expect(customerScopeWhere(contextOf({ customerIds: ['c1', 'c2'] }))).toEqual({
|
||||
customerId: { in: ['c1', 'c2'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('isAllCustomers / isCustomerVisible', () => {
|
||||
expect(isAllCustomers(contextOf({ customerIds: null }))).toBe(true);
|
||||
expect(isAllCustomers(contextOf())).toBe(false);
|
||||
expect(isCustomerVisible(contextOf({ customerIds: null }), 'any')).toBe(true);
|
||||
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c1')).toBe(true);
|
||||
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c2')).toBe(false);
|
||||
});
|
||||
|
||||
it('assertCustomerAccess:范围外抛 40302;requireScopeContext 缺上下文抛 40302', () => {
|
||||
expect(() => {
|
||||
assertCustomerAccess(contextOf({ customerIds: null }), 'c1');
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c1');
|
||||
}).not.toThrow();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c2');
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AppException);
|
||||
expect((thrown as AppException).code).toBe(ErrorCode.OUT_OF_SCOPE);
|
||||
expect(() => requireScopeContext(null)).toThrow(AppException);
|
||||
expect(requireScopeContext(contextOf()).userId).toBe('u1');
|
||||
});
|
||||
|
||||
it('CUSTOMER_DIRECTORY 端口 token 唯一', () => {
|
||||
expect(typeof CUSTOMER_DIRECTORY).toBe('symbol');
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,20 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { AuditInterceptor } from './audit.interceptor';
|
||||
import { PermissionCatalogService } from './catalog/permission-catalog.service';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
|
||||
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
/**
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*
|
||||
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
|
||||
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -18,18 +24,27 @@ import { TokenService } from './token.service';
|
||||
providers: [
|
||||
RequestContextService,
|
||||
PermissionService,
|
||||
PermissionCatalogService,
|
||||
PermissionGuard,
|
||||
ScopeResolver,
|
||||
TokenService,
|
||||
AuditInterceptor,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
SpecialPermissionGuard,
|
||||
ScopeGuard,
|
||||
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
|
||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: ScopeGuard },
|
||||
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
|
||||
],
|
||||
exports: [RequestContextService, PermissionService, TokenService],
|
||||
exports: [
|
||||
RequestContextService,
|
||||
PermissionService,
|
||||
PermissionCatalogService,
|
||||
ScopeResolver,
|
||||
TokenService,
|
||||
],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -11,8 +11,10 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import type { UserAuthRecord } from './permission.service';
|
||||
import { PermissionService, resolveDataScope } from './permission.service';
|
||||
import { PermissionService } from './permission.service';
|
||||
import type { RequestContextService } from './request-context';
|
||||
import type { ScopeContext } from './scope/scope-context';
|
||||
import type { ScopeResolver } from './scope/scope-resolver.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const jwt = new JwtService({});
|
||||
@@ -41,7 +43,9 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
dataScopes: [{ type: 'department_and_children' }],
|
||||
departmentIds: ['d1'],
|
||||
isSuperAdmin: false,
|
||||
mustChangePassword: false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -110,6 +114,7 @@ function authRecord(
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
roles: roleCodes.map((code, index) => ({
|
||||
userId: 'u1',
|
||||
@@ -128,6 +133,7 @@ function authRecord(
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
|
||||
],
|
||||
dataScopes: [],
|
||||
},
|
||||
})),
|
||||
};
|
||||
@@ -170,19 +176,31 @@ describe('TokenService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionService / resolveDataScope', () => {
|
||||
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
|
||||
expect(resolveDataScope(['super_admin']).type).toBe('all');
|
||||
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
|
||||
});
|
||||
|
||||
describe('PermissionService', () => {
|
||||
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
|
||||
const service = new PermissionService({} as never);
|
||||
const context = service.toAuthContext(authRecord());
|
||||
expect(context.permissions).toEqual(['user:manage', 'user:view']);
|
||||
expect(context.status).toBe('active');
|
||||
expect(context.roles).toHaveLength(1);
|
||||
expect(context.dataScope.type).toBe('all');
|
||||
expect(context.isSuperAdmin).toBe(false);
|
||||
expect(context.dataScopes).toEqual([]);
|
||||
expect(context.departmentIds).toEqual(['d1']);
|
||||
});
|
||||
|
||||
it('超管隐式全集:权限点 / 特殊权限不依赖 role_permissions', () => {
|
||||
const record = authRecord('ACTIVE', ['super_admin']);
|
||||
const role = record.roles[0];
|
||||
if (role === undefined) {
|
||||
throw new Error('fixture 缺少角色');
|
||||
}
|
||||
role.role.permissions = [];
|
||||
const service = new PermissionService({} as never);
|
||||
const context = service.toAuthContext(record);
|
||||
expect(context.isSuperAdmin).toBe(true);
|
||||
expect(context.permissions).toHaveLength(30);
|
||||
expect(context.permissions).toContain('audit:export');
|
||||
expect(context.dataScopes).toEqual([{ type: 'all' }]);
|
||||
});
|
||||
|
||||
it('resolveAuth 用户不存在返回 null', async () => {
|
||||
@@ -198,7 +216,7 @@ describe('PermissionService / resolveDataScope', () => {
|
||||
const service = new PermissionService(prisma as never);
|
||||
const context = await service.resolveAuth('u1');
|
||||
expect(context?.userId).toBe('u1');
|
||||
expect(context?.dataScope.type).toBe('department_and_children');
|
||||
expect(context?.isSuperAdmin).toBe(false);
|
||||
expect(context?.roles[0]?.code).toBe('designer');
|
||||
});
|
||||
});
|
||||
@@ -299,11 +317,26 @@ describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
|
||||
expect(granted.canActivate(contextOf(request))).toBe(true);
|
||||
});
|
||||
|
||||
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
|
||||
it('未登录时普通守卫返回 40101;ScopeGuard 解析并注入 scope', async () => {
|
||||
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
|
||||
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
|
||||
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
|
||||
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
|
||||
|
||||
const scope: ScopeContext = {
|
||||
userId: 'u1',
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [{ type: 'owned_customers', customerIds: [] }],
|
||||
customerIds: [],
|
||||
};
|
||||
const resolver = { resolve: vi.fn().mockResolvedValue(scope) } as unknown as ScopeResolver;
|
||||
const scopeGuard = new ScopeGuard(reflectorFor({}) as never, resolver, {
|
||||
patch: vi.fn(),
|
||||
} as unknown as RequestContextService);
|
||||
const scopedRequest = { auth } as AuthenticatedRequest;
|
||||
await expect(scopeGuard.canActivate(contextOf(scopedRequest))).resolves.toBe(true);
|
||||
expect(scopedRequest.scope).toEqual(scope);
|
||||
|
||||
// 未登录(无 auth)直接放行,由 JwtAuthGuard 负责 401
|
||||
await expect(scopeGuard.canActivate(contextOf({}))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('@Audit 元数据可读取', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PermissionService } from '../../common/security/permission.service';
|
||||
import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||
import { TokenService } from '../../common/security/token.service';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { testConfig } from '../../testing/test-app';
|
||||
@@ -102,16 +103,19 @@ function harness(): Harness {
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: ['user:manage'],
|
||||
dataScope: { type: 'all' },
|
||||
dataScopes: [{ type: 'all' }],
|
||||
departmentIds: ['d1'],
|
||||
isSuperAdmin: true,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
};
|
||||
const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) };
|
||||
const me = {
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
user: { id: 'u1' },
|
||||
roles: [],
|
||||
permissions: [],
|
||||
dataScope: { type: 'all' },
|
||||
dataScopes: [{ type: 'all' }],
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
};
|
||||
@@ -122,6 +126,7 @@ function harness(): Harness {
|
||||
passwords,
|
||||
failures as unknown as LoginFailureService,
|
||||
permissions as unknown as PermissionService,
|
||||
scopes as unknown as ScopeResolver,
|
||||
me as unknown as MeService,
|
||||
);
|
||||
return { service, prisma, failures, passwords, permissions, me };
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client';
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
||||
import { PermissionService } from '../../common/security/permission.service';
|
||||
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||
import { TokenService } from '../../common/security/token.service';
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
@@ -33,6 +34,7 @@ export class AuthService {
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
|
||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
|
||||
@Inject(MeService) private readonly me: MeService,
|
||||
) {}
|
||||
|
||||
@@ -61,8 +63,12 @@ export class AuthService {
|
||||
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
|
||||
});
|
||||
const auth = await this.permissions.resolveAuth(user.id);
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
||||
}
|
||||
const scope = await this.scopes.resolve(auth);
|
||||
const { pair } = await this.createTokenPair(user.id, null, meta);
|
||||
const me = await this.me.getMe(user.id, auth?.permissions ?? []);
|
||||
const me = await this.me.getMe(user.id, auth.permissions, scope);
|
||||
return { ...pair, me };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,18 @@ interface FixtureUser {
|
||||
account: string;
|
||||
}
|
||||
|
||||
interface MeBody {
|
||||
permissions: string[];
|
||||
dataScopes: { type: string; customerIds?: string[] }[];
|
||||
user: { id: string; phone?: string };
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
interface TokenBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
|
||||
me: MeBody;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
|
||||
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
||||
}
|
||||
|
||||
interface JsonRequestResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
async function callJson<T>(
|
||||
app: CreatedApiApp['app'],
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<JsonRequestResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
return { status: response.statusCode, body: response.json<T>() };
|
||||
}
|
||||
|
||||
const ROLE_NAME_PREFIX = 'IT-角色-';
|
||||
|
||||
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
|
||||
const createdRoleIds: string[] = [];
|
||||
|
||||
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
|
||||
async function createTestRole(input: {
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
isSystem?: boolean;
|
||||
dataScope?: { type: string; targetIds?: string[] };
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
}): Promise<{ id: string; name: string }> {
|
||||
const id = ulid();
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `it_role_${id.toLowerCase()}`,
|
||||
name: `${ROLE_NAME_PREFIX}${input.name}`,
|
||||
description: '集成测试角色',
|
||||
isSystem: input.isSystem ?? false,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
permissions: {
|
||||
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
|
||||
},
|
||||
dataScopes: {
|
||||
create: [
|
||||
{
|
||||
id: ulid(),
|
||||
scopeType: input.dataScope?.type ?? 'created_by_me',
|
||||
targetId: input.dataScope?.targetIds?.[0] ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(role.id);
|
||||
return { id: role.id, name: role.name };
|
||||
}
|
||||
|
||||
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `集成测试${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: `138${String(Date.now()).slice(-8)}`,
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
return { id, account };
|
||||
}
|
||||
|
||||
describe('identity 集成(真实 PG / Redis)', () => {
|
||||
let created: CreatedApiApp;
|
||||
let manager: FixtureUser;
|
||||
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||
manager = await createFixtureUser('manager', 'platform_admin');
|
||||
viewer = await createFixtureUser('viewer', 'designer');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
await created.app.close();
|
||||
});
|
||||
@@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
expect(roles.statusCode).toBe(200);
|
||||
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const response = await callJson<{
|
||||
modules: { module: string; moduleLabel: string }[];
|
||||
specialPermissions: { code: string }[];
|
||||
sodPairs: { id: string }[];
|
||||
totalPermissions: number;
|
||||
}>(created.app, 'GET', '/permissions', body.accessToken);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totalPermissions).toBe(30);
|
||||
expect(response.body.modules.map((module) => module.module)).toEqual([
|
||||
'script',
|
||||
'material',
|
||||
'asset',
|
||||
'delivery',
|
||||
'customer',
|
||||
'customer_group',
|
||||
'identity',
|
||||
'industry',
|
||||
'platform',
|
||||
'audit',
|
||||
]);
|
||||
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
|
||||
expect(response.body.specialPermissions).toHaveLength(6);
|
||||
expect(response.body.sodPairs).toHaveLength(3);
|
||||
|
||||
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
|
||||
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
|
||||
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
|
||||
const designerLogin = await login(created.app, designerOnly.account);
|
||||
const denied = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/permissions',
|
||||
designerLogin.body.accessToken,
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
});
|
||||
|
||||
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const response = await callJson<{
|
||||
items: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
isSystem: boolean;
|
||||
permissionCount: number;
|
||||
specialPermissionCount: number;
|
||||
userCount: number;
|
||||
dataScope: { type: string };
|
||||
}[];
|
||||
}>(created.app, 'GET', '/roles', body.accessToken);
|
||||
expect(response.status).toBe(200);
|
||||
const codes = response.body.items.map((item) => item.code);
|
||||
expect(codes).not.toContain('review_lead');
|
||||
expect(codes).toContain('material_operator');
|
||||
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
|
||||
expect(superAdmin?.isSystem).toBe(true);
|
||||
expect(superAdmin?.permissionCount).toBe(24);
|
||||
expect(superAdmin?.specialPermissionCount).toBe(6);
|
||||
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
|
||||
|
||||
const all = await callJson<{ items: { code: string }[] }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/roles?status=all',
|
||||
body.accessToken,
|
||||
);
|
||||
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
|
||||
|
||||
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
|
||||
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
|
||||
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
|
||||
const selectorLogin = await login(created.app, selector.account);
|
||||
const selectorRoles = await callJson<{ items: { code: string }[] }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/roles',
|
||||
selectorLogin.body.accessToken,
|
||||
);
|
||||
expect(selectorRoles.status).toBe(200);
|
||||
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
|
||||
const selectorPermissions = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/permissions',
|
||||
selectorLogin.body.accessToken,
|
||||
);
|
||||
expect(selectorPermissions.status).toBe(403);
|
||||
});
|
||||
|
||||
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const createdRole = await callJson<{
|
||||
role: {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
permissions: string[];
|
||||
specialPermissions: string[];
|
||||
dataScope: { type: string };
|
||||
boundUsers: unknown[];
|
||||
};
|
||||
affectedUserCount: number;
|
||||
warnings: string[];
|
||||
}>(created.app, 'POST', '/roles', body.accessToken, {
|
||||
name: `${ROLE_NAME_PREFIX}内容运营`,
|
||||
permissions: ['script:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
expect(createdRole.status).toBe(201);
|
||||
createdRoleIds.push(createdRole.body.role.id);
|
||||
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||||
expect(createdRole.body.role.permissions).toEqual(['script:read']);
|
||||
expect(createdRole.body.role.specialPermissions).toEqual([]);
|
||||
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
|
||||
expect(createdRole.body.affectedUserCount).toBe(0);
|
||||
|
||||
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
|
||||
created.app,
|
||||
'POST',
|
||||
'/roles',
|
||||
body.accessToken,
|
||||
{
|
||||
name: `${ROLE_NAME_PREFIX}互斥冲突`,
|
||||
permissions: ['script:write', 'script:review'],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
);
|
||||
expect(sod.status).toBe(403);
|
||||
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
|
||||
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
|
||||
|
||||
const specialInNormal = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'POST',
|
||||
'/roles',
|
||||
body.accessToken,
|
||||
{
|
||||
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
|
||||
permissions: ['delivery:export'],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
);
|
||||
expect(specialInNormal.status).toBe(400);
|
||||
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const role = await createTestRole({
|
||||
name: '待编辑',
|
||||
permissions: ['script:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
|
||||
|
||||
const patched = await callJson<{
|
||||
role: {
|
||||
permissions: string[];
|
||||
specialPermissions: string[];
|
||||
dataScope: { type: string };
|
||||
status: string;
|
||||
};
|
||||
affectedUserCount: number;
|
||||
warnings: string[];
|
||||
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
|
||||
permissions: ['script:read', 'user:view'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
});
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
|
||||
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
|
||||
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
|
||||
expect(patched.body.affectedUserCount).toBe(1);
|
||||
|
||||
const outbox = await prisma.outboxEvent.findFirst({
|
||||
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
});
|
||||
expect(outbox).not.toBeNull();
|
||||
const payload = outbox?.payloadJson as {
|
||||
roleId: string;
|
||||
affectedUserIds: string[];
|
||||
changes: { permissions: { added: string[]; removed: string[] } };
|
||||
};
|
||||
expect(payload.roleId).toBe(role.id);
|
||||
expect(payload.affectedUserIds).toEqual([boundUser.id]);
|
||||
expect(payload.changes.permissions.added).toEqual(['user:view']);
|
||||
|
||||
// 系统角色保护:改权限 40903,仅显示名可改
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
const protectedUpdate = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
{ permissions: ['user:view'] },
|
||||
);
|
||||
expect(protectedUpdate.status).toBe(409);
|
||||
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
const renamed = await callJson<{ role: { name: string } }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
{ name: '超级管理员' },
|
||||
);
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(renamed.body.role.name).toBe('超级管理员');
|
||||
});
|
||||
|
||||
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
const systemDelete = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'DELETE',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
);
|
||||
expect(systemDelete.status).toBe(409);
|
||||
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const bound = await createTestRole({ name: '有绑定' });
|
||||
await createFixtureUserWithRoleId('role-bound', bound.id);
|
||||
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
|
||||
created.app,
|
||||
'DELETE',
|
||||
`/roles/${bound.id}`,
|
||||
body.accessToken,
|
||||
);
|
||||
expect(boundDelete.status).toBe(409);
|
||||
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
expect(boundDelete.body.details.userCount).toBe(1);
|
||||
|
||||
const free = await createTestRole({ name: '无绑定' });
|
||||
const freeDelete = await created.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `${API}/roles/${free.id}`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(freeDelete.statusCode).toBe(204);
|
||||
const gone = await prisma.role.findUnique({ where: { id: free.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
|
||||
const user = await createFixtureUserWithRoleId('role-disable', role.id);
|
||||
const userLogin = await login(created.app, user.account);
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||
|
||||
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${role.id}`,
|
||||
body.accessToken,
|
||||
{ status: 'disabled', reason: '集成测试停用' },
|
||||
);
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(disabled.body.role.status).toBe('disabled');
|
||||
expect(disabled.body.affectedUserCount).toBe(1);
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
|
||||
|
||||
const enabled = await callJson<{ role: { status: string } }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${role.id}`,
|
||||
body.accessToken,
|
||||
{ status: 'enabled' },
|
||||
);
|
||||
expect(enabled.body.role.status).toBe('active');
|
||||
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
|
||||
const adminLogin = await login(created.app, manager.account);
|
||||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||||
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
|
||||
|
||||
const ownedRole = await createTestRole({
|
||||
name: '负责客户范围',
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
});
|
||||
const deptRole = await createTestRole({
|
||||
name: '部门范围',
|
||||
permissions: ['customer:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
|
||||
const userLogin = await login(created.app, user.account);
|
||||
expect(userLogin.status).toBe(200);
|
||||
const scopes = userLogin.body.me.dataScopes;
|
||||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||
for (const scope of scopes) {
|
||||
expect(scope.customerIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { LoginFailureService } from './login-failure.service';
|
||||
import { MeController } from './me.controller';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
import { PermissionsController } from './permissions.controller';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesService } from './roles.service';
|
||||
import { UsersController } from './users.controller';
|
||||
@@ -24,6 +25,7 @@ import { UsersService } from './users.service';
|
||||
UsersController,
|
||||
DepartmentsController,
|
||||
RolesController,
|
||||
PermissionsController,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -2,17 +2,19 @@ import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||
import type { RequestContextService } from '../../common/security/request-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { LoginFailureService } from './login-failure.service';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
import { RolesService } from './roles.service';
|
||||
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||
import { maskPhone, toUserDetail, toUserSummary } from './user.mapper';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
|
||||
const base: UserWithRelations = {
|
||||
function userFixture(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
|
||||
const base: UserAuthRecord = {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
@@ -51,6 +53,7 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
@@ -66,6 +69,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
permissions: [],
|
||||
dataScopes: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -81,6 +86,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
||||
status: 'DISABLED',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
permissions: [],
|
||||
dataScopes: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -357,43 +364,79 @@ describe('DepartmentsService', () => {
|
||||
});
|
||||
|
||||
describe('MeService / RolesService', () => {
|
||||
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
|
||||
const scope = {
|
||||
userId: 'u1',
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [{ type: 'department_and_children' as const, customerIds: [] }],
|
||||
customerIds: [] as string[],
|
||||
};
|
||||
|
||||
it('/me:手机号按 user:manage 返回,dataScopes 来自 ScopeResolver', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = new MeService(prisma as unknown as PrismaService);
|
||||
const me = await service.getMe('u1', ['user:manage']);
|
||||
const me = await service.getMe('u1', ['user:manage'], scope);
|
||||
expect(me.user.phone).toBe('13866202046');
|
||||
expect(me.permissions).toEqual(['user:manage']);
|
||||
expect(me.roles).toHaveLength(1);
|
||||
const meWithout = await service.getMe('u1', []);
|
||||
expect(me.dataScopes).toEqual([{ type: 'department_and_children', customerIds: [] }]);
|
||||
const meWithout = await service.getMe('u1', [], scope);
|
||||
expect(meWithout.user.phone).toBeUndefined();
|
||||
await expectRejected(
|
||||
new MeService({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService).getMe('u1', []),
|
||||
} as unknown as PrismaService).getMe('u1', [], scope),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
await expectRejected(
|
||||
new MeService(prisma as unknown as PrismaService).getMe('u1', [], null),
|
||||
ErrorCode.OUT_OF_SCOPE,
|
||||
);
|
||||
});
|
||||
|
||||
it('角色列表只返回启用角色', async () => {
|
||||
it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => {
|
||||
const prisma = {
|
||||
role: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
description: '内置',
|
||||
isSystem: true,
|
||||
code: 'reviewer',
|
||||
name: '审核员',
|
||||
description: '审核',
|
||||
isSystem: false,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date('2026-11-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-11-02T00:00:00.000Z'),
|
||||
permissions: [
|
||||
{ permissionCode: 'script:read' },
|
||||
{ permissionCode: 'script:review' },
|
||||
{ permissionCode: 'delivery:export' },
|
||||
],
|
||||
dataScopes: [{ scopeType: 'department_and_children', targetId: null }],
|
||||
_count: { users: 3 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const roles = await new RolesService(prisma as unknown as PrismaService).list();
|
||||
expect(roles).toEqual([
|
||||
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
|
||||
]);
|
||||
const service = new RolesService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
||||
);
|
||||
const result = await service.list({
|
||||
status: 'active',
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
limit: 20,
|
||||
});
|
||||
expect(result.next_cursor).toBeNull();
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
id: 'r1',
|
||||
code: 'reviewer',
|
||||
status: 'active',
|
||||
permissionCount: 2,
|
||||
specialPermissionCount: 1,
|
||||
userCount: 3,
|
||||
dataScope: { type: 'department_and_children' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import { CurrentUser } from '../../common/security/decorators';
|
||||
import { CurrentScope, CurrentUser } from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { MeService } from './me.service';
|
||||
|
||||
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */
|
||||
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */
|
||||
@ApiTags('identity-me')
|
||||
@ApiBearerAuth()
|
||||
@Controller('me')
|
||||
@@ -15,11 +16,14 @@ export class MeController {
|
||||
constructor(@Inject(MeService) private readonly me: MeService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' })
|
||||
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> {
|
||||
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' })
|
||||
async current(
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MeResponse> {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return this.me.getMe(auth.userId, auth.permissions);
|
||||
return this.me.getMe(auth.userId, auth.permissions, scope);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,35 @@ import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||
import { resolveDataScope } from '../../common/security/permission.service';
|
||||
import { activeRolesOf } from '../../common/security/permission.service';
|
||||
import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { toRoleSummary, toUserSummary } from './user.mapper';
|
||||
|
||||
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */
|
||||
/**
|
||||
* /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。
|
||||
* `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。
|
||||
*/
|
||||
@Injectable()
|
||||
export class MeService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async getMe(userId: string, permissions: string[]): Promise<MeResponse> {
|
||||
async getMe(
|
||||
userId: string,
|
||||
permissions: string[],
|
||||
scope: ScopeContext | null,
|
||||
): Promise<MeResponse> {
|
||||
const user = await this.findUser(userId);
|
||||
if (user === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
||||
}
|
||||
if (scope === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
message: '数据范围解析失败,已拒绝访问',
|
||||
});
|
||||
}
|
||||
const roles = activeRolesOf(user);
|
||||
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
|
||||
return {
|
||||
@@ -28,7 +40,7 @@ export class MeService {
|
||||
},
|
||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||
permissions,
|
||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
||||
dataScopes: scope.dataScopes,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
@@ -38,8 +50,8 @@ export class MeService {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
department: { include: { parent: true, children: true } },
|
||||
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Permission, type PermissionTree } from '@lingxi/shared';
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { PermissionCatalogService } from '../../common/security/catalog/permission-catalog.service';
|
||||
import { RequirePermissions } from '../../common/security/decorators';
|
||||
|
||||
/**
|
||||
* 权限点目录(A2-BE-02):前端角色编辑器据此渲染「模块 × 操作」勾选树、
|
||||
* 特殊权限区域与 SoD 互斥对高亮。目录由 `@lingxi/shared` 代码注册,不读业务 DB。
|
||||
*/
|
||||
@ApiTags('identity-permissions')
|
||||
@ApiBearerAuth()
|
||||
@Controller('permissions')
|
||||
export class PermissionsController {
|
||||
constructor(
|
||||
@Inject(PermissionCatalogService) private readonly catalog: PermissionCatalogService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.ROLE_VIEW)
|
||||
@ApiOperation({
|
||||
summary: '权限点目录(10 模块树 + 特殊权限 + SoD 对)',
|
||||
description: '模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予',
|
||||
})
|
||||
getTree(): PermissionTree {
|
||||
return this.catalog.buildTree();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,49 @@
|
||||
import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared';
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import {
|
||||
type CreateRoleInput,
|
||||
createRoleInputSchema,
|
||||
ErrorCode,
|
||||
Permission,
|
||||
type RoleDetail,
|
||||
type RoleListQuery,
|
||||
roleListQuerySchema,
|
||||
type RoleListResponse,
|
||||
type RoleSaveResult,
|
||||
type UpdateRoleInput,
|
||||
updateRoleInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { RequirePermissions } from '../../common/security/decorators';
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
/** 角色列表:供用户管理页做角色多选绑定 */
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色管理(A2-BE-01 / 03 / 05)。
|
||||
*
|
||||
* 兼容 A1:`GET /roles` 同时接受 `role:view` 与 `user:view`(用户管理页的角色多选),
|
||||
* 默认只返回启用角色,返回项包含 `RoleSummary` 的 id / code / name / isSystem / description。
|
||||
*/
|
||||
@ApiTags('identity-roles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('roles')
|
||||
@@ -13,9 +51,59 @@ export class RolesController {
|
||||
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({ summary: '角色列表(绑定用)' })
|
||||
async list(): Promise<ListResponse<RoleSummary>> {
|
||||
return { items: await this.roles.list(), next_cursor: null };
|
||||
@RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW)
|
||||
@ApiOperation({
|
||||
summary: '角色列表(游标分页 + 筛选 + 排序)',
|
||||
description: '默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围',
|
||||
})
|
||||
async list(
|
||||
@Query(new ZodValidationPipe(roleListQuerySchema)) query: RoleListQuery,
|
||||
): Promise<RoleListResponse> {
|
||||
return this.roles.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.ROLE_VIEW)
|
||||
@ApiOperation({ summary: '角色详情(权限点、特殊权限、数据范围、绑定用户)' })
|
||||
async detail(@Param('id') id: string): Promise<RoleDetail> {
|
||||
return this.roles.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||
@Audit('identity.role.create')
|
||||
@ApiOperation({
|
||||
summary: '新建角色',
|
||||
description: '权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予',
|
||||
})
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createRoleInputSchema)) input: CreateRoleInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<RoleSaveResult> {
|
||||
return this.roles.create(input, requireAuth(auth).userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||
@Audit('identity.role.update')
|
||||
@ApiOperation({
|
||||
summary: '更新角色 / 启停',
|
||||
description: '系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示',
|
||||
})
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateRoleInputSchema)) input: UpdateRoleInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<RoleSaveResult> {
|
||||
return this.roles.update(id, input, requireAuth(auth).userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||
@Audit('identity.role.delete')
|
||||
@ApiOperation({ summary: '删除角色', description: '系统角色拒绝;有绑定用户(40903)只能停用' })
|
||||
async remove(@Param('id') id: string, @CurrentUser() auth: AuthContext | null): Promise<void> {
|
||||
await this.roles.remove(id, requireAuth(auth).userId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { RequestContextService } from '../../common/security/request-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
||||
|
||||
function roleFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'r1',
|
||||
code: 'copywriter',
|
||||
name: '文案策划',
|
||||
description: '脚本撰写',
|
||||
isSystem: false,
|
||||
status: 'ACTIVE',
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'script:write' }],
|
||||
dataScopes: [{ scopeType: 'department', targetId: null }],
|
||||
users: [
|
||||
{
|
||||
createdAt: NOW,
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: '周岸',
|
||||
account: 'zhou.an',
|
||||
phone: '13755205520',
|
||||
status: 'ACTIVE',
|
||||
department: { name: '素材二组' },
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: NOW,
|
||||
user: {
|
||||
id: 'u2',
|
||||
name: '林清',
|
||||
account: 'lin.qing',
|
||||
phone: '13912347712',
|
||||
status: 'DISABLED',
|
||||
department: { name: '设计组' },
|
||||
},
|
||||
},
|
||||
],
|
||||
_count: { users: 2 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: RolesService;
|
||||
prisma: {
|
||||
role: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
$transaction: ReturnType<typeof vi.fn>;
|
||||
tx: {
|
||||
role: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
rolePermission: {
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataScope: { deleteMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn> };
|
||||
outboxEvent: { create: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
};
|
||||
requestContext: { get: ReturnType<typeof vi.fn>; patchAudit: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const tx = {
|
||||
role: { create: vi.fn().mockResolvedValue({}), update: vi.fn(), delete: vi.fn() },
|
||||
rolePermission: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
dataScope: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
outboxEvent: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
const prisma = {
|
||||
role: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
count: vi.fn().mockResolvedValue(9),
|
||||
},
|
||||
user: { count: vi.fn().mockResolvedValue(2) },
|
||||
$transaction: vi.fn(async (callback: unknown) => {
|
||||
if (typeof callback === 'function') {
|
||||
return (callback as (client: typeof tx) => Promise<unknown>)(tx);
|
||||
}
|
||||
return Promise.all(callback as Promise<unknown>[]);
|
||||
}),
|
||||
tx,
|
||||
};
|
||||
const requestContext = {
|
||||
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
||||
patchAudit: vi.fn(),
|
||||
};
|
||||
return {
|
||||
service: new RolesService(
|
||||
prisma as unknown as PrismaService,
|
||||
requestContext as unknown as RequestContextService,
|
||||
),
|
||||
prisma,
|
||||
requestContext,
|
||||
};
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('RolesService.create', () => {
|
||||
it('默认全空:code 自动生成、权限点为空、写审计与 outbox', async () => {
|
||||
const { service, prisma, requestContext } = harness();
|
||||
prisma.role.findUnique.mockImplementation((args: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
roleFixture({ id: args.where.id, code: 'custom_x', permissions: [], users: [] }),
|
||||
),
|
||||
);
|
||||
const result = await service.create(
|
||||
{ name: '新运营组', dataScope: { type: 'created_by_me' } },
|
||||
'actor-1',
|
||||
);
|
||||
const createdArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { data: { code: string } };
|
||||
expect(createdArgs.data.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||||
expect(result.role.permissions).toEqual([]);
|
||||
expect(result.affectedUserCount).toBe(0);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const createArgs = prisma.tx.role.create.mock.calls[0]?.[0] as {
|
||||
data: { code: string; permissions: { create: unknown[] } };
|
||||
};
|
||||
expect(createArgs.data.permissions.create).toEqual([]);
|
||||
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
|
||||
data: { eventType: string; payloadJson: { action: string; roleId: string } };
|
||||
};
|
||||
expect(outboxArgs.data.eventType).toBe('role.permissions.changed');
|
||||
expect(outboxArgs.data.payloadJson.action).toBe('created');
|
||||
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ targetType: 'role', affectedUserCount: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('角色名称重复返回 40902', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findFirst.mockResolvedValueOnce({ id: 'other' });
|
||||
await expectRejected(
|
||||
service.create({ name: '文案策划', dataScope: { type: 'all' } }, 'actor-1'),
|
||||
ErrorCode.DUPLICATE_SUBMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('SoD 命中返回 40304 并给出冲突对', async () => {
|
||||
const { service } = harness();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await service.create(
|
||||
{
|
||||
name: '冲突角色',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
dataScope: { type: 'department' },
|
||||
},
|
||||
'actor-1',
|
||||
);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AppException);
|
||||
expect((thrown as AppException).code).toBe(ErrorCode.SOD_VIOLATION);
|
||||
expect((thrown as AppException).details).toMatchObject({
|
||||
conflicts: [{ pairId: 'script-write-review' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('特殊权限混入 permissions 返回 40001', async () => {
|
||||
const { service } = harness();
|
||||
await expectRejected(
|
||||
service.create(
|
||||
{
|
||||
name: '导出角色',
|
||||
permissions: ['delivery:export'],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
'actor-1',
|
||||
),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RolesService.update', () => {
|
||||
it('系统角色仅允许改显示名:改权限 / 状态返回 40903,改名放行', async () => {
|
||||
const { service, prisma } = harness();
|
||||
const systemRole = roleFixture({ code: 'super_admin', isSystem: true });
|
||||
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
|
||||
await expectRejected(
|
||||
service.update('r1', { permissions: ['user:view'] }, 'actor-1'),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
|
||||
await expectRejected(
|
||||
service.update('r1', { status: 'disabled' }, 'actor-1'),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
prisma.role.findUnique
|
||||
.mockResolvedValueOnce(systemRole)
|
||||
.mockResolvedValueOnce(
|
||||
roleFixture({ code: 'super_admin', isSystem: true, name: '新超管名' }),
|
||||
);
|
||||
const result = await service.update('r1', { name: '新超管名' }, 'actor-1');
|
||||
expect(result.role.name).toBe('新超管名');
|
||||
});
|
||||
|
||||
it('更新权限:返回受影响用户数、outbox 携带 diff、审计含前后值', async () => {
|
||||
const { service, prisma, requestContext } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
|
||||
roleFixture({
|
||||
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'user:view' }],
|
||||
}),
|
||||
);
|
||||
const result = await service.update(
|
||||
'r1',
|
||||
{ permissions: ['script:read', 'user:view'], status: 'disabled', reason: '岗位合并' },
|
||||
'actor-1',
|
||||
);
|
||||
expect(result.affectedUserCount).toBe(2);
|
||||
expect(result.role.specialPermissions).toEqual([]);
|
||||
|
||||
const updateArgs = prisma.tx.role.update.mock.calls[0]?.[0] as { data: { status: string } };
|
||||
expect(updateArgs.data.status).toBe('DISABLED');
|
||||
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
|
||||
data: {
|
||||
payloadJson: {
|
||||
changes: { permissions: { added: string[]; removed: string[] } };
|
||||
affectedUserIds: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(outboxArgs.data.payloadJson.changes.permissions.added).toEqual(['user:view']);
|
||||
expect(outboxArgs.data.payloadJson.changes.permissions.removed).toEqual(['script:write']);
|
||||
expect(outboxArgs.data.payloadJson.affectedUserIds).toEqual(['u1', 'u2']);
|
||||
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
affectedUserCount: 2,
|
||||
affectedUserIds: ['u1', 'u2'],
|
||||
reason: '岗位合并',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('更新数据范围:替换 data_scopes 行;customer_groups 多个客户组写多行', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
|
||||
roleFixture({
|
||||
dataScopes: [
|
||||
{ scopeType: 'customer_groups', targetId: 'g1' },
|
||||
{ scopeType: 'customer_groups', targetId: 'g2' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const groups = ['01HZZZZZZZZZZZZZZZZZZZZZZ1', '01HZZZZZZZZZZZZZZZZZZZZZZ2'];
|
||||
const result = await service.update(
|
||||
'r1',
|
||||
{ dataScope: { type: 'customer_groups', targetIds: groups } },
|
||||
'actor-1',
|
||||
);
|
||||
expect(result.role.dataScope).toEqual({ type: 'customer_groups', targetIds: ['g1', 'g2'] });
|
||||
const createManyArgs = prisma.tx.dataScope.createMany.mock.calls[0]?.[0] as {
|
||||
data: { roleId: string; scopeType: string; targetId: string }[];
|
||||
};
|
||||
expect(createManyArgs.data).toHaveLength(2);
|
||||
expect(createManyArgs.data[0]?.scopeType).toBe('customer_groups');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RolesService.remove', () => {
|
||||
it('系统角色不可删除(40903)', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ isSystem: true }));
|
||||
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
|
||||
});
|
||||
|
||||
it('有绑定用户只能停用(40903,附 userCount)', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(roleFixture());
|
||||
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
|
||||
});
|
||||
|
||||
it('无绑定用户删除成功并写 outbox(先事件后删除)', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ users: [], _count: { users: 0 } }));
|
||||
await service.remove('r1', 'actor-1');
|
||||
expect(prisma.tx.role.delete).toHaveBeenCalledWith({ where: { id: 'r1' } });
|
||||
expect(prisma.tx.outboxEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RolesService.detail', () => {
|
||||
it('角色不存在返回 40401', async () => {
|
||||
const { service } = harness();
|
||||
await expectRejected(service.detail('missing'), ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('超管详情隐式展示全部权限点(30)与 all 范围', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.role.findUnique.mockResolvedValueOnce(
|
||||
roleFixture({ code: 'super_admin', isSystem: true, permissions: [], dataScopes: [] }),
|
||||
);
|
||||
const detail = await service.detail('r1');
|
||||
expect(detail.permissions).toHaveLength(24);
|
||||
expect(detail.specialPermissions).toHaveLength(6);
|
||||
expect(detail.dataScope).toEqual({ type: 'all' });
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,749 @@
|
||||
import { type RoleSummary } from '@lingxi/shared';
|
||||
import {
|
||||
type CreateRoleInput,
|
||||
DATA_SCOPE_TYPES,
|
||||
type DataScope,
|
||||
type DataScopeType,
|
||||
ErrorCode,
|
||||
findSodConflicts,
|
||||
isPermissionCode,
|
||||
isSpecialPermission,
|
||||
type ListResponse,
|
||||
PERMISSION_CODES,
|
||||
type PermissionCode,
|
||||
ROLE_SOFT_LIMIT,
|
||||
type RoleBoundUser,
|
||||
type RoleDetail,
|
||||
type RoleListItem,
|
||||
type RoleListQuery,
|
||||
type RoleSaveResult,
|
||||
splitPermissions,
|
||||
SUPER_ADMIN_SOFT_LIMIT,
|
||||
ulid,
|
||||
type UpdateRoleInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { RequestContextService } from '../../common/security/request-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { toRoleSummary } from './user.mapper';
|
||||
import { iso, maskPhone } from './user.mapper';
|
||||
|
||||
/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */
|
||||
const ROLE_LIST_INCLUDE = {
|
||||
permissions: { select: { permissionCode: true } },
|
||||
dataScopes: { select: { scopeType: true, targetId: true } },
|
||||
_count: { select: { users: true } },
|
||||
} satisfies Prisma.RoleInclude;
|
||||
|
||||
const ROLE_DETAIL_INCLUDE = {
|
||||
permissions: { select: { permissionCode: true } },
|
||||
dataScopes: { select: { scopeType: true, targetId: true } },
|
||||
users: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
account: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
department: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
_count: { select: { users: true } },
|
||||
} satisfies Prisma.RoleInclude;
|
||||
|
||||
type RoleWithList = Prisma.RoleGetPayload<{ include: typeof ROLE_LIST_INCLUDE }>;
|
||||
type RoleWithDetail = Prisma.RoleGetPayload<{ include: typeof ROLE_DETAIL_INCLUDE }>;
|
||||
|
||||
interface RolePermissionSnapshot {
|
||||
permissions: PermissionCode[];
|
||||
specialPermissions: PermissionCode[];
|
||||
dataScope: DataScope;
|
||||
status: 'active' | 'disabled';
|
||||
}
|
||||
|
||||
interface PermissionDiff {
|
||||
added: PermissionCode[];
|
||||
removed: PermissionCode[];
|
||||
specialAdded: PermissionCode[];
|
||||
specialRemoved: PermissionCode[];
|
||||
}
|
||||
|
||||
interface CreatePersistArgs {
|
||||
id: string;
|
||||
code: string;
|
||||
input: CreateRoleInput;
|
||||
permissions: PermissionCode[];
|
||||
specialPermissions: PermissionCode[];
|
||||
diff: PermissionDiff;
|
||||
actorId: string;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
interface UpdatePersistArgs {
|
||||
id: string;
|
||||
existing: RoleWithDetail;
|
||||
input: UpdateRoleInput;
|
||||
after: RolePermissionSnapshot;
|
||||
diff: PermissionDiff;
|
||||
affectedUserIds: string[];
|
||||
nextName: string;
|
||||
nextDescription: string;
|
||||
beforeDataScopeType: string;
|
||||
actorId: string;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
interface OutboxEventArgs {
|
||||
action: 'created' | 'updated' | 'deleted';
|
||||
roleId: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
actorId: string;
|
||||
traceId: string | null;
|
||||
diff: PermissionDiff;
|
||||
affectedUserIds: string[];
|
||||
dataScopeBefore: string | null;
|
||||
dataScopeAfter: string | null;
|
||||
}
|
||||
|
||||
function splitCodes(codes: readonly PermissionCode[]): {
|
||||
normal: PermissionCode[];
|
||||
special: PermissionCode[];
|
||||
} {
|
||||
const normal: PermissionCode[] = [];
|
||||
const special: PermissionCode[] = [];
|
||||
for (const code of codes) {
|
||||
if (isSpecialPermission(code)) {
|
||||
special.push(code);
|
||||
} else {
|
||||
normal.push(code);
|
||||
}
|
||||
}
|
||||
return { normal: normal.sort(), special: special.sort() };
|
||||
}
|
||||
|
||||
function effectiveCodes(role: {
|
||||
isSystem: boolean;
|
||||
permissions: { permissionCode: string }[];
|
||||
}): PermissionCode[] {
|
||||
if (role.isSystem) {
|
||||
return [...PERMISSION_CODES];
|
||||
}
|
||||
return role.permissions
|
||||
.map((item) => item.permissionCode)
|
||||
.filter(isPermissionCode)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** 一个角色只允许一种范围类型(A2 契约):多行(customer_groups 多客户组)聚合为单值 */
|
||||
function aggregateDataScope(
|
||||
rows: readonly { scopeType: string; targetId: string | null }[],
|
||||
isSystem: boolean,
|
||||
): DataScope {
|
||||
if (isSystem) {
|
||||
return { type: 'all' };
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return { type: 'created_by_me' };
|
||||
}
|
||||
const ordered = [...rows].sort(
|
||||
(left, right) =>
|
||||
DATA_SCOPE_TYPES.indexOf(left.scopeType as DataScopeType) -
|
||||
DATA_SCOPE_TYPES.indexOf(right.scopeType as DataScopeType),
|
||||
);
|
||||
const type = (ordered[0]?.scopeType ?? 'created_by_me') as DataScopeType;
|
||||
if (type !== 'customer_groups') {
|
||||
return { type };
|
||||
}
|
||||
const targetIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.filter((row) => row.scopeType === type)
|
||||
.flatMap((row) => (row.targetId === null ? [] : [row.targetId])),
|
||||
),
|
||||
].sort();
|
||||
return targetIds.length > 0 ? { type, targetIds } : { type };
|
||||
}
|
||||
|
||||
function toBoundUser(row: RoleWithDetail['users'][number]): RoleBoundUser {
|
||||
return {
|
||||
id: row.user.id,
|
||||
name: row.user.name,
|
||||
account: row.user.account,
|
||||
phoneMasked: maskPhone(row.user.phone),
|
||||
departmentName: row.user.department.name,
|
||||
status: row.user.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
function toListItem(role: RoleWithList): RoleListItem {
|
||||
const codes = splitCodes(effectiveCodes(role));
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isSystem: role.isSystem,
|
||||
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||
permissionCount: codes.normal.length,
|
||||
specialPermissionCount: codes.special.length,
|
||||
userCount: role._count.users,
|
||||
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
|
||||
createdAt: iso(role.createdAt),
|
||||
updatedAt: iso(role.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toDetail(role: RoleWithDetail): RoleDetail {
|
||||
const listItem = toListItem(role);
|
||||
const codes = splitCodes(effectiveCodes(role));
|
||||
return {
|
||||
...listItem,
|
||||
permissions: codes.normal,
|
||||
specialPermissions: codes.special,
|
||||
boundUsers: role.users.map((row) => toBoundUser(row)),
|
||||
};
|
||||
}
|
||||
|
||||
function diffCodes(before: RolePermissionSnapshot, after: RolePermissionSnapshot): PermissionDiff {
|
||||
const beforeNormal = new Set(before.permissions);
|
||||
const afterNormal = new Set(after.permissions);
|
||||
const beforeSpecial = new Set(before.specialPermissions);
|
||||
const afterSpecial = new Set(after.specialPermissions);
|
||||
return {
|
||||
added: after.permissions.filter((code) => !beforeNormal.has(code)).sort(),
|
||||
removed: before.permissions.filter((code) => !afterNormal.has(code)).sort(),
|
||||
specialAdded: after.specialPermissions.filter((code) => !beforeSpecial.has(code)).sort(),
|
||||
specialRemoved: before.specialPermissions.filter((code) => !afterSpecial.has(code)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色管理(A2-BE-01 / 03 / 05):
|
||||
* - 列表 / 详情(目录统计 + 数据范围 + 绑定用户);
|
||||
* - 新建(权限默认全空、code 自动生成、SoD 保存即校验);
|
||||
* - 更新(含启用 / 停用;系统角色仅显示名可改);
|
||||
* - 删除(系统角色拒绝;有绑定用户 40903);
|
||||
* - 变更审计(前后值 diff + 影响用户数)与 outbox `role.permissions.changed` 占位通知。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<RoleSummary[]> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
||||
const where: Prisma.RoleWhereInput = {};
|
||||
if (query.status !== 'all') {
|
||||
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
|
||||
}
|
||||
if (query.keyword !== undefined && query.keyword.length > 0) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ code: { contains: query.keyword, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
|
||||
const orderBy: Prisma.RoleOrderByWithRelationInput[] =
|
||||
query.sort === 'name'
|
||||
? [{ name: direction }, { id: 'asc' }]
|
||||
: [{ createdAt: direction }, { id: 'asc' }];
|
||||
const rows = await this.prisma.role.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: query.limit + 1,
|
||||
include: ROLE_LIST_INCLUDE,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
return roles.map((role) => toRoleSummary(role));
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return { items: page.map((row) => toListItem(row)), next_cursor: next };
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<RoleDetail> {
|
||||
const role = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: ROLE_DETAIL_INCLUDE,
|
||||
});
|
||||
if (role === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||
}
|
||||
return toDetail(role);
|
||||
}
|
||||
|
||||
async create(input: CreateRoleInput, actorId: string): Promise<RoleSaveResult> {
|
||||
await this.ensureNameAvailable(input.name);
|
||||
const { permissions, specialPermissions } = this.validatePermissions(
|
||||
input.permissions ?? [],
|
||||
input.specialPermissions ?? [],
|
||||
);
|
||||
this.assertSod({
|
||||
permissions,
|
||||
specialPermissions,
|
||||
dataScope: input.dataScope,
|
||||
status: 'active',
|
||||
});
|
||||
const id = ulid();
|
||||
const traceId = this.requestContext.get()?.traceId ?? null;
|
||||
const diff: PermissionDiff = {
|
||||
added: permissions,
|
||||
removed: [],
|
||||
specialAdded: specialPermissions,
|
||||
specialRemoved: [],
|
||||
};
|
||||
const code = `custom_${ulid().toLowerCase()}`;
|
||||
await this.prisma.$transaction((tx) =>
|
||||
this.persistCreate(tx, {
|
||||
id,
|
||||
code,
|
||||
input,
|
||||
permissions,
|
||||
specialPermissions,
|
||||
diff,
|
||||
actorId,
|
||||
traceId,
|
||||
}),
|
||||
);
|
||||
const role = await this.detail(id);
|
||||
this.recordCreateAudit(role, diff);
|
||||
return { role, affectedUserCount: 0, warnings: await this.buildWarnings(role.code) };
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateRoleInput, actorId: string): Promise<RoleSaveResult> {
|
||||
const existing = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: ROLE_DETAIL_INCLUDE,
|
||||
});
|
||||
if (existing === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||
}
|
||||
this.assertSystemRoleMutable(existing, input);
|
||||
if (input.name !== undefined && input.name !== existing.name) {
|
||||
await this.ensureNameAvailable(input.name, id);
|
||||
}
|
||||
const before = this.snapshotOf(existing);
|
||||
const after = this.mergeSnapshot(before, input);
|
||||
// 超管豁免 SoD(系统角色不可改权限,隐式全集必然「违反」互斥对)
|
||||
if (!existing.isSystem) {
|
||||
this.assertSod(after);
|
||||
}
|
||||
const diff = diffCodes(before, after);
|
||||
const affectedUserIds = existing.users.map((row) => row.user.id);
|
||||
await this.prisma.$transaction((tx) =>
|
||||
this.persistUpdate(tx, {
|
||||
id,
|
||||
existing,
|
||||
input,
|
||||
after,
|
||||
diff,
|
||||
affectedUserIds,
|
||||
nextName: input.name ?? existing.name,
|
||||
nextDescription: input.description ?? existing.description,
|
||||
beforeDataScopeType: before.dataScope.type,
|
||||
actorId,
|
||||
traceId: this.requestContext.get()?.traceId ?? null,
|
||||
}),
|
||||
);
|
||||
const role = await this.detail(id);
|
||||
this.recordUpdateAudit({ existing, before, role, affectedUserIds, diff, input });
|
||||
return {
|
||||
role,
|
||||
affectedUserCount: affectedUserIds.length,
|
||||
warnings: await this.buildWarnings(role.code),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(id: string, actorId: string): Promise<void> {
|
||||
const existing = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { users: true } } },
|
||||
});
|
||||
if (existing === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||
}
|
||||
if (existing.isSystem) {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '系统角色不可删除' });
|
||||
}
|
||||
if (existing._count.users > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: `角色已绑定 ${String(existing._count.users)} 名用户,只能停用`,
|
||||
details: { userCount: existing._count.users },
|
||||
});
|
||||
}
|
||||
await this.prisma.$transaction((tx) =>
|
||||
this.persistRemove(tx, {
|
||||
id,
|
||||
existing,
|
||||
actorId,
|
||||
traceId: this.requestContext.get()?.traceId ?? null,
|
||||
}),
|
||||
);
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'role',
|
||||
targetId: id,
|
||||
before: { name: existing.name, code: existing.code, status: existing.status },
|
||||
after: null,
|
||||
affectedUserCount: 0,
|
||||
affectedUserIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
private async persistCreate(
|
||||
tx: Prisma.TransactionClient,
|
||||
args: CreatePersistArgs,
|
||||
): Promise<void> {
|
||||
await tx.role.create({
|
||||
data: {
|
||||
id: args.id,
|
||||
code: args.code,
|
||||
name: args.input.name,
|
||||
description: args.input.description ?? '',
|
||||
isSystem: false,
|
||||
status: 'ACTIVE',
|
||||
permissions: {
|
||||
create: [...args.permissions, ...args.specialPermissions].map((permissionCode) => ({
|
||||
permissionCode,
|
||||
})),
|
||||
},
|
||||
dataScopes: {
|
||||
create: this.dataScopeRows(
|
||||
args.input.dataScope.type,
|
||||
args.input.dataScope.targetIds ?? [],
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.writeOutboxEvent(tx, {
|
||||
action: 'created',
|
||||
roleId: args.id,
|
||||
roleCode: args.code,
|
||||
roleName: args.input.name,
|
||||
actorId: args.actorId,
|
||||
traceId: args.traceId,
|
||||
diff: args.diff,
|
||||
affectedUserIds: [],
|
||||
dataScopeBefore: null,
|
||||
dataScopeAfter: args.input.dataScope.type,
|
||||
});
|
||||
}
|
||||
|
||||
private recordCreateAudit(role: RoleDetail, diff: PermissionDiff): void {
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'role',
|
||||
targetId: role.id,
|
||||
before: null,
|
||||
after: this.auditSnapshot(role),
|
||||
affectedUserCount: 0,
|
||||
affectedUserIds: [],
|
||||
changes: diff,
|
||||
});
|
||||
}
|
||||
|
||||
private async persistUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
args: UpdatePersistArgs,
|
||||
): Promise<void> {
|
||||
await this.applyRoleUpdate(tx, args);
|
||||
await this.writeOutboxEvent(tx, {
|
||||
action: 'updated',
|
||||
roleId: args.id,
|
||||
roleCode: args.existing.code,
|
||||
roleName: args.nextName,
|
||||
actorId: args.actorId,
|
||||
traceId: args.traceId,
|
||||
diff: args.diff,
|
||||
affectedUserIds: args.affectedUserIds,
|
||||
dataScopeBefore:
|
||||
args.existing.dataScopes.length > 0
|
||||
? (args.existing.dataScopes[0]?.scopeType ?? null)
|
||||
: null,
|
||||
dataScopeAfter: args.after.dataScope.type,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyRoleUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
args: UpdatePersistArgs,
|
||||
): Promise<void> {
|
||||
await tx.role.update({
|
||||
where: { id: args.id },
|
||||
data: {
|
||||
name: args.nextName,
|
||||
description: args.nextDescription,
|
||||
status: args.after.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||
},
|
||||
});
|
||||
if (args.input.permissions !== undefined || args.input.specialPermissions !== undefined) {
|
||||
await tx.rolePermission.deleteMany({ where: { roleId: args.id } });
|
||||
await tx.rolePermission.createMany({
|
||||
data: [...args.after.permissions, ...args.after.specialPermissions].map(
|
||||
(permissionCode) => ({ roleId: args.id, permissionCode }),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (args.input.dataScope !== undefined) {
|
||||
await tx.dataScope.deleteMany({ where: { roleId: args.id } });
|
||||
await tx.dataScope.createMany({
|
||||
data: this.dataScopeRows(
|
||||
args.input.dataScope.type,
|
||||
args.input.dataScope.targetIds ?? [],
|
||||
).map((row) => ({ ...row, roleId: args.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private recordUpdateAudit(args: {
|
||||
existing: RoleWithDetail;
|
||||
before: RolePermissionSnapshot;
|
||||
role: RoleDetail;
|
||||
affectedUserIds: string[];
|
||||
diff: PermissionDiff;
|
||||
input: UpdateRoleInput;
|
||||
}): void {
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'role',
|
||||
targetId: args.role.id,
|
||||
before: {
|
||||
name: args.existing.name,
|
||||
description: args.existing.description,
|
||||
permissions: args.before.permissions,
|
||||
specialPermissions: args.before.specialPermissions,
|
||||
dataScope: args.before.dataScope,
|
||||
status: args.before.status,
|
||||
},
|
||||
after: this.auditSnapshot(args.role),
|
||||
affectedUserCount: args.affectedUserIds.length,
|
||||
affectedUserIds: args.affectedUserIds,
|
||||
changes: args.diff,
|
||||
...(args.input.reason === undefined ? {} : { reason: args.input.reason }),
|
||||
});
|
||||
}
|
||||
|
||||
private async persistRemove(
|
||||
tx: Prisma.TransactionClient,
|
||||
args: {
|
||||
id: string;
|
||||
existing: { code: string; name: string };
|
||||
actorId: string;
|
||||
traceId: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.writeOutboxEvent(tx, {
|
||||
action: 'deleted',
|
||||
roleId: args.id,
|
||||
roleCode: args.existing.code,
|
||||
roleName: args.existing.name,
|
||||
actorId: args.actorId,
|
||||
traceId: args.traceId,
|
||||
diff: { added: [], removed: [], specialAdded: [], specialRemoved: [] },
|
||||
affectedUserIds: [],
|
||||
dataScopeBefore: null,
|
||||
dataScopeAfter: null,
|
||||
});
|
||||
await tx.role.delete({ where: { id: args.id } });
|
||||
}
|
||||
|
||||
private async writeOutboxEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
args: OutboxEventArgs,
|
||||
): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
aggregateType: 'role',
|
||||
aggregateId: args.roleId,
|
||||
eventType: 'role.permissions.changed',
|
||||
payloadJson: this.eventPayload(args),
|
||||
...(args.traceId === null ? {} : { traceId: args.traceId }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private mergeSnapshot(
|
||||
before: RolePermissionSnapshot,
|
||||
input: UpdateRoleInput,
|
||||
): RolePermissionSnapshot {
|
||||
return {
|
||||
permissions:
|
||||
input.permissions === undefined
|
||||
? before.permissions
|
||||
: this.validatePermissions(input.permissions, []).permissions,
|
||||
specialPermissions:
|
||||
input.specialPermissions === undefined
|
||||
? before.specialPermissions
|
||||
: this.validatePermissions([], input.specialPermissions).specialPermissions,
|
||||
dataScope: input.dataScope ?? before.dataScope,
|
||||
status:
|
||||
input.status === undefined
|
||||
? before.status
|
||||
: input.status === 'enabled'
|
||||
? 'active'
|
||||
: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
private auditSnapshot(role: RoleDetail): Record<string, unknown> {
|
||||
return {
|
||||
name: role.name,
|
||||
permissions: role.permissions,
|
||||
specialPermissions: role.specialPermissions,
|
||||
dataScope: role.dataScope,
|
||||
status: role.status,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotOf(role: RoleWithDetail): RolePermissionSnapshot {
|
||||
const codes = splitCodes(effectiveCodes(role));
|
||||
return {
|
||||
permissions: codes.normal,
|
||||
specialPermissions: codes.special,
|
||||
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
|
||||
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
private assertSystemRoleMutable(role: { isSystem: boolean }, input: UpdateRoleInput): void {
|
||||
if (!role.isSystem) {
|
||||
return;
|
||||
}
|
||||
const touchesProtected =
|
||||
input.description !== undefined ||
|
||||
input.permissions !== undefined ||
|
||||
input.specialPermissions !== undefined ||
|
||||
input.dataScope !== undefined ||
|
||||
input.status !== undefined;
|
||||
if (touchesProtected) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '系统角色不可修改权限 / 数据范围 / 状态,仅显示名可改',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private validatePermissions(
|
||||
normalInput: readonly string[],
|
||||
specialInput: readonly string[],
|
||||
): { permissions: PermissionCode[]; specialPermissions: PermissionCode[] } {
|
||||
let parsedNormal: PermissionCode[];
|
||||
let parsedSpecial: PermissionCode[];
|
||||
try {
|
||||
const combined = splitPermissions([...normalInput, ...specialInput]);
|
||||
parsedNormal = combined.permissions;
|
||||
parsedSpecial = combined.specialPermissions;
|
||||
} catch {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在未登记的权限点编码' });
|
||||
}
|
||||
const normalSet = new Set<string>(parsedNormal);
|
||||
for (const code of normalInput) {
|
||||
if (!normalSet.has(code)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const specialSet = new Set<string>(parsedSpecial);
|
||||
for (const code of specialInput) {
|
||||
if (!specialSet.has(code)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `普通权限 ${code} 不能放入 specialPermissions`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { permissions: parsedNormal, specialPermissions: parsedSpecial };
|
||||
}
|
||||
|
||||
private assertSod(snapshot: RolePermissionSnapshot): void {
|
||||
const conflicts = findSodConflicts([...snapshot.permissions, ...snapshot.specialPermissions]);
|
||||
if (conflicts.length > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SOD_VIOLATION,
|
||||
message: `违反职责互斥:${conflicts.map((conflict) => conflict.label).join(';')}`,
|
||||
details: { conflicts },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private dataScopeRows(
|
||||
type: DataScopeType,
|
||||
targetIds: readonly string[],
|
||||
): { id: string; scopeType: string; targetId: string | null }[] {
|
||||
if (type !== 'customer_groups' || targetIds.length === 0) {
|
||||
return [{ id: ulid(), scopeType: type, targetId: null }];
|
||||
}
|
||||
return targetIds.map((targetId) => ({ id: ulid(), scopeType: type, targetId }));
|
||||
}
|
||||
|
||||
private async ensureNameAvailable(name: string, excludeId?: string): Promise<void> {
|
||||
const existing = await this.prisma.role.findFirst({
|
||||
where: { name, ...(excludeId === undefined ? {} : { NOT: { id: excludeId } }) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '角色名称已存在' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
|
||||
private async buildWarnings(roleCode: string): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
||||
if (activeRoleCount > ROLE_SOFT_LIMIT) {
|
||||
warnings.push(
|
||||
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
|
||||
);
|
||||
}
|
||||
if (roleCode === 'super_admin') {
|
||||
const superAdminCount = await this.prisma.user.count({
|
||||
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
||||
});
|
||||
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
|
||||
warnings.push(
|
||||
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private eventPayload(input: {
|
||||
action: 'created' | 'updated' | 'deleted';
|
||||
roleId: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
actorId: string;
|
||||
traceId: string | null;
|
||||
diff: PermissionDiff;
|
||||
affectedUserIds: string[];
|
||||
dataScopeBefore: string | null;
|
||||
dataScopeAfter: string | null;
|
||||
}): Prisma.InputJsonObject {
|
||||
return {
|
||||
action: input.action,
|
||||
roleId: input.roleId,
|
||||
roleCode: input.roleCode,
|
||||
roleName: input.roleName,
|
||||
actorId: input.actorId,
|
||||
traceId: input.traceId,
|
||||
changes: {
|
||||
permissions: { added: input.diff.added, removed: input.diff.removed },
|
||||
specialPermissions: {
|
||||
added: input.diff.specialAdded,
|
||||
removed: input.diff.specialRemoved,
|
||||
},
|
||||
dataScope: { before: input.dataScopeBefore, after: input.dataScopeAfter },
|
||||
},
|
||||
affectedUserIds: input.affectedUserIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
SUPER_ADMIN_SOFT_LIMIT,
|
||||
ulid,
|
||||
type UpdateUserInput,
|
||||
type UserDetail,
|
||||
type UserListQuery,
|
||||
type UserSummary,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
@@ -31,6 +32,8 @@ function hasPermission(permissions: string[], code: string): boolean {
|
||||
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger('UsersService');
|
||||
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
@@ -90,6 +93,7 @@ export class UsersService {
|
||||
} catch (error) {
|
||||
throw this.mapUniqueError(error);
|
||||
}
|
||||
await this.warnIfSuperAdminExceeded(input.roleIds);
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
@@ -125,6 +129,9 @@ export class UsersService {
|
||||
} catch (error) {
|
||||
throw this.mapUniqueError(error);
|
||||
}
|
||||
if (input.roleIds !== undefined) {
|
||||
await this.warnIfSuperAdminExceeded(input.roleIds);
|
||||
}
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
@@ -181,6 +188,28 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
|
||||
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
||||
*/
|
||||
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
||||
const superAdminRole = await this.prisma.role.findUnique({
|
||||
where: { code: 'super_admin' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
||||
return;
|
||||
}
|
||||
const count = await this.prisma.user.count({
|
||||
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
||||
});
|
||||
if (count > SUPER_ADMIN_SOFT_LIMIT) {
|
||||
this.logger.warn(
|
||||
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrThrow(id: string): Promise<UserWithRelations> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
|
||||
if (user === null) {
|
||||
|
||||
Reference in new issue
Block a user