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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user