Files
lingxi/apps/api/src/modules/identity/roles.service.ts
T
Stev_Wang e4330a43e4 feat(api): A4 数据字典与系统配置后端
背景:A4 需要字典三层合并、配置单一来源与阈值同源。\n改动:新增 catalog 模块(类型/条目/三层合并/编码不可改/快照工具);platform 新增 SystemConfigService/Controller、NamingService 与配置版本历史;roles/users/customers/customer-fields 改为读 system_configs 上限。\n影响面:新增 13 个 REST 端点与 4 个模块注入;system_configs 写入走事务/乐观锁/版本历史;OpenAPI 快照更新。\n回滚:回滚本提交并恢复 shared 契约;新增表可保留。\n验证:api typecheck/build;单测 137;集成 58;OpenAPI diff 无破坏性变更。
2026-11-04 11:49:05 +08:00

757 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { SystemConfigService } from '../platform/system-config/system-config.service';
import { iso, maskPhone } from './user.mapper';
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,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
) {}
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 }),
});
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: '角色名称已存在' });
}
}
/** 软提示(不阻断):角色 / 超管人数超过建议值;阈值读 system_configs(R-26),常量仅作读失败兜底 */
private async buildWarnings(roleCode: string): Promise<string[]> {
const warnings: string[] = [];
const roleSoftLimit = await this.systemConfig.getNumber('limit.role.max', ROLE_SOFT_LIMIT);
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
if (activeRoleCount > roleSoftLimit) {
warnings.push(
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(roleSoftLimit)},建议合并角色`,
);
}
if (roleCode === 'super_admin') {
const superAdminSoftLimit = await this.systemConfig.getNumber(
'limit.super_admin.max',
SUPER_ADMIN_SOFT_LIMIT,
);
const superAdminCount = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
});
if (superAdminCount > superAdminSoftLimit) {
warnings.push(
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人`,
);
}
}
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,
};
}
}