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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(parsedNormal); for (const code of normalInput) { if (!normalSet.has(code)) { throw new AppException({ code: ErrorCode.PARAM_INVALID, message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`, }); } } const specialSet = new Set(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 { 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 { 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, }; } }