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:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

@@ -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);
}
}
+20 -8
View File
@@ -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' });
});
});
+738 -9
View File
@@ -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,
};
}
}
+30 -1
View File
@@ -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) {