Compare commits

..
2 Commits
Author SHA1 Message Date
Stev_Wang 7fb458f62f docs(plan): 回写 A2 完成状态并记录 H1 权限门决策
ci / 质量门禁 (push) Canceled after 0s
背景:A2 角色权限与数据范围已完成,需回写计划状态、实施报告与架构决策记录。

改动:
- 新增 docs/adr/ADR-001-权限点目录与六类数据范围.md 及 ADR 索引,记录 H1 权限门六项决策。
- 新增 A2 实施报告(范围、交付、门禁、遗留、回滚、下一步)。
- 更新开发计划 §5.2 / §6.3 / 文档状态,并同步根 README 文档索引。
2026-11-03 12:27:15 +08:00
Stev_Wang 61894df401 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 全绿。
2026-11-03 12:26:51 +08:00
85 changed files with 14628 additions and 230 deletions

No files matched your search

+3
View File
@@ -19,6 +19,9 @@
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) | | 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 | | **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 | | **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限点目录与六类数据范围) |
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** | | 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。 > 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
@@ -11,6 +11,7 @@ import { tap } from 'rxjs';
import { AUDIT_ACTION_KEY } from './decorators'; import { AUDIT_ACTION_KEY } from './decorators';
import { import {
type AuditDetail,
buildRequestContext, buildRequestContext,
type RequestContext, type RequestContext,
RequestContextService, RequestContextService,
@@ -26,7 +27,16 @@ interface AuditPayload {
result: 'success' | 'failure'; result: 'success' | 'failure';
actorId: string | null; actorId: string | null;
account: string | null; account: string | null;
ip: string | null;
userAgent: string | null;
targetType: string | null;
targetId: string | null; targetId: string | null;
before?: unknown;
after?: unknown;
changes?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
traceId: string; traceId: string;
durationMs: number; durationMs: number;
error?: string; error?: string;
@@ -39,7 +49,10 @@ function actionOf(context: ExecutionContext): string | null {
/** /**
* 全局请求上下文 + @Audit 结构化日志。 * 全局请求上下文 + @Audit 结构化日志。
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。 *
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
*/ */
@Injectable() @Injectable()
export class AuditInterceptor implements NestInterceptor { export class AuditInterceptor implements NestInterceptor {
@@ -80,15 +93,27 @@ export class AuditInterceptor implements NestInterceptor {
ctx: RequestContext, ctx: RequestContext,
action: string, action: string,
result: AuditPayload['result'], result: AuditPayload['result'],
targetId: string | null, defaultTargetId: string | null,
error?: unknown, error?: unknown,
): void { ): void {
const detail: AuditDetail = ctx.audit ?? {};
const payload: AuditPayload = { const payload: AuditPayload = {
action, action,
result, result,
actorId: ctx.userId, actorId: ctx.userId,
account: ctx.account, account: ctx.account,
targetId, ip: ctx.ip,
userAgent: ctx.userAgent,
targetType: detail.targetType ?? null,
targetId: detail.targetId ?? defaultTargetId,
...(detail.before === undefined ? {} : { before: detail.before }),
...(detail.after === undefined ? {} : { after: detail.after }),
...(detail.changes === undefined ? {} : { changes: detail.changes }),
...(detail.affectedUserCount === undefined
? {}
: { affectedUserCount: detail.affectedUserCount }),
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
...(detail.reason === undefined ? {} : { reason: detail.reason }),
traceId: ctx.traceId, traceId: ctx.traceId,
durationMs: Date.now() - ctx.startedAt, durationMs: Date.now() - ctx.startedAt,
...(error === undefined ...(error === undefined
+13 -1
View File
@@ -1,6 +1,8 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared'; import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import type { ScopeContext } from './scope/scope-context';
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */ /** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
export interface JwtAccessPayload { export interface JwtAccessPayload {
sub: string; sub: string;
@@ -22,12 +24,22 @@ export interface AuthContext {
status: UserStatus; status: UserStatus;
roles: RoleSummary[]; roles: RoleSummary[];
permissions: string[]; permissions: string[];
dataScope: DataScope; /**
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
*/
dataScopes: DataScope[];
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
departmentIds: string[];
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
isSuperAdmin: boolean;
mustChangePassword: boolean; mustChangePassword: boolean;
} }
export interface AuthenticatedRequest extends FastifyRequest { export interface AuthenticatedRequest extends FastifyRequest {
auth?: AuthContext; auth?: AuthContext;
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
scope?: ScopeContext;
} }
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */ /** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
@@ -0,0 +1,79 @@
import { PERMISSION_CATALOG, SPECIAL_PERMISSIONS } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { PermissionCatalogService } from './permission-catalog.service';
function makeService(): {
service: PermissionCatalogService;
upsert: ReturnType<typeof vi.fn>;
} {
const upsert = vi.fn().mockResolvedValue({});
const prisma = { permission: { upsert } };
return {
service: new PermissionCatalogService(prisma as never),
upsert,
};
}
describe('PermissionCatalogService', () => {
it('启动同步:30 个权限点逐个幂等 upsert(不删除历史行)', async () => {
const { service, upsert } = makeService();
const count = await service.sync();
expect(count).toBe(30);
expect(upsert).toHaveBeenCalledTimes(30);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { code: 'role:view' },
}),
);
expect(upsert.mock.calls[0]?.[0]).toEqual({
where: { code: PERMISSION_CATALOG[0]?.code },
update: expect.objectContaining({ module: PERMISSION_CATALOG[0]?.module }),
create: expect.objectContaining({ code: PERMISSION_CATALOG[0]?.code }),
});
});
it('启动同步失败只告警不阻断(OpenAPI 导出 / 无 DB 命令可继续)', async () => {
const { service, upsert } = makeService();
upsert.mockRejectedValueOnce(new Error('db unavailable'));
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
});
it('权限树:10 模块(与 UI 原型顺序一致)+ 6 特殊权限 + 3 对 SoD', () => {
const { service } = makeService();
const tree = service.buildTree();
expect(tree.totalPermissions).toBe(30);
expect(tree.modules).toHaveLength(10);
expect(tree.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(tree.modules.map((module) => module.moduleLabel)).toEqual([
'脚本',
'素材',
'资产',
'导出交付',
'客户',
'客户组',
'组织与角色',
'行业包',
'平台配置',
'审计',
]);
expect(tree.modules.flatMap((module) => module.permissions)).toHaveLength(30);
expect(tree.specialPermissions).toHaveLength(6);
expect(tree.specialPermissions.map((item) => item.code).sort()).toEqual(
[...SPECIAL_PERMISSIONS].sort(),
);
expect(tree.sodPairs).toHaveLength(3);
expect(tree.sodPairs[0]?.permissions).toEqual(['script:write', 'script:review']);
});
});
@@ -0,0 +1,112 @@
import {
PERMISSION_CATALOG,
type PermissionModule,
permissionsByModule,
type PermissionTree,
SOD_PAIRS,
SPECIAL_PERMISSIONS,
} from '@lingxi/shared';
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import { PRISMA } from '../../../infra/tokens';
/**
* 权限点注册与同步(A2-BE-02)。
*
* 唯一事实源是 `@lingxi/shared` 的 `PERMISSION_CATALOG`:启动时把目录幂等 upsert 进
* `permissions` 表(只增不删),供管理端检索 / 审计 / 后续统计 join;权限树本身仍从
* 代码目录生成(避免「DB 里有、代码里没有」的幽灵权限点)。
*
* 启动同步失败只告警不阻断(OpenAPI 导出、无 DB 的本地命令不应因此失败);
* 生产健康检查与告警另行覆盖。
*/
@Injectable()
export class PermissionCatalogService implements OnApplicationBootstrap {
private readonly logger = new Logger('PermissionCatalog');
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
try {
const count = await this.sync();
this.logger.log(`权限点目录已同步:${String(count)} 个`);
} catch (error) {
this.logger.warn(
`权限点目录同步失败(不阻断启动):${error instanceof Error ? error.message : '未知错误'}`,
);
}
}
/** 幂等 upsert:更新元数据、补新增,不删除目录之外的历史行(只增不删) */
async sync(): Promise<number> {
for (const item of PERMISSION_CATALOG) {
await this.prisma.permission.upsert({
where: { code: item.code },
update: {
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
create: {
code: item.code,
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
});
}
return PERMISSION_CATALOG.length;
}
/** 权限树(10 模块 + 特殊权限汇总 + SoD 对):直接由代码目录生成 */
buildTree(): PermissionTree {
const modules: {
module: PermissionModule;
moduleLabel: string;
sortOrder: number;
permissions: {
code: string;
name: string;
description: string;
isSpecial: boolean;
}[];
}[] = permissionsByModule().map((group) => ({
module: group.module,
moduleLabel: group.moduleLabel,
sortOrder: group.sortOrder,
permissions: group.permissions.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
})),
}));
const specialCodeSet = new Set<string>(SPECIAL_PERMISSIONS);
const specialPermissions = PERMISSION_CATALOG.filter((item) =>
specialCodeSet.has(item.code),
).map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: true,
}));
return {
modules,
specialPermissions,
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
};
}
}
@@ -1,6 +1,7 @@
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common'; import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
import type { AuthContext, AuthenticatedRequest } from './auth.types'; import type { AuthContext, AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
export const IS_PUBLIC_KEY = 'auth:public'; export const IS_PUBLIC_KEY = 'auth:public';
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */ /** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
@@ -23,3 +24,11 @@ export const CurrentUser = createParamDecorator(
return request.auth ?? null; return request.auth ?? null;
}, },
); );
/** 取 ScopeGuard 解析后的数据范围上下文;未登录 / 未解析返回 null */
export const CurrentScope = createParamDecorator(
(_data: unknown, context: ExecutionContext): ScopeContext | null => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.scope ?? null;
},
);
+28 -12
View File
@@ -7,6 +7,7 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators'; import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { PermissionService } from './permission.service'; import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context'; import { RequestContextService } from './request-context';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [ const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
@@ -123,9 +124,9 @@ export class PermissionGuard implements CanActivate {
return true; return true;
} }
const auth = requireAuth(context); const auth = requireAuth(context);
const missing = normal.filter((code) => !auth.permissions.includes(code)); // 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
if (missing.length > 0) { if (!normal.some((code) => auth.permissions.includes(code))) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } }); throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
} }
return true; return true;
} }
@@ -147,11 +148,10 @@ export class SpecialPermissionGuard implements CanActivate {
return true; return true;
} }
const auth = requireAuth(context); const auth = requireAuth(context);
const missing = special.filter((code) => !auth.permissions.includes(code)); if (!special.some((code) => auth.permissions.includes(code))) {
if (missing.length > 0) {
throw new AppException({ throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED, code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
details: { missing }, details: { required: special },
}); });
} }
return true; return true;
@@ -159,16 +159,32 @@ export class SpecialPermissionGuard implements CanActivate {
} }
/** /**
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。 * 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。 * 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
*
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
* 公开接口(登录 / 健康检查)不解析。
*/ */
@Injectable() @Injectable()
export class ScopeGuard implements CanActivate { export class ScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { constructor(
const auth = authOf(context); @Inject(Reflector) private readonly reflector: Reflector,
if (auth !== null && auth.dataScope.type !== 'all') { @Inject(ScopeResolver) private readonly resolver: ScopeResolver,
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤 @Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isPublic(this.reflector, context)) {
return true;
} }
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const auth = authOf(context);
if (auth === null) {
return true;
}
const scope = await this.resolver.resolve(auth);
request.scope = scope;
this.requestContext.patch({ scope });
return true; return true;
} }
} }
@@ -1,4 +1,12 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared'; import {
DATA_SCOPE_TYPES,
type DataScope,
type DataScopeType,
isPermissionCode,
PERMISSION_CODES,
type RoleSummary,
type UserStatus,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
@@ -6,17 +14,19 @@ import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens'; import { PRISMA } from '../../infra/tokens';
import type { AuthContext } from './auth.types'; import type { AuthContext } from './auth.types';
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */ /** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
export type UserAuthRecord = Prisma.UserGetPayload<{ export type UserAuthRecord = Prisma.UserGetPayload<{
include: { include: {
department: { include: { parent: true } }; department: { include: { parent: true; children: true } };
roles: { include: { role: { include: { permissions: true } } } }; roles: {
include: { role: { include: { permissions: true; dataScopes: true } } };
};
}; };
}>; }>;
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin']; type RoleEntity = UserAuthRecord['roles'][number]['role'];
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary { function toRoleSummary(role: RoleEntity): RoleSummary {
return { return {
id: role.id, id: role.id,
code: role.code, code: role.code,
@@ -26,32 +36,59 @@ function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSumma
}; };
} }
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
}
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] { export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
return user.roles.filter((item) => item.role.status === 'ACTIVE'); return user.roles.filter((item) => item.role.status === 'ACTIVE');
} }
/**
* 合并多角色的数据范围声明:同一类型取并集(targetIds 去重排序),按六类固定顺序输出。
* 一个角色只允许一种类型(A2 契约),但历史数据可能有多行(customer_groups 多客户组),
* 因此按 scope_type 聚合后再跨角色合并。
*/
export function mergeRoleDataScopes(roles: readonly RoleEntity[]): DataScope[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const role of roles) {
for (const scope of role.dataScopes) {
const type = scope.scopeType as DataScopeType;
const targets = byType.get(type) ?? new Set<string>();
if (scope.targetId !== null) {
targets.add(scope.targetId);
}
byType.set(type, targets);
}
}
const ordered: DataScope[] = [];
for (const type of DATA_SCOPE_TYPES) {
const targets = byType.get(type);
if (targets === undefined) {
continue;
}
ordered.push({
type,
...(type === 'customer_groups' ? { targetIds: [...targets].sort() } : {}),
});
}
return ordered;
}
/** 用户部门子树 id(自身在前,最多两级):scope 解析需要「本部门 / 本部门及下级」 */
export function departmentSubtreeIds(user: UserAuthRecord): string[] {
return [user.department.id, ...user.department.children.map((child) => child.id)];
}
/** /**
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点, * 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。 * 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
*
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
*/ */
@Injectable() @Injectable()
export class PermissionService { export class PermissionService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async resolveAuth(userId: string): Promise<AuthContext | null> { async resolveAuth(userId: string): Promise<AuthContext | null> {
const user = await this.prisma.user.findUnique({ const user = await this.findAuthRecord(userId);
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
if (user === null) { if (user === null) {
return null; return null;
} }
@@ -62,25 +99,35 @@ export class PermissionService {
return this.prisma.user.findUnique({ return this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
include: { include: {
department: { include: { parent: true } }, department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true } } } }, roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
}, },
}); });
} }
toAuthContext(user: UserAuthRecord): AuthContext { toAuthContext(user: UserAuthRecord): AuthContext {
const roles = activeRolesOf(user); const roles = activeRolesOf(user);
const permissions = [ const roleEntities = roles.map((item) => item.role);
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))), const isSuperAdmin = roleEntities.some((role) => role.isSystem);
const permissions = isSuperAdmin
? [...PERMISSION_CODES]
: [
...new Set(
roleEntities
.flatMap((role) => role.permissions.map((item) => item.permissionCode))
.filter((code) => isPermissionCode(code)),
),
].sort(); ].sort();
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled'; const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
return { return {
userId: user.id, userId: user.id,
account: user.account, account: user.account,
status, status,
roles: roles.map((item) => toRoleSummary(item.role)), roles: roleEntities.map((role) => toRoleSummary(role)),
permissions, permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)), dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
departmentIds: departmentSubtreeIds(user),
isSuperAdmin,
mustChangePassword: user.mustChangePassword, mustChangePassword: user.mustChangePassword,
}; };
} }
@@ -4,13 +4,35 @@ import { ulid } from '@lingxi/shared';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.types'; import type { AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */ /**
* 变更审计明细(A2-BE-05):由业务 Service 通过 `RequestContextService.patch({ audit })` 写入,
* AuditInterceptor 在请求结束时统一输出结构化 Pino 日志(A5 再落审计表)。
*
* 权限 / 角色类变更必须带:操作人(context.userId)、IP(context.ip)、
* 变更前后值(before / after)、影响用户数(affectedUserCount / affectedUserIds)。
*/
export interface AuditDetail {
targetType?: string;
targetId?: string;
before?: unknown;
after?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
/** 追加的变更摘要(如权限点 diff),与 before / after 并存 */
changes?: unknown;
}
/** 单次请求上下文:trace_id / 当前用户 / 权限快照 / 数据范围 / 审计明细 */
export interface RequestContext { export interface RequestContext {
traceId: string; traceId: string;
userId: string | null; userId: string | null;
account: string | null; account: string | null;
permissions: string[] | null; permissions: string[] | null;
scope: ScopeContext | null;
audit: AuditDetail | null;
ip: string | null; ip: string | null;
userAgent: string | null; userAgent: string | null;
method: string; method: string;
@@ -32,6 +54,8 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
userId: auth?.userId ?? null, userId: auth?.userId ?? null,
account: auth?.account ?? null, account: auth?.account ?? null,
permissions: auth?.permissions ?? null, permissions: auth?.permissions ?? null,
scope: request.scope ?? null,
audit: null,
ip: request.ip.length > 0 ? request.ip : null, ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null, userAgent: request.headers['user-agent'] ?? null,
method: request.method, method: request.method,
@@ -42,7 +66,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
/** /**
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立, * AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。 * 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
*/ */
@Injectable() @Injectable()
export class RequestContextService { export class RequestContextService {
@@ -63,4 +87,13 @@ export class RequestContextService {
} }
Object.assign(store, partial); Object.assign(store, partial);
} }
/** 追加审计明细(多次 patch 时浅合并,便于业务 Service 逐步补充) */
patchAudit(detail: Partial<AuditDetail>): void {
const store = this.storage.getStore();
if (store === undefined) {
return;
}
store.audit = { ...(store.audit ?? {}), ...detail };
}
} }
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
/**
* 客户目录端口(A2-BE-04 / 技术架构 §5.5)。
*
* 数据范围的可见客户集合由「客户目录」解析;A2 只有角色与范围声明,客户 / 客户组 /
* 客户负责人属 A3(client 模块)。为避免 A2 依赖未建表的客户模块,这里定义可替换端口:
* - A2 注册 `EmptyCustomerDirectory`:所有方法返回空集(不越权,不需要降级);
* - A3 落地后用真实实现替换 provider(注入 token 不变),ScopeResolver 无需修改。
*
* 约定:端口只读;返回客户 id 列表,由 ScopeResolver 做去重与并集。
*/
export const CUSTOMER_DIRECTORY = Symbol('CUSTOMER_DIRECTORY');
export interface CustomerDirectory {
/** 「负责客户」范围:客户负责人 = 本人 */
findOwnedCustomerIds(userId: string): Promise<string[]>;
/** 「指定客户组」范围:客户组下全部客户 */
findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]>;
/** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */
findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]>;
/** 「本人创建」范围:创建人 = 本人 */
findCreatedCustomerIds(userId: string): Promise<string[]>;
}
/**
* A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。
* 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。
*/
@Injectable()
export class EmptyCustomerDirectory implements CustomerDirectory {
findOwnedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findCreatedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
}
@@ -0,0 +1,72 @@
import { type DataScope, ErrorCode } from '@lingxi/shared';
import { AppException } from '../../errors/app-exception';
/**
* 请求作用域上下文(技术架构 §5.5):登录用户 + 多角色并集后的可见客户集合。
*
* 解析顺序(技术架构 §7.1):
* 1. JWT / 账号状态由 JwtAuthGuard 校验;
* 2. 角色数据范围 → 客户集合(六类,CustomerDirectory 端口解析);
* 3. 多角色取并集;任一角色为「全部客户」即全部;
* 4. 单个资源请求显式指定客户时,必须落在集合内。
*
* 失败处理:解析失败一律 403(40302),**不降级为全部客户**。
*/
export interface ScopeContext {
userId: string;
/** 是否拥有 is_system 超管角色(隐式全部数据范围) */
isSuperAdmin: boolean;
/** 解析后的数据范围(每个元素带 customerIds;超管为 [{ type: 'all' }]) */
dataScopes: DataScope[];
/** null = 全部客户可见;否则为可见客户 id 集合(已排序去重;A3 前为空数组) */
customerIds: readonly string[] | null;
}
/**
* Repository 客户维度过滤参数(Prisma where 片段)。
* 使用方必须把返回值展开进 where:`where: { ...customerScopeWhere(ctx), ... }`,
* 禁止「先查全量再内存过滤」(AGENTS §3.5)。
*/
export interface CustomerScopeWhere {
customerId?: { in: string[] };
}
/** 是否可见全部客户 */
export function isAllCustomers(ctx: ScopeContext): boolean {
return ctx.customerIds === null;
}
/** 构造客户维度强制过滤片段:全部客户返回空对象,受限范围返回 `{ customerId: { in } }` */
export function customerScopeWhere(ctx: ScopeContext): CustomerScopeWhere {
return ctx.customerIds === null ? {} : { customerId: { in: [...ctx.customerIds] } };
}
/** 判断单个客户是否在范围内(用于单条资源的 404 / 403 语义选择) */
export function isCustomerVisible(ctx: ScopeContext, customerId: string): boolean {
return ctx.customerIds === null || ctx.customerIds.includes(customerId);
}
/**
* 断言对指定客户有写权限;范围外抛 40302(不泄露资源是否存在)。
* 单条**读**接口按 §10.3 应返回 404(避免探测存在性),调用方可先用 isCustomerVisible 判定。
*/
export function assertCustomerAccess(ctx: ScopeContext, customerId: string): void {
if (!isCustomerVisible(ctx, customerId)) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '客户不在当前数据范围之内',
});
}
}
/** 断言存在客户维度上下文(Repository 方法入口守卫,防止漏注入) */
export function requireScopeContext(ctx: ScopeContext | null | undefined): ScopeContext {
if (ctx === null || ctx === undefined) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '缺少数据范围上下文,已拒绝访问',
});
}
return ctx;
}
@@ -0,0 +1,135 @@
import { DATA_SCOPE_TYPES, type DataScope, type DataScopeType, ErrorCode } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../errors/app-exception';
import type { AuthContext } from '../auth.types';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import type { ScopeContext } from './scope-context';
/** 范围声明去重后的内部结构:一个角色一种类型,但多角色可能带来同类型的多个 targetIds */
interface ScopeDeclaration {
type: DataScopeType;
targetIds: string[];
}
function mergeDeclarations(scopes: readonly DataScope[]): ScopeDeclaration[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const scope of scopes) {
const existing = byType.get(scope.type) ?? new Set<string>();
for (const targetId of scope.targetIds ?? []) {
existing.add(targetId);
}
byType.set(scope.type, existing);
}
return [...byType.entries()]
.map(([type, targetIdSet]) => ({
type,
targetIds: [...targetIdSet].sort(),
}))
.sort(
(left, right) => DATA_SCOPE_TYPES.indexOf(left.type) - DATA_SCOPE_TYPES.indexOf(right.type),
);
}
function uniqueSorted(ids: readonly string[]): string[] {
return [...new Set(ids)].sort();
}
/**
* 数据范围解析器(A2-BE-04):把角色声明的六类范围解析为 `ScopeContext`。
*
* - 超管(is_system 角色)隐式全集:`[{ type: 'all' }]`;
* - 任一角色为「全部客户」→ 并集为全部客户;
* - 多个角色取并集(customerIds 去重排序);
* - 没有任何范围声明时按最小权限「本人创建」解析,不放行全部;
* - 端口调用失败 → 40302(不降级)。
*/
@Injectable()
export class ScopeResolver {
constructor(@Inject(CUSTOMER_DIRECTORY) private readonly customers: CustomerDirectory) {}
async resolve(
auth: Pick<AuthContext, 'userId' | 'isSuperAdmin' | 'dataScopes' | 'departmentIds'>,
): Promise<ScopeContext> {
if (auth.isSuperAdmin) {
return {
userId: auth.userId,
isSuperAdmin: true,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
const declarations =
mergeDeclarations(auth.dataScopes).length > 0
? mergeDeclarations(auth.dataScopes)
: [{ type: 'created_by_me' as const, targetIds: [] }];
if (declarations.some((declaration) => declaration.type === 'all')) {
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
try {
return await this.buildScopedContext(auth, declarations);
} catch (error) {
if (error instanceof AppException) {
throw error;
}
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
}
private async buildScopedContext(
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
declarations: readonly ScopeDeclaration[],
): Promise<ScopeContext> {
const union = new Set<string>();
const resolved: DataScope[] = [];
for (const declaration of declarations) {
const ids = uniqueSorted(await this.customerIdsFor(declaration, auth));
for (const id of ids) {
union.add(id);
}
resolved.push({
type: declaration.type,
...(declaration.targetIds.length > 0 ? { targetIds: declaration.targetIds } : {}),
customerIds: ids,
});
}
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: resolved,
customerIds: [...union].sort(),
};
}
private async customerIdsFor(
declaration: ScopeDeclaration,
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
): Promise<string[]> {
switch (declaration.type) {
case 'all':
return [];
case 'customer_groups':
return this.customers.findCustomerGroupCustomerIds(declaration.targetIds);
case 'owned_customers':
return this.customers.findOwnedCustomerIds(auth.userId);
case 'created_by_me':
return this.customers.findCreatedCustomerIds(auth.userId);
case 'department': {
const ownDepartmentId = auth.departmentIds[0];
return ownDepartmentId === undefined
? []
: this.customers.findDepartmentCustomerIds([ownDepartmentId]);
}
case 'department_and_children':
return this.customers.findDepartmentCustomerIds(auth.departmentIds);
}
}
}
@@ -0,0 +1,189 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../errors/app-exception';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import {
assertCustomerAccess,
customerScopeWhere,
isAllCustomers,
isCustomerVisible,
requireScopeContext,
type ScopeContext,
} from './scope-context';
import { ScopeResolver } from './scope-resolver.service';
function directoryMock(): {
directory: CustomerDirectory;
owned: ReturnType<typeof vi.fn>;
groups: ReturnType<typeof vi.fn>;
departments: ReturnType<typeof vi.fn>;
created: ReturnType<typeof vi.fn>;
} {
const owned = vi.fn().mockResolvedValue(['c-owned']);
const groups = vi.fn().mockResolvedValue(['c-group-1']);
const departments = vi.fn().mockResolvedValue(['c-dept']);
const created = vi.fn().mockResolvedValue(['c-created']);
return {
directory: {
findOwnedCustomerIds: owned,
findCustomerGroupCustomerIds: groups,
findDepartmentCustomerIds: departments,
findCreatedCustomerIds: created,
},
owned,
groups,
departments,
created,
};
}
function contextOf(overrides: Partial<ScopeContext> = {}): ScopeContext {
return {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
customerIds: [],
...overrides,
};
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('ScopeResolver', () => {
const baseAuth = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
departmentIds: ['d1', 'd1-child'],
};
it('超管隐式全集:[{ type: all }],不调用客户目录', async () => {
const { directory, owned } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, isSuperAdmin: true });
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
expect(owned).not.toHaveBeenCalled();
});
it('任一角色为全部客户 → 并集为全部客户', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'owned_customers' }, { type: 'all' }],
});
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
});
it('多角色并集:同一类型 targetIds 合并、customerIds 去重排序、按六类固定顺序输出', async () => {
const { directory, groups, departments } = directoryMock();
departments.mockResolvedValue(['c-dept', 'c-dept-2']);
groups.mockResolvedValue(['c-group-1', 'c-group-2']);
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [
{ type: 'customer_groups', targetIds: ['g2'] },
{ type: 'department_and_children' },
{ type: 'customer_groups', targetIds: ['g1'] },
],
});
expect(scope.customerIds).toEqual(['c-dept', 'c-dept-2', 'c-group-1', 'c-group-2']);
expect(scope.dataScopes).toEqual([
{ type: 'department_and_children', customerIds: ['c-dept', 'c-dept-2'] },
{
type: 'customer_groups',
targetIds: ['g1', 'g2'],
customerIds: ['c-group-1', 'c-group-2'],
},
]);
expect(departments).toHaveBeenCalledWith(['d1', 'd1-child']);
});
it('「本部门」只用用户本部门;「指定客户组」透传 targetIds;「负责客户」「本人创建」按用户解析', async () => {
const { directory, groups, departments, owned, created } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'department' }, { type: 'customer_groups', targetIds: ['g1'] }],
});
expect(departments).toHaveBeenCalledWith(['d1']);
expect(groups).toHaveBeenCalledWith(['g1']);
expect(scope.dataScopes[0]?.type).toBe('department');
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] });
expect(owned).toHaveBeenCalledWith('u1');
created.mockResolvedValue([]);
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'created_by_me' }] });
expect(created).toHaveBeenCalledWith('u1');
});
it('没有任何范围声明 → 按最小权限「本人创建」解析,不分全量', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, dataScopes: [] });
expect(scope.customerIds).toEqual(['c-created']);
expect(scope.dataScopes[0]?.type).toBe('created_by_me');
});
it('客户目录端口异常 → 40302,不降级为全部客户', async () => {
const { directory, owned } = directoryMock();
owned.mockRejectedValueOnce(new Error('db down'));
const resolver = new ScopeResolver(directory);
await expectRejected(
resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] }),
ErrorCode.OUT_OF_SCOPE,
);
});
});
describe('scope-context 帮助函数', () => {
it('customerScopeWhere:全部客户返回空片段,受限范围返回 customerId IN', () => {
expect(customerScopeWhere(contextOf({ customerIds: null }))).toEqual({});
expect(customerScopeWhere(contextOf({ customerIds: ['c1', 'c2'] }))).toEqual({
customerId: { in: ['c1', 'c2'] },
});
});
it('isAllCustomers / isCustomerVisible', () => {
expect(isAllCustomers(contextOf({ customerIds: null }))).toBe(true);
expect(isAllCustomers(contextOf())).toBe(false);
expect(isCustomerVisible(contextOf({ customerIds: null }), 'any')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c1')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c2')).toBe(false);
});
it('assertCustomerAccess:范围外抛 40302;requireScopeContext 缺上下文抛 40302', () => {
expect(() => {
assertCustomerAccess(contextOf({ customerIds: null }), 'c1');
}).not.toThrow();
expect(() => {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c1');
}).not.toThrow();
let thrown: unknown;
try {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c2');
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AppException);
expect((thrown as AppException).code).toBe(ErrorCode.OUT_OF_SCOPE);
expect(() => requireScopeContext(null)).toThrow(AppException);
expect(requireScopeContext(contextOf()).userId).toBe('u1');
});
it('CUSTOMER_DIRECTORY 端口 token 唯一', () => {
expect(typeof CUSTOMER_DIRECTORY).toBe('symbol');
});
});
@@ -3,14 +3,20 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { AuditInterceptor } from './audit.interceptor'; import { AuditInterceptor } from './audit.interceptor';
import { PermissionCatalogService } from './catalog/permission-catalog.service';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards'; import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service'; import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context'; import { RequestContextService } from './request-context';
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
/** /**
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。 * 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。 * 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
*/ */
@Global() @Global()
@Module({ @Module({
@@ -18,18 +24,27 @@ import { TokenService } from './token.service';
providers: [ providers: [
RequestContextService, RequestContextService,
PermissionService, PermissionService,
PermissionCatalogService,
PermissionGuard,
ScopeResolver,
TokenService, TokenService,
AuditInterceptor, AuditInterceptor,
JwtAuthGuard, JwtAuthGuard,
PermissionGuard,
SpecialPermissionGuard, SpecialPermissionGuard,
ScopeGuard, ScopeGuard,
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
{ provide: APP_GUARD, useExisting: JwtAuthGuard }, { provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard }, { provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard }, { provide: APP_GUARD, useExisting: SpecialPermissionGuard },
{ provide: APP_GUARD, useExisting: ScopeGuard }, { provide: APP_GUARD, useExisting: ScopeGuard },
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor }, { provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
], ],
exports: [RequestContextService, PermissionService, TokenService], exports: [
RequestContextService,
PermissionService,
PermissionCatalogService,
ScopeResolver,
TokenService,
],
}) })
export class SecurityModule {} export class SecurityModule {}
+46 -13
View File
@@ -11,8 +11,10 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators'; import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards'; import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import type { UserAuthRecord } from './permission.service'; import type { UserAuthRecord } from './permission.service';
import { PermissionService, resolveDataScope } from './permission.service'; import { PermissionService } from './permission.service';
import type { RequestContextService } from './request-context'; import type { RequestContextService } from './request-context';
import type { ScopeContext } from './scope/scope-context';
import type { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
const jwt = new JwtService({}); const jwt = new JwtService({});
@@ -41,7 +43,9 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
status: 'active', status: 'active',
roles: [], roles: [],
permissions: ['user:view'], permissions: ['user:view'],
dataScope: { type: 'department_and_children' }, dataScopes: [{ type: 'department_and_children' }],
departmentIds: ['d1'],
isSuperAdmin: false,
mustChangePassword: false, mustChangePassword: false,
...overrides, ...overrides,
}; };
@@ -110,6 +114,7 @@ function authRecord(
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
children: [],
}, },
roles: roleCodes.map((code, index) => ({ roles: roleCodes.map((code, index) => ({
userId: 'u1', userId: 'u1',
@@ -128,6 +133,7 @@ function authRecord(
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() }, { roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() }, { roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
], ],
dataScopes: [],
}, },
})), })),
}; };
@@ -170,19 +176,31 @@ describe('TokenService', () => {
}); });
}); });
describe('PermissionService / resolveDataScope', () => { describe('PermissionService', () => {
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
expect(resolveDataScope(['super_admin']).type).toBe('all');
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
});
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => { it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
const service = new PermissionService({} as never); const service = new PermissionService({} as never);
const context = service.toAuthContext(authRecord()); const context = service.toAuthContext(authRecord());
expect(context.permissions).toEqual(['user:manage', 'user:view']); expect(context.permissions).toEqual(['user:manage', 'user:view']);
expect(context.status).toBe('active'); expect(context.status).toBe('active');
expect(context.roles).toHaveLength(1); expect(context.roles).toHaveLength(1);
expect(context.dataScope.type).toBe('all'); expect(context.isSuperAdmin).toBe(false);
expect(context.dataScopes).toEqual([]);
expect(context.departmentIds).toEqual(['d1']);
});
it('超管隐式全集:权限点 / 特殊权限不依赖 role_permissions', () => {
const record = authRecord('ACTIVE', ['super_admin']);
const role = record.roles[0];
if (role === undefined) {
throw new Error('fixture 缺少角色');
}
role.role.permissions = [];
const service = new PermissionService({} as never);
const context = service.toAuthContext(record);
expect(context.isSuperAdmin).toBe(true);
expect(context.permissions).toHaveLength(30);
expect(context.permissions).toContain('audit:export');
expect(context.dataScopes).toEqual([{ type: 'all' }]);
}); });
it('resolveAuth 用户不存在返回 null', async () => { it('resolveAuth 用户不存在返回 null', async () => {
@@ -198,7 +216,7 @@ describe('PermissionService / resolveDataScope', () => {
const service = new PermissionService(prisma as never); const service = new PermissionService(prisma as never);
const context = await service.resolveAuth('u1'); const context = await service.resolveAuth('u1');
expect(context?.userId).toBe('u1'); expect(context?.userId).toBe('u1');
expect(context?.dataScope.type).toBe('department_and_children'); expect(context?.isSuperAdmin).toBe(false);
expect(context?.roles[0]?.code).toBe('designer'); expect(context?.roles[0]?.code).toBe('designer');
}); });
}); });
@@ -299,11 +317,26 @@ describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
expect(granted.canActivate(contextOf(request))).toBe(true); expect(granted.canActivate(contextOf(request))).toBe(true);
}); });
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => { it('未登录时普通守卫返回 40101;ScopeGuard 解析并注入 scope', async () => {
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never); const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED); expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true); const scope: ScopeContext = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [{ type: 'owned_customers', customerIds: [] }],
customerIds: [],
};
const resolver = { resolve: vi.fn().mockResolvedValue(scope) } as unknown as ScopeResolver;
const scopeGuard = new ScopeGuard(reflectorFor({}) as never, resolver, {
patch: vi.fn(),
} as unknown as RequestContextService);
const scopedRequest = { auth } as AuthenticatedRequest;
await expect(scopeGuard.canActivate(contextOf(scopedRequest))).resolves.toBe(true);
expect(scopedRequest.scope).toEqual(scope);
// 未登录(无 auth)直接放行,由 JwtAuthGuard 负责 401
await expect(scopeGuard.canActivate(contextOf({}))).resolves.toBe(true);
}); });
it('@Audit 元数据可读取', () => { it('@Audit 元数据可读取', () => {
@@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception'; import { AppException } from '../../common/errors/app-exception';
import type { PermissionService } from '../../common/security/permission.service'; 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 { TokenService } from '../../common/security/token.service';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import { testConfig } from '../../testing/test-app'; import { testConfig } from '../../testing/test-app';
@@ -102,16 +103,19 @@ function harness(): Harness {
status: 'active', status: 'active',
roles: [], roles: [],
permissions: ['user:manage'], permissions: ['user:manage'],
dataScope: { type: 'all' }, dataScopes: [{ type: 'all' }],
departmentIds: ['d1'],
isSuperAdmin: true,
mustChangePassword: false, mustChangePassword: false,
}), }),
}; };
const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) };
const me = { const me = {
getMe: vi.fn().mockResolvedValue({ getMe: vi.fn().mockResolvedValue({
user: { id: 'u1' }, user: { id: 'u1' },
roles: [], roles: [],
permissions: [], permissions: [],
dataScope: { type: 'all' }, dataScopes: [{ type: 'all' }],
mustChangePassword: false, mustChangePassword: false,
}), }),
}; };
@@ -122,6 +126,7 @@ function harness(): Harness {
passwords, passwords,
failures as unknown as LoginFailureService, failures as unknown as LoginFailureService,
permissions as unknown as PermissionService, permissions as unknown as PermissionService,
scopes as unknown as ScopeResolver,
me as unknown as MeService, me as unknown as MeService,
); );
return { service, prisma, failures, passwords, permissions, me }; 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 { AppException } from '../../common/errors/app-exception';
import type { AuthRequestMeta } from '../../common/security/auth.types'; import type { AuthRequestMeta } from '../../common/security/auth.types';
import { PermissionService } from '../../common/security/permission.service'; import { PermissionService } from '../../common/security/permission.service';
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
import { TokenService } from '../../common/security/token.service'; import { TokenService } from '../../common/security/token.service';
import { APP_CONFIG } from '../../config/config.module'; import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env'; import type { AppConfig } from '../../config/env';
@@ -33,6 +34,7 @@ export class AuthService {
@Inject(PasswordService) private readonly passwords: PasswordService, @Inject(PasswordService) private readonly passwords: PasswordService,
@Inject(LoginFailureService) private readonly failures: LoginFailureService, @Inject(LoginFailureService) private readonly failures: LoginFailureService,
@Inject(PermissionService) private readonly permissions: PermissionService, @Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
@Inject(MeService) private readonly me: MeService, @Inject(MeService) private readonly me: MeService,
) {} ) {}
@@ -61,8 +63,12 @@ export class AuthService {
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip }, data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
}); });
const auth = await this.permissions.resolveAuth(user.id); 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 { 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 }; return { ...pair, me };
} }
@@ -19,11 +19,18 @@ interface FixtureUser {
account: string; account: string;
} }
interface MeBody {
permissions: string[];
dataScopes: { type: string; customerIds?: string[] }[];
user: { id: string; phone?: string };
mustChangePassword: boolean;
}
interface TokenBody { interface TokenBody {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn: number;
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean }; me: MeBody;
} }
interface ErrorBody { interface ErrorBody {
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
await prisma.userRole.create({ data: { userId, roleId: role.id } }); 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)', () => { describe('identity 集成(真实 PG / Redis)', () => {
let created: CreatedApiApp; let created: CreatedApiApp;
let manager: FixtureUser; let manager: FixtureUser;
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
created = await createApp(); created = await createApp();
await created.app.init(); await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); 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'); manager = await createFixtureUser('manager', 'platform_admin');
viewer = await createFixtureUser('viewer', 'designer'); viewer = await createFixtureUser('viewer', 'designer');
}); });
afterAll(async () => { afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); 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 prisma.$disconnect();
await created.app.close(); await created.app.close();
}); });
@@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => {
expect(roles.statusCode).toBe(200); expect(roles.statusCode).toBe(200);
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8); 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 { MeController } from './me.controller';
import { MeService } from './me.service'; import { MeService } from './me.service';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
import { PermissionsController } from './permissions.controller';
import { RolesController } from './roles.controller'; import { RolesController } from './roles.controller';
import { RolesService } from './roles.service'; import { RolesService } from './roles.service';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
@@ -24,6 +25,7 @@ import { UsersService } from './users.service';
UsersController, UsersController,
DepartmentsController, DepartmentsController,
RolesController, RolesController,
PermissionsController,
], ],
providers: [ providers: [
AuthService, AuthService,
@@ -2,17 +2,19 @@ import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception'; 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 type { PrismaService } from '../../infra/prisma/prisma.service';
import { DepartmentsService } from './departments.service'; import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service'; import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service'; import { MeService } from './me.service';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
import { RolesService } from './roles.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'; import { UsersService } from './users.service';
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations { function userFixture(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
const base: UserWithRelations = { const base: UserAuthRecord = {
id: 'u1', id: 'u1',
account: 'chen.mo', account: 'chen.mo',
email: 'chen.mo@lingxi.cn', email: 'chen.mo@lingxi.cn',
@@ -51,6 +53,7 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
children: [],
}, },
roles: [ roles: [
{ {
@@ -66,6 +69,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
status: 'ACTIVE', status: 'ACTIVE',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
permissions: [],
dataScopes: [],
}, },
}, },
{ {
@@ -81,6 +86,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
status: 'DISABLED', status: 'DISABLED',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
permissions: [],
dataScopes: [],
}, },
}, },
], ],
@@ -357,43 +364,79 @@ describe('DepartmentsService', () => {
}); });
describe('MeService / RolesService', () => { 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 prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = new MeService(prisma as unknown as PrismaService); 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.user.phone).toBe('13866202046');
expect(me.permissions).toEqual(['user:manage']); expect(me.permissions).toEqual(['user:manage']);
expect(me.roles).toHaveLength(1); 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(); expect(meWithout.user.phone).toBeUndefined();
await expectRejected( await expectRejected(
new MeService({ new MeService({
user: { findUnique: vi.fn().mockResolvedValue(null) }, user: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService).getMe('u1', []), } as unknown as PrismaService).getMe('u1', [], scope),
ErrorCode.UNAUTHENTICATED, ErrorCode.UNAUTHENTICATED,
); );
await expectRejected(
new MeService(prisma as unknown as PrismaService).getMe('u1', [], null),
ErrorCode.OUT_OF_SCOPE,
);
}); });
it('角色列表只返回启用角色', async () => { it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => {
const prisma = { const prisma = {
role: { role: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
{ {
id: 'r1', id: 'r1',
code: 'super_admin', code: 'reviewer',
name: '超级管理员', name: '审核员',
description: '内置', description: '审核',
isSystem: true, isSystem: false,
status: 'ACTIVE', status: 'ACTIVE',
createdAt: new Date(), createdAt: new Date('2026-11-01T00:00:00.000Z'),
updatedAt: new Date(), 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(); const service = new RolesService(
expect(roles).toEqual([ prisma as unknown as PrismaService,
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' }, { 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 { AppException } from '../../common/errors/app-exception';
import type { AuthContext } from '../../common/security/auth.types'; 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'; import { MeService } from './me.service';
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */ /** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */
@ApiTags('identity-me') @ApiTags('identity-me')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('me') @Controller('me')
@@ -15,11 +16,14 @@ export class MeController {
constructor(@Inject(MeService) private readonly me: MeService) {} constructor(@Inject(MeService) private readonly me: MeService) {}
@Get() @Get()
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' }) @ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' })
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> { async current(
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<MeResponse> {
if (auth === null) { if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); 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 { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../common/errors/app-exception'; import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service'; import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service';
import { resolveDataScope } from '../../common/security/permission.service'; import type { ScopeContext } from '../../common/security/scope/scope-context';
import { activeRolesOf } from '../../common/security/permission.service';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens'; import { PRISMA } from '../../infra/tokens';
import { toRoleSummary, toUserSummary } from './user.mapper'; import { toRoleSummary, toUserSummary } from './user.mapper';
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */ /**
* /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。
* `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。
*/
@Injectable() @Injectable()
export class MeService { export class MeService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} 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); const user = await this.findUser(userId);
if (user === null) { if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' }); throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
} }
if (scope === null) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
const roles = activeRolesOf(user); const roles = activeRolesOf(user);
const canSeePhone = permissions.includes(Permission.USER_MANAGE); const canSeePhone = permissions.includes(Permission.USER_MANAGE);
return { return {
@@ -28,7 +40,7 @@ export class MeService {
}, },
roles: roles.map((item) => toRoleSummary(item.role)), roles: roles.map((item) => toRoleSummary(item.role)),
permissions, permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)), dataScopes: scope.dataScopes,
mustChangePassword: user.mustChangePassword, mustChangePassword: user.mustChangePassword,
}; };
} }
@@ -38,8 +50,8 @@ export class MeService {
return this.prisma.user.findUnique({ return this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
include: { include: {
department: { include: { parent: true } }, department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: 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 {
import { Controller, Get, Inject } from '@nestjs/common'; 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 { 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'; 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') @ApiTags('identity-roles')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('roles') @Controller('roles')
@@ -13,9 +51,59 @@ export class RolesController {
constructor(@Inject(RolesService) private readonly roles: RolesService) {} constructor(@Inject(RolesService) private readonly roles: RolesService) {}
@Get() @Get()
@RequirePermissions(Permission.USER_VIEW) @RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW)
@ApiOperation({ summary: '角色列表(绑定用)' }) @ApiOperation({
async list(): Promise<ListResponse<RoleSummary>> { summary: '角色列表(游标分页 + 筛选 + 排序)',
return { items: await this.roles.list(), next_cursor: null }; 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 { 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 type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens'; 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() @Injectable()
export class RolesService { 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[]> { async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
const roles = await this.prisma.role.findMany({ const where: Prisma.RoleWhereInput = {};
where: { status: 'ACTIVE' }, if (query.status !== 'all') {
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }], 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, ErrorCode,
type ListResponse, type ListResponse,
Permission, Permission,
SUPER_ADMIN_SOFT_LIMIT,
ulid, ulid,
type UpdateUserInput, type UpdateUserInput,
type UserDetail, type UserDetail,
type UserListQuery, type UserListQuery,
type UserSummary, type UserSummary,
} from '@lingxi/shared'; } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception'; import { AppException } from '../../common/errors/app-exception';
@@ -31,6 +32,8 @@ function hasPermission(permissions: string[], code: string): boolean {
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */ /** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
@Injectable() @Injectable()
export class UsersService { export class UsersService {
private readonly logger = new Logger('UsersService');
constructor( constructor(
@Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(PasswordService) private readonly passwords: PasswordService, @Inject(PasswordService) private readonly passwords: PasswordService,
@@ -90,6 +93,7 @@ export class UsersService {
} catch (error) { } catch (error) {
throw this.mapUniqueError(error); throw this.mapUniqueError(error);
} }
await this.warnIfSuperAdminExceeded(input.roleIds);
return this.detail(id, permissions); return this.detail(id, permissions);
} }
@@ -125,6 +129,9 @@ export class UsersService {
} catch (error) { } catch (error) {
throw this.mapUniqueError(error); throw this.mapUniqueError(error);
} }
if (input.roleIds !== undefined) {
await this.warnIfSuperAdminExceeded(input.roleIds);
}
return this.detail(id, permissions); 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> { private async findOrThrow(id: string): Promise<UserWithRelations> {
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE }); const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
if (user === null) { if (user === null) {
+2
View File
@@ -20,4 +20,6 @@ export const ENDPOINTS = {
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`, `${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
departments: '/departments/tree', departments: '/departments/tree',
roles: '/roles', roles: '/roles',
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
permissions: '/permissions',
} as const; } as const;
+139 -2
View File
@@ -85,7 +85,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** 当前用户、角色、权限点、数据范围 */ /** 当前用户、角色、权限点、数据范围(多角色并集) */
get: operations['MeController_current']; get: operations['MeController_current'];
put?: never; put?: never;
post?: never; post?: never;
@@ -258,9 +258,61 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** 角色列表(绑定用) */ /**
* 角色列表(游标分页 + 筛选 + 排序)
* @description 默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围
*/
get: operations['RolesController_list']; get: operations['RolesController_list'];
put?: never; put?: never;
/**
* 新建角色
* @description 权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予
*/
post: operations['RolesController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/roles/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 角色详情(权限点、特殊权限、数据范围、绑定用户) */
get: operations['RolesController_detail'];
put?: never;
post?: never;
/**
* 删除角色
* @description 系统角色拒绝;有绑定用户(40903)只能停用
*/
delete: operations['RolesController_remove'];
options?: never;
head?: never;
/**
* 更新角色 / 启停
* @description 系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示
*/
patch: operations['RolesController_update'];
trace?: never;
};
'/api/v1/permissions': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 权限点目录(10 模块树 + 特殊权限 + SoD 对)
* @description 模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予
*/
get: operations['PermissionsController_getTree'];
put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
options?: never; options?: never;
@@ -671,6 +723,91 @@ export interface operations {
}; };
}; };
}; };
RolesController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RolesController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RolesController_remove: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RolesController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
PermissionsController_getTree: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
HealthController_check: { HealthController_check: {
parameters: { parameters: {
query?: never; query?: never;
+1 -1
View File
@@ -42,7 +42,7 @@ const me = {
user: userSummary, user: userSummary,
roles: userSummary.roles, roles: userSummary.roles,
permissions: ['user:view'], permissions: ['user:view'],
dataScope: { type: 'all' }, dataScopes: [{ type: 'all' }],
mustChangePassword: false, mustChangePassword: false,
}; };
+4
View File
@@ -1,3 +1,4 @@
import type { RoleListQueryInput } from './roles';
import type { UserListQueryInput } from './users'; import type { UserListQueryInput } from './users';
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */ /** TanStack Query 缓存键(唯一出处,避免散落字符串) */
@@ -9,4 +10,7 @@ export const queryKeys = {
userCustomers: (userId: string) => ['users', 'customers', userId] as const, userCustomers: (userId: string) => ['users', 'customers', userId] as const,
departments: ['departments'] as const, departments: ['departments'] as const,
roles: ['roles'] as const, roles: ['roles'] as const,
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
role: (roleId: string) => ['roles', 'detail', roleId] as const,
permissions: ['permissions'] as const,
}; };
+210
View File
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import {
createRole,
deleteRole,
fetchPermissionTree,
fetchRoleDetail,
fetchRoleList,
toRoleListSearchParams,
updateRole,
} from './roles';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const roleListItem = {
id: ROLE_ID,
code: 'reviewer',
name: '审核员',
description: '脚本与素材审核',
isSystem: false,
status: 'active',
permissionCount: 4,
specialPermissionCount: 0,
userCount: 3,
dataScope: { type: 'department_and_children' },
createdAt: '2026-10-27T02:40:00.000Z',
updatedAt: '2026-10-27T02:40:00.000Z',
};
const roleDetail = {
...roleListItem,
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
specialPermissions: [],
boundUsers: [
{
id: USER_ID,
name: '赵一诺',
account: 'zhao.yinuo',
phoneMasked: '136****3188',
departmentName: '内容生产中心 / 素材二组',
status: 'active',
},
],
};
const permissionTree = {
modules: [
{
module: 'script',
moduleLabel: '脚本',
sortOrder: 10,
permissions: [
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
{ code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false },
],
},
{
module: 'delivery',
moduleLabel: '导出交付',
sortOrder: 40,
permissions: [
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
},
],
specialPermissions: [
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
sodPairs: [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '同一角色不得同时拥有',
permissions: ['script:write', 'script:review'],
},
],
totalPermissions: 4,
};
const saveResult = {
role: roleDetail,
affectedUserCount: 3,
warnings: [],
};
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
function bodyOf(init: RequestInit | undefined): unknown {
return typeof init?.body === 'string' ? JSON.parse(init.body) : null;
}
describe('角色列表请求参数', () => {
it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => {
const params = toRoleListSearchParams({
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
keyword: ' 审核 ',
});
expect(params.toString()).toBe(
'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc',
);
const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' });
expect(empty.has('keyword')).toBe(false);
expect(empty.has('cursor')).toBe(false);
});
it('带游标时完整输出', () => {
const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' });
expect(params.get('cursor')).toBe('cursor-2');
expect(params.get('status')).toBe('disabled');
});
});
describe('角色 API 客户端', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' },
permissions: ['role:view'],
});
vi.restoreAllMocks();
});
it('解析角色列表信封与详情', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' }))
.mockResolvedValueOnce(jsonResponse(roleDetail));
const list = await fetchRoleList({ limit: 100, status: 'all' });
expect(list.items).toHaveLength(1);
expect(list.next_cursor).toBe('cursor-2');
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all');
const detail = await fetchRoleDetail(ROLE_ID);
expect(detail.permissions).toContain('script:review');
expect(detail.boundUsers[0]?.name).toBe('赵一诺');
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`);
});
it('解析权限点目录', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree));
const tree = await fetchPermissionTree();
expect(tree.totalPermissions).toBe(4);
expect(tree.specialPermissions[0]?.code).toBe('delivery:export');
});
it('新建角色以 camelCase body 提交并解析保存结果', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
const result = await createRole({
name: '审核员',
description: '脚本与素材审核',
permissions: ['script:read', 'script:review'],
dataScope: { type: 'department_and_children' },
});
expect(result.affectedUserCount).toBe(3);
const init = fetchMock.mock.calls[0]?.[1];
expect(init?.method).toBe('POST');
expect(bodyOf(init)).toEqual({
name: '审核员',
description: '脚本与素材审核',
permissions: ['script:read', 'script:review'],
dataScope: { type: 'department_and_children' },
});
});
it('更新角色状态与停用原因', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' });
const init = fetchMock.mock.calls[0]?.[1];
expect(init?.method).toBe('PATCH');
expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' });
});
it('删除角色返回 204', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(null, { status: 204 }));
await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined();
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
});
it('响应用共享 schema 校验:非法权限点编码直接报错', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }),
);
await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow();
});
});
+82
View File
@@ -0,0 +1,82 @@
import {
type CreateRoleInput,
createRoleInputSchema,
type PermissionTree,
permissionTreeSchema,
type RoleDetail,
roleDetailSchema,
type RoleListQuery,
type RoleListResponse,
roleListResponseSchema,
type RoleSaveResult,
roleSaveResultSchema,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
/** 角色管理列表查询(query 参数 snake_case;角色管理页始终传全量状态) */
export interface RoleListQueryInput extends Partial<RoleListQuery> {
limit: number;
cursor?: string;
/** 排除指定角色 id(模板 / 复制来源场景由前端过滤,接口不感知) */
excludeRoleId?: string;
}
/** 组装 GET /roles 的 query 参数;空值不落 query,避免后端严格校验报错 */
export function toRoleListSearchParams(query: RoleListQueryInput): URLSearchParams {
const params = new URLSearchParams();
params.set('limit', String(query.limit));
if (query.cursor !== undefined && query.cursor !== '') {
params.set('cursor', query.cursor);
}
if (query.keyword !== undefined && query.keyword.trim() !== '') {
params.set('keyword', query.keyword.trim());
}
if (query.status !== undefined) {
params.set('status', query.status);
}
if (query.sort !== undefined) {
params.set('sort', query.sort);
}
if (query.order !== undefined) {
params.set('order', query.order);
}
return params;
}
export async function fetchRoleList(query: RoleListQueryInput): Promise<RoleListResponse> {
const search = toRoleListSearchParams(query).toString();
const path = search === '' ? ENDPOINTS.roles : `${ENDPOINTS.roles}?${search}`;
const payload: unknown = await request(path);
return roleListResponseSchema.parse(payload);
}
export async function fetchRoleDetail(roleId: string): Promise<RoleDetail> {
const payload: unknown = await request(ENDPOINTS.role(roleId));
return roleDetailSchema.parse(payload);
}
/** GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD(与后端同源) */
export async function fetchPermissionTree(): Promise<PermissionTree> {
const payload: unknown = await request(ENDPOINTS.permissions);
return permissionTreeSchema.parse(payload);
}
export async function createRole(input: CreateRoleInput): Promise<RoleSaveResult> {
const body = createRoleInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.roles, { method: 'POST', body });
return roleSaveResultSchema.parse(payload);
}
export async function updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSaveResult> {
const body = updateRoleInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.role(roleId), { method: 'PATCH', body });
return roleSaveResultSchema.parse(payload);
}
export async function deleteRole(roleId: string): Promise<void> {
await request<unknown>(ENDPOINTS.role(roleId), { method: 'DELETE' });
}
+98
View File
@@ -0,0 +1,98 @@
import type {
CreateRoleInput,
PermissionTree,
RoleDetail,
RoleListResponse,
RoleSaveResult,
UpdateRoleInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { queryKeys } from '@/api/query-keys';
import {
createRole,
deleteRole,
fetchPermissionTree,
fetchRoleDetail,
fetchRoleList,
type RoleListQueryInput,
updateRole,
} from '@/api/roles';
export function useRoleListQuery(query: RoleListQueryInput): UseQueryResult<RoleListResponse> {
return useQuery({
queryKey: queryKeys.roleList(query),
queryFn: () => fetchRoleList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useRoleDetailQuery(roleId: string | null): UseQueryResult<RoleDetail> {
return useQuery({
queryKey: queryKeys.role(roleId ?? ''),
queryFn: () => fetchRoleDetail(roleId ?? ''),
enabled: roleId !== null && roleId !== '',
});
}
/** 权限点目录(10 模块 / 30 点 / 6 特殊 / 3 对 SoD):登录后长期复用 */
export function usePermissionTreeQuery(): UseQueryResult<PermissionTree> {
return useQuery({
queryKey: queryKeys.permissions,
queryFn: fetchPermissionTree,
staleTime: 30 * 60_000,
});
}
function useInvalidateRoles(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.roles });
};
}
export function useCreateRoleMutation(): UseMutationResult<RoleSaveResult, Error, CreateRoleInput> {
const invalidate = useInvalidateRoles();
return useMutation({
mutationFn: (input: CreateRoleInput) => createRole(input),
onSuccess: async (result) => {
await invalidate();
return result;
},
});
}
export function useUpdateRoleMutation(): UseMutationResult<
RoleSaveResult,
Error,
{ roleId: string; input: UpdateRoleInput }
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ roleId, input }: { roleId: string; input: UpdateRoleInput }) =>
updateRole(roleId, input),
onSuccess: (result) => {
queryClient.setQueryData(queryKeys.role(result.role.id), result.role);
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
},
});
}
export function useDeleteRoleMutation(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (roleId: string) => deleteRole(roleId),
onSuccess: (_result, roleId) => {
queryClient.removeQueries({ queryKey: queryKeys.role(roleId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
},
});
}
+5 -2
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { getUserSectionTrail } from '@/navigation/breadcrumbs'; import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps { export interface PageBreadcrumbsProps {
@@ -9,7 +9,10 @@ export interface PageBreadcrumbsProps {
} }
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode { export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname); const entries =
getUserSectionTrail(pathname) ??
getRoleSectionTrail(pathname) ??
getBreadcrumbs(NAV_MODULES, pathname);
return ( return (
<nav className="lx-breadcrumbs" aria-label="面包屑"> <nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => { {entries.map((entry, index) => {
+2
View File
@@ -6,6 +6,8 @@ import '@/styles/global.css';
import '@/styles/layout.css'; import '@/styles/layout.css';
import '@/styles/pages.css'; import '@/styles/pages.css';
import '@/styles/users.css'; import '@/styles/users.css';
import '@/styles/roles.css';
import '@/styles/role-editor.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui'; import { localeZhCN } from '@lingxi/ui';
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
describe('面包屑子路由补全', () => {
it('角色新建页:工作台 / 管理中心 / 角色权限 / 新建角色', () => {
const trail = getRoleSectionTrail('/system/roles/new');
expect(trail?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'角色权限',
'新建角色',
]);
expect(trail?.[2]?.path).toBe('/system/roles');
});
it('角色编辑器:末级为角色编辑', () => {
const trail = getRoleSectionTrail('/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ');
expect(trail?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'角色权限',
'角色编辑',
]);
});
it('角色列表页由导航配置派生,不走子路由补全', () => {
expect(getRoleSectionTrail('/system/roles')).toBeNull();
});
it('用户子路由补全不受影响', () => {
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
'工作台',
'管理中心',
'用户与组织',
'新建用户',
]);
expect(getUserSectionTrail('/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')).not.toBeNull();
});
});
+21
View File
@@ -2,6 +2,27 @@ import { ROUTES } from '@/routes';
import type { BreadcrumbEntry } from './nav-config'; import type { BreadcrumbEntry } from './nav-config';
/** 角色模块下的子路由(新建 / 编辑器)不在 nav-config 的菜单项里,这里补齐面包屑 */
export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null {
if (pathname === ROUTES.system.roleNew) {
return [
{ key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles },
{ key: 'role-new', label: '新建角色' },
];
}
if (/^\/system\/roles\/[^/]+$/.test(pathname)) {
return [
{ key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles },
{ key: 'role-detail', label: '角色编辑' },
];
}
return null;
}
/** /**
* 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里, * 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
* 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。 * 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。
@@ -33,6 +33,17 @@ describe('导航配置', () => {
); );
}); });
it('角色权限菜单使用 role:view,只读角色也能进入角色页', () => {
const rolesItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
(item) => item.key === 'roles',
);
expect(rolesItem?.permission).toBe('role:view');
expect(findActiveNav(NAV_MODULES, '/system/roles/new')?.item?.label).toBe('角色权限');
expect(findActiveNav(NAV_MODULES, '/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
'system',
);
});
it('按路径定位当前模块与菜单', () => { it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review'); const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material'); expect(active?.module.key).toBe('material');
+1 -1
View File
@@ -259,7 +259,7 @@ export const NAV_MODULES: readonly NavModule[] = [
label: '角色权限', label: '角色权限',
path: '/system/roles', path: '/system/roles',
icon: IconShield, icon: IconShield,
permission: Permission.ROLE_MANAGE, permission: Permission.ROLE_VIEW,
description: '角色、权限点与职责互斥', description: '角色、权限点与职责互斥',
}, },
{ {
@@ -43,7 +43,7 @@ function meWith(mustChangePassword: boolean): MeResponse {
}, },
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }], roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'], permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' }, dataScopes: [{ type: 'all' }],
mustChangePassword, mustChangePassword,
}; };
} }
@@ -0,0 +1,97 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import { Button, SideSheet } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { IconCheck, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { scopeLabelOf } from './role-display';
export interface PermissionCatalogDrawerProps {
visible: boolean;
tree: PermissionTree | undefined;
role: RoleDetail | null;
onClose: () => void;
}
/** 权限点目录抽屉:只读展示 30 个权限点与当前角色的持有情况 */
export function PermissionCatalogDrawer({
visible,
tree,
role,
onClose,
}: PermissionCatalogDrawerProps): ReactNode {
const granted = new Set<string>([
...(role?.permissions ?? []),
...(role?.specialPermissions ?? []),
]);
return (
<SideSheet
visible={visible}
title="权限点目录"
width={480}
placement="right"
onCancel={onClose}
footer={
<div className="lx-role-catalog__foot">
<span>权限点变更即时对绑定用户生效</span>
<Button onClick={onClose}>关闭</Button>
</div>
}
>
<div className="lx-role-catalog">
<p className="lx-role-catalog__sub">
{tree?.totalPermissions ?? 30} 个权限点 · {tree?.specialPermissions.length ?? 6}{' '}
个特殊权限;★ 默认关闭、需单独授予。
{role === null
? ' 当前角色尚未加载。'
: ` 当前勾选为「${role.name}」持有(${String(role.permissionCount + role.specialPermissionCount)} 个)。`}
</p>
{tree === undefined ? (
<p className="lx-role-catalog__empty">权限点目录加载中…</p>
) : (
tree.modules.map((module) => (
<section key={module.module} className="lx-role-catalog__module">
<header className="lx-role-catalog__module-head">
<b>{module.moduleLabel}</b>
<span>
{module.permissions.filter((permission) => granted.has(permission.code)).length} /{' '}
{module.permissions.length}
</span>
</header>
{module.permissions.map((permission) => {
const owned = granted.has(permission.code);
return (
<div
key={permission.code}
className={owned ? 'lx-role-catalog__row is-granted' : 'lx-role-catalog__row'}
>
<span className="lx-role-catalog__check" aria-hidden="true">
{owned ? <IconCheck size={12} /> : null}
</span>
<span className="lx-role-catalog__copy">
<span className="lx-role-catalog__name">
{permission.name}
{permission.isSpecial ? (
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
) : null}
</span>
<span className="lx-role-catalog__desc">{permission.description}</span>
</span>
<code className="lx-role-catalog__code">{permission.code}</code>
</div>
);
})}
</section>
))
)}
<div className="lx-role-catalog__note">
<IconStar size={14} />
<span>
特殊权限 6 项与普通权限分开授予,不随角色复制带入;当前角色数据范围为「
{role === null ? '—' : scopeLabelOf(role.dataScope.type)}」。
</span>
</div>
</div>
</SideSheet>
);
}
@@ -0,0 +1,176 @@
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import {
PERMISSION_CATALOG,
permissionsByModule,
permissionTreeSchema,
roleDetailSchema,
SOD_PAIRS,
} from '@lingxi/shared';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RoleDetailPanel } from './RoleDetailPanel';
vi.mock('@lingxi/ui', () => ({
PageEmpty: ({ title }: { title?: string }) => <div data-testid="page-empty">{title}</div>,
ErrorState: ({ title }: { title?: string }) => <div data-testid="error-state">{title}</div>,
PageSkeleton: () => <div data-testid="page-skeleton" />,
}));
vi.mock('@douyinfe/semi-ui-19', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
function roleNode(permission: (typeof PERMISSION_CATALOG)[number]): {
code: string;
name: string;
description: string;
isSpecial: boolean;
} {
return {
code: permission.code,
name: permission.name,
description: permission.description,
isSpecial: permission.isSpecial,
};
}
const tree: PermissionTree = permissionTreeSchema.parse({
modules: permissionsByModule().map((module) => ({
module: module.module,
moduleLabel: module.moduleLabel,
sortOrder: module.sortOrder,
permissions: module.permissions.map(roleNode),
})),
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(roleNode),
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
});
const detail: RoleDetail = roleDetailSchema.parse({
id: ROLE_ID,
code: 'account_owner',
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
isSystem: false,
status: 'active',
permissionCount: 2,
specialPermissionCount: 1,
userCount: 4,
dataScope: { type: 'customer_groups', targetIds: [] },
permissions: ['script:read', 'script:review'],
specialPermissions: ['delivery:export'],
boundUsers: [
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ3',
name: '陈默',
account: 'chen.mo',
phoneMasked: '138****2046',
departmentName: '内容生产中心 / 素材一组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ4',
name: '高岑',
account: 'gao.cen',
phoneMasked: '186****4402',
departmentName: '交付中心 / 客户成功组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ5',
name: '江岚',
account: 'jiang.lan',
phoneMasked: '159****3320',
departmentName: '平台技术部 / 平台运维组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ6',
name: '何岸',
account: 'he.an',
phoneMasked: '135****6641',
departmentName: '交付中心 / 客户成功组',
status: 'disabled',
},
],
createdAt: '2026-10-25T01:30:00.000Z',
updatedAt: '2026-10-25T01:30:00.000Z',
});
function renderPanel(overrides: Partial<Parameters<typeof RoleDetailPanel>[0]> = {}): void {
render(
<RoleDetailPanel
roleId={ROLE_ID}
detail={detail}
tree={tree}
loading={false}
error={null}
canManage
onRetry={vi.fn()}
onEdit={vi.fn()}
onCopy={vi.fn()}
onRename={vi.fn()}
onDisable={vi.fn()}
onEnable={vi.fn()}
onOpenCatalog={vi.fn()}
{...overrides}
/>,
);
}
afterEach(() => {
cleanup();
});
describe('角色详情面板', () => {
it('渲染头部、权限摘要、数据范围与特殊权限', () => {
renderPanel();
expect(screen.getByText('客户负责人')).toBeTruthy();
expect(screen.getByText('启用')).toBeTruthy();
expect(screen.getByText('account_owner')).toBeTruthy();
expect(screen.getByText('指定客户组')).toBeTruthy();
expect(screen.getByText('发起导出')).toBeTruthy();
expect(document.querySelector('.lx-role-detail__module-count')?.textContent).toContain('2 / 3');
expect(document.querySelector('.lx-role-detail__meta')?.textContent).toContain('4 名');
});
it('绑定用户默认展示前 3 名,可展开全部', () => {
renderPanel();
expect(screen.getByText('陈默')).toBeTruthy();
expect(screen.queryByText('何岸')).toBeNull();
fireEvent.click(screen.getByText('查看全部 4 名'));
expect(screen.getByText('何岸')).toBeTruthy();
expect(screen.getByText('收起')).toBeTruthy();
expect(screen.queryByText('查看全部 4 名')).toBeNull();
});
it('无管理权限时不渲染复制 / 停用等写控件', () => {
renderPanel({ canManage: false });
expect(screen.queryByText('复制')).toBeNull();
expect(screen.queryByText('停用')).toBeNull();
expect(screen.getByText('编辑角色')).toBeTruthy();
});
it('未选择角色时展示空态', () => {
renderPanel({ roleId: null, detail: undefined });
expect(screen.getByTestId('page-empty')).toBeTruthy();
});
it('详情加载失败时展示错误态并可重试', () => {
renderPanel({ detail: undefined, error: new Error('boom') });
expect(screen.getByTestId('error-state').textContent).toContain('角色详情加载失败');
});
});
@@ -0,0 +1,367 @@
/* eslint-disable max-lines-per-function -- 详情面板按头部/摘要/范围/特殊/绑定用户分区,集中便于对齐原型 */
import { Button } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
import { IconCopy, IconEdit, IconPause, IconPlay, IconShield, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import {
buildPermissionMetaMap,
formatRoleDateTime,
permissionNameOf,
scopeDescriptionOf,
scopeLabelOf,
summarizePermissionModules,
} from './role-display';
import { RoleStatusTag } from './RoleStatusTag';
export interface RoleDetailPanelProps {
roleId: string | null;
detail: RoleDetail | undefined;
tree: PermissionTree | undefined;
loading: boolean;
error: Error | null;
canManage: boolean;
onRetry: () => void;
onEdit: (roleId: string) => void;
onCopy: (role: RoleDetail) => void;
onRename: (role: RoleDetail) => void;
onDisable: (role: RoleDetail) => void;
onEnable: (role: RoleDetail) => void;
onOpenCatalog: (role: RoleDetail) => void;
}
function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
return (
<span
className={
status === 'active' ? 'lx-role-detail__avatar' : 'lx-role-detail__avatar is-disabled'
}
aria-hidden="true"
>
<IconShield size={22} />
</span>
);
}
function DetailHead({
role,
canManage,
onCopy,
onRename,
onDisable,
onEnable,
}: Pick<RoleDetailPanelProps, 'canManage' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'> & {
role: RoleDetail;
}): ReactNode {
return (
<header className="lx-role-detail__head">
<RoleAvatar status={role.status} />
<div className="lx-role-detail__ident">
<div className="lx-role-detail__name">
<h2>{role.name}</h2>
<RoleStatusTag status={role.status} />
{role.isSystem ? <span className="lx-role-tag lx-role-tag--builtin">内置</span> : null}
</div>
<p className="lx-role-detail__desc">{role.description}</p>
</div>
{canManage ? (
<div className="lx-role-detail__actions">
<Button
icon={<IconCopy size={14} />}
onClick={() => {
onCopy(role);
}}
>
复制
</Button>
{role.isSystem ? (
<Button
icon={<IconEdit size={14} />}
onClick={() => {
onRename(role);
}}
>
改名
</Button>
) : role.status === 'active' ? (
<Button
type="danger"
icon={<IconPause size={14} />}
onClick={() => {
onDisable(role);
}}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
onClick={() => {
onEnable(role);
}}
>
启用
</Button>
)}
</div>
) : null}
</header>
);
}
function PermissionSummary({
role,
tree,
onOpenCatalog,
}: {
role: RoleDetail;
tree: PermissionTree | undefined;
onOpenCatalog: () => void;
}): ReactNode {
const selected = [...role.permissions, ...role.specialPermissions];
const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected);
return (
<section className="lx-role-detail__section">
<div className="lx-role-detail__section-head">
<h3>权限点摘要</h3>
<span className="lx-role-detail__count">
已选 {role.permissionCount + role.specialPermissionCount} /{' '}
{tree?.totalPermissions ?? '—'}
</span>
<Button theme="borderless" size="small" onClick={onOpenCatalog}>
查看权限点目录
</Button>
</div>
{modules.length === 0 ? (
<p className="lx-role-detail__muted">权限点目录加载中…</p>
) : (
<div className="lx-role-detail__modules">
{modules.map((module) => (
<div
key={module.module}
className={
module.selected === 0 ? 'lx-role-detail__module is-zero' : 'lx-role-detail__module'
}
>
<span className="lx-role-detail__module-name">{module.moduleLabel}</span>
<span className="lx-role-detail__module-count">
<b>{module.selected}</b> / {module.total}
</span>
</div>
))}
</div>
)}
</section>
);
}
function ScopeAndSpecial({
role,
tree,
}: {
role: RoleDetail;
tree: PermissionTree | undefined;
}): ReactNode {
const meta = tree === undefined ? undefined : buildPermissionMetaMap(tree);
return (
<section className="lx-role-detail__section">
<div className="lx-role-detail__cols">
<div className="lx-role-detail__col">
<div className="lx-role-detail__section-head">
<h3>数据范围</h3>
</div>
<p className="lx-role-detail__scope">
<b>{scopeLabelOf(role.dataScope.type)}</b>
<span>{scopeDescriptionOf(role.dataScope.type)}</span>
</p>
{role.dataScope.type === 'customer_groups' ? (
<p className="lx-role-detail__muted">
{(role.dataScope.targetIds ?? []).length === 0
? '暂未指定客户组,客户组将在客户模块上线后可选择。'
: `已指定 ${String((role.dataScope.targetIds ?? []).length)} 个客户组,客户组名称将在客户模块上线后展示。`}
</p>
) : null}
</div>
<div className="lx-role-detail__col">
<div className="lx-role-detail__section-head">
<h3>特殊权限</h3>
<span className="lx-role-detail__count">{role.specialPermissions.length} / 6</span>
</div>
{role.specialPermissions.length === 0 ? (
<p className="lx-role-detail__muted">
未授予特殊权限,6 项均默认关闭,需单独授予并记录审计。
</p>
) : (
<div className="lx-role-detail__special-chips">
{role.specialPermissions.map((code) => (
<span key={code} className="lx-role-detail__special-chip" title={code}>
<IconStar size={12} />
{meta === undefined ? code : permissionNameOf(meta, code)}
</span>
))}
</div>
)}
</div>
</div>
</section>
);
}
function BoundUsers({ role }: { role: RoleDetail }): ReactNode {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? role.boundUsers : role.boundUsers.slice(0, 3);
return (
<section className="lx-role-detail__section">
<div className="lx-role-detail__section-head">
<h3>绑定用户</h3>
{role.boundUsers.length > 3 ? (
<Button
theme="borderless"
size="small"
onClick={() => {
setExpanded((value) => !value);
}}
>
{expanded ? '收起' : `查看全部 ${String(role.boundUsers.length)} 名`}
</Button>
) : null}
</div>
{role.boundUsers.length === 0 ? (
<p className="lx-role-detail__muted">暂未绑定用户,可停用或删除该角色。</p>
) : (
<ul className="lx-role-detail__users">
{shown.map((user) => (
<li key={user.id} className="lx-role-detail__user">
<span
className={
user.status === 'disabled'
? 'lx-role-detail__user-avatar is-disabled'
: 'lx-role-detail__user-avatar'
}
>
{user.name.slice(0, 1)}
</span>
<span className="lx-role-detail__user-name">{user.name}</span>
<span className="lx-role-detail__user-account">{user.account}</span>
<span className="lx-role-detail__user-dept">{user.departmentName}</span>
<span
className={
user.status === 'active' ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'
}
>
{user.status === 'active' ? '在职' : '停用'}
</span>
</li>
))}
</ul>
)}
</section>
);
}
function DetailState({
roleId,
loading,
error,
onRetry,
}: Pick<RoleDetailPanelProps, 'roleId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
if (roleId === null) {
return (
<PageEmpty
title="请选择一个角色"
description="左侧列表支持搜索与状态筛选,选中角色后在此查看权限详情。"
/>
);
}
if (loading) {
return (
<div className="lx-role-detail__state">
<PageSkeleton rows={8} />
</div>
);
}
if (error !== null) {
return (
<ErrorState
className="lx-role-detail__state"
title="角色详情加载失败"
message={error.message}
onRetry={onRetry}
/>
);
}
return null;
}
/** 右侧角色详情面板(只读):权限摘要、数据范围、特殊权限、绑定用户与编辑入口 */
export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
const ready = props.roleId !== null && props.detail !== undefined && props.error === null;
const detail = props.detail;
return (
<section className="lx-role-detail" aria-label="角色详情">
{ready && detail !== undefined ? (
<>
<DetailHead
role={detail}
canManage={props.canManage}
onCopy={props.onCopy}
onRename={props.onRename}
onDisable={props.onDisable}
onEnable={props.onEnable}
/>
<div className="lx-role-detail__body">
<div className="lx-role-detail__meta">
<span>
<em>编码</em>
<code>{detail.code}</code>
</span>
<span>
<em>绑定用户</em>
{detail.userCount} 名
</span>
<span>
<em>最近更新</em>
{formatRoleDateTime(detail.updatedAt)}
</span>
</div>
<PermissionSummary
role={detail}
tree={props.tree}
onOpenCatalog={() => {
props.onOpenCatalog(detail);
}}
/>
<ScopeAndSpecial role={detail} tree={props.tree} />
<BoundUsers role={detail} />
</div>
<footer className="lx-role-detail__foot">
<span className="lx-role-detail__foot-note">
{detail.isSystem ? '系统内置角色,不可停用或删除' : '自定义角色,可复制、停用或删除'}
</span>
<Button
theme="solid"
type="primary"
icon={<IconEdit size={14} />}
onClick={() => {
props.onEdit(detail.id);
}}
>
编辑角色
</Button>
</footer>
</>
) : (
<div className="lx-role-detail__empty">
<DetailState
roleId={props.roleId}
loading={props.loading}
error={props.error}
onRetry={props.onRetry}
/>
</div>
)}
</section>
);
}
@@ -0,0 +1,703 @@
/* eslint-disable max-lines-per-function, complexity -- 编辑器页负责角色加载、草稿编辑、保存编排与离开拦截,集中便于追踪数据流 */
import { Button } from '@douyinfe/semi-ui-19';
import {
Permission,
type PermissionNode,
type PermissionTree,
type RoleDetail,
type RoleListItem,
type SodConflict,
} from '@lingxi/shared';
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
import { IconAlert, IconBack, IconCopy, IconPause, IconPlay, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router';
import { ApiError } from '@/api/client';
import { Can } from '@/components/Can';
import {
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
useUpdateRoleMutation,
} from '@/hooks/queries/use-roles';
import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, roleNewPath, ROUTES } from '@/routes';
import {
DisableRoleDialog,
LeaveRoleDialog,
SaveRoleDialog,
SpecialPermissionDialog,
} from './role-dialogs';
import { buildPermissionMetaMap, formatRoleDateTime, scopeLabelOf } from './role-display';
import {
conflictsFromErrorDetails,
type RoleDraftErrors,
saveResultMessage,
validateUpdateDraft,
} from './role-utils';
import { type RoleEditorTabKey, RoleEditorTabs } from './RoleEditorTabs';
import { RoleListPanel } from './RoleListPanel';
import { RoleStatusTag } from './RoleStatusTag';
import { useRoleEditor } from './use-role-editor';
/** 左侧角色切换列表:与列表页同一查询口径,全部状态一次拉取 */
const PEER_ROLE_QUERY = {
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
} as const;
interface EditorContentProps {
detail: RoleDetail;
tree: PermissionTree | undefined;
treeLoading: boolean;
treeError: Error | null;
onRetryTree: () => void;
peers: readonly RoleListItem[];
peersLoading: boolean;
peersError: Error | null;
onRetryPeers: () => void;
onSelectPeer: (roleId: string) => void;
canManage: boolean;
}
function mergeConflicts(
local: readonly SodConflict[],
server: readonly SodConflict[],
): SodConflict[] {
const seen = new Set<string>();
const merged: SodConflict[] = [];
for (const conflict of [...local, ...server]) {
if (seen.has(conflict.pairId)) {
continue;
}
seen.add(conflict.pairId);
merged.push(conflict);
}
return merged;
}
function conflictMessages(
conflicts: readonly SodConflict[],
meta: ReadonlyMap<string, { name: string }> | null,
): string {
return conflicts
.map((conflict) => {
const left = meta?.get(conflict.permissions[0])?.name ?? conflict.permissions[0];
const right = meta?.get(conflict.permissions[1])?.name ?? conflict.permissions[1];
return `命中职责互斥:${conflict.label}。请移除「${left}」或「${right}」后重试。`;
})
.join(' ');
}
function EditorAlert({
readonly,
locked,
conflicts,
saveError,
meta,
}: {
readonly: boolean;
locked: boolean;
conflicts: readonly SodConflict[];
saveError: string | null;
meta: ReadonlyMap<string, { name: string }> | null;
}): ReactNode {
if (readonly) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>你没有角色管理权限(role:manage),当前为只读视图。如需调整请联系平台管理员。</span>
</div>
);
}
if (locked) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。</span>
</div>
);
}
if (conflicts.length > 0) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{conflictMessages(conflicts, meta)}</span>
</div>
);
}
if (saveError !== null) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{saveError}</span>
</div>
);
}
return null;
}
function EditorFoot({
readonly,
locked,
conflicts,
editor,
saving,
affectedUsers,
onCancel,
onSave,
}: {
readonly: boolean;
locked: boolean;
conflicts: readonly SodConflict[];
editor: ReturnType<typeof useRoleEditor>;
saving: boolean;
affectedUsers: number;
onCancel: () => void;
onSave: () => void;
}): ReactNode {
if (readonly) {
return (
<footer className="lx-role-editor__foot">
<span className="lx-role-editor__impact is-clean">
只读视图:可以查看权限配置,但没有修改权限
</span>
</footer>
);
}
const dirty = editor.isDirty;
return (
<footer className="lx-role-editor__foot">
<div className="lx-role-editor__impact">
{conflicts.length > 0 ? (
<span className="is-blocked">
<IconAlert size={14} />
存在职责互斥,保存不可用
</span>
) : !dirty ? (
<span className="is-clean">未做修改</span>
) : (
<>
<span className="is-dirty">未保存</span>
{editor.diff.totalAdded > 0 ? (
<span>
本次新增 <b>{editor.diff.totalAdded}</b>
</span>
) : null}
{editor.diff.totalRemoved > 0 ? (
<span>
移除 <b>{editor.diff.totalRemoved}</b>
</span>
) : null}
{editor.diff.nameChanged || editor.diff.descriptionChanged ? (
<span>基本信息已修改</span>
) : null}
{editor.diff.scopeChanged ? <span>数据范围已修改</span> : null}
<span>
影响 <b>{affectedUsers}</b> 名用户
</span>
</>
)}
</div>
<div className="lx-role-editor__foot-actions">
<Button onClick={onCancel} disabled={saving}>
取消
</Button>
<Button
theme="solid"
type="primary"
loading={saving}
disabled={conflicts.length > 0 || !dirty}
onClick={onSave}
>
{locked ? '保存名称' : '保存'}
</Button>
</div>
</footer>
);
}
function EditorContent({
detail,
tree,
treeLoading,
treeError,
onRetryTree,
peers,
peersLoading,
peersError,
onRetryPeers,
onSelectPeer,
canManage,
}: EditorContentProps): ReactNode {
const navigate = useNavigate();
const editor = useRoleEditor(detail);
const updateRole = useUpdateRoleMutation();
const locked = detail.isSystem;
const readonly = !canManage;
const [activeTab, setActiveTab] = useState<RoleEditorTabKey>('basic');
const [errors, setErrors] = useState<RoleDraftErrors>({});
const draftFingerprint = useMemo(() => JSON.stringify(editor.draft), [editor.draft]);
const [serverState, setServerState] = useState<{
fingerprint: string;
conflicts: readonly SodConflict[];
saveError: string | null;
} | null>(null);
const serverConflicts = useMemo<readonly SodConflict[]>(
() =>
serverState !== null && serverState.fingerprint === draftFingerprint
? serverState.conflicts
: [],
[serverState, draftFingerprint],
);
const saveError =
serverState !== null && serverState.fingerprint === draftFingerprint
? serverState.saveError
: null;
const [saveOpen, setSaveOpen] = useState(false);
const [specialPrompt, setSpecialPrompt] = useState(false);
const [specialDialog, setSpecialDialog] = useState<{
permission: PermissionNode;
granted: boolean;
} | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
const blocker = useBlocker(editor.isDirty);
const conflicts = useMemo(
() => (detail.isSystem ? [] : mergeConflicts(editor.conflicts, serverConflicts)),
[detail.isSystem, editor.conflicts, serverConflicts],
);
const meta = useMemo(() => (tree === undefined ? null : buildPermissionMetaMap(tree)), [tree]);
useEffect(() => {
if (!editor.isDirty) {
return undefined;
}
const handler = (event: BeforeUnloadEvent): void => {
event.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- 浏览器离开提示仍依赖 returnValue
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => {
window.removeEventListener('beforeunload', handler);
};
}, [editor.isDirty]);
const specialChangedCodes = useMemo(
() => [...editor.diff.addedSpecial, ...editor.diff.removedSpecial],
[editor.diff.addedSpecial, editor.diff.removedSpecial],
);
const attemptSave = (): void => {
if (readonly) {
return;
}
const validation = validateUpdateDraft(detail, editor.draft);
setErrors(validation.errors);
if (validation.input === null) {
setActiveTab('basic');
notifyWarning(validation.errors.name ?? validation.errors.description ?? '请检查角色信息');
return;
}
if (!editor.isDirty) {
notifyWarning('未做修改');
return;
}
if (editor.diff.hasSpecialChanges) {
setSpecialPrompt(true);
return;
}
setSaveOpen(true);
};
const doSave = (): void => {
const validation = validateUpdateDraft(detail, editor.draft);
if (validation.input === null) {
setErrors(validation.errors);
setSaveOpen(false);
setActiveTab('basic');
notifyWarning(validation.errors.name ?? '请检查角色信息');
return;
}
updateRole.mutate(
{ roleId: detail.id, input: validation.input },
{
onSuccess: (result) => {
setSaveOpen(false);
setSpecialPrompt(false);
setErrors({});
editor.markSaved();
if (detail.isSystem) {
notifySuccess('角色名称已更新');
} else {
notifySuccess(saveResultMessage(result));
}
if (result.warnings.length > 0) {
notifyWarning(result.warnings.join(';'));
}
},
onError: (error) => {
setSaveOpen(false);
setSpecialPrompt(false);
if (error instanceof ApiError && error.code === 40304) {
setServerState({
fingerprint: draftFingerprint,
conflicts: conflictsFromErrorDetails(error.details),
saveError: '保存时服务端检测到职责互斥,请调整冲突权限点后重试。',
});
setActiveTab('permissions');
return;
}
if (error instanceof ApiError) {
setServerState({
fingerprint: draftFingerprint,
conflicts: [],
saveError: error.message,
});
}
},
},
);
};
const toggleSpecial = (code: string, granted: boolean): void => {
const permission = tree?.specialPermissions.find((item) => item.code === code);
if (permission === undefined) {
return;
}
setSpecialDialog({ permission, granted });
};
const headerTitle = editor.draft.name.trim() === '' ? detail.name : editor.draft.name;
const impactUsers = detail.userCount;
return (
<>
<header className="lx-roles__header">
<button
type="button"
className="lx-roles__back"
aria-label="返回角色列表"
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
<IconBack size={18} />
</button>
<div className="lx-roles__head-txt">
<div className="lx-roles__title-line">
<h1>{headerTitle}</h1>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
{readonly ? <span className="lx-role-tag">只读</span> : null}
</div>
<p>调整角色权限、数据范围与特殊权限,保存后对绑定用户生效</p>
</div>
<div className="lx-roles__header-actions">
<Can permission={Permission.ROLE_MANAGE}>
<Button
icon={<IconCopy size={14} />}
onClick={() => {
void navigate(roleNewPath(detail.id));
}}
>
复制
</Button>
{detail.isSystem ? null : detail.status === 'active' ? (
<Button
type="danger"
icon={<IconPause size={14} />}
onClick={() => {
setDisableOpen(true);
}}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
onClick={() => {
updateRole.mutate(
{ roleId: detail.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${detail.name}」已启用`);
},
},
);
}}
>
启用
</Button>
)}
</Can>
</div>
</header>
<div className="lx-roles__split">
<RoleListPanel
roles={peers}
totalCount={peers.length}
loading={peersLoading}
error={peersError}
selectedRoleId={detail.id}
note={`当前:${detail.name}`}
footerNote="切换后继续编辑"
canManage={canManage}
showRowMenu={false}
isFiltered={false}
onSelect={onSelectPeer}
onRowAction={() => {
// 编辑器内左侧列表仅用于切换角色,不提供行菜单
}}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
// 编辑器内左侧列表不展示筛选
}}
onRetry={onRetryPeers}
/>
<section className="lx-role-editor" aria-label="角色编辑器">
<header className="lx-role-editor__head">
<span className="lx-role-editor__avatar" aria-hidden="true">
<IconShield size={20} />
</span>
<div className="lx-role-editor__ident">
<div className="lx-role-editor__name">
<b>{headerTitle}</b>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
</div>
<div className="lx-role-editor__meta">
<code>{detail.code}</code>
<i>·</i>
<span>{detail.userCount} 名用户</span>
<i>·</i>
<span>最近更新 {formatRoleDateTime(detail.updatedAt)}</span>
<i>·</i>
<span>{scopeLabelOf(detail.dataScope.type)}</span>
</div>
</div>
</header>
<div className="lx-role-editor__alert-wrap">
<EditorAlert
readonly={readonly}
locked={locked}
conflicts={conflicts}
saveError={saveError}
meta={meta}
/>
</div>
<RoleEditorTabs
detail={detail}
tree={tree}
treeLoading={treeLoading}
treeError={treeError}
onRetryTree={onRetryTree}
editor={editor}
locked={locked}
readonly={readonly}
activeTab={activeTab}
onChangeTab={setActiveTab}
errors={errors}
onSpecialRequest={toggleSpecial}
/>
<EditorFoot
readonly={readonly}
locked={locked}
conflicts={conflicts}
editor={editor}
saving={updateRole.isPending}
affectedUsers={detail.userCount}
onCancel={() => {
void navigate(ROUTES.system.roles);
}}
onSave={attemptSave}
/>
</section>
</div>
<DisableRoleDialog
key={`disable-${disableOpen ? detail.id : 'closed'}`}
visible={disableOpen}
role={detail}
loading={updateRole.isPending}
onCancel={() => {
setDisableOpen(false);
}}
onConfirm={(reason) => {
updateRole.mutate(
{
roleId: detail.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDisableOpen(false);
notifySuccess(`「${detail.name}」已停用,${impactUsers} 名用户下一次请求生效`);
},
},
);
}}
/>
<SpecialPermissionDialog
visible={specialDialog !== null}
permission={specialDialog?.permission ?? null}
granted={specialDialog?.granted ?? false}
holderCount={impactUsers}
onCancel={() => {
setSpecialDialog(null);
}}
onConfirm={() => {
if (specialDialog === null) {
return;
}
editor.setSpecialPermission(specialDialog.permission.code, specialDialog.granted);
setSpecialDialog(null);
notifySuccess(
specialDialog.granted
? `已设置「${specialDialog.permission.name}」,保存后生效`
: `已取消「${specialDialog.permission.name}」,保存后生效`,
);
}}
/>
{editor.diff.hasSpecialChanges ? (
<SpecialPermissionDialog
visible={specialPrompt}
permission={
tree?.specialPermissions.find((item) => item.code === specialChangedCodes[0]) ?? null
}
granted={editor.diff.addedSpecial.length > 0}
holderCount={impactUsers}
onCancel={() => {
setSpecialPrompt(false);
}}
onConfirm={() => {
setSpecialPrompt(false);
setSaveOpen(true);
}}
/>
) : null}
<SaveRoleDialog
visible={saveOpen}
role={detail}
diff={editor.diff}
meta={meta}
baseName={editor.base.name}
draftName={editor.draft.name}
baseScope={scopeLabelOf(editor.base.dataScope.type)}
draftScope={scopeLabelOf(editor.draft.dataScope.type)}
loading={updateRole.isPending}
onCancel={() => {
setSaveOpen(false);
}}
onConfirm={doSave}
/>
<LeaveRoleDialog
visible={blocker.state === 'blocked'}
roleName={detail.name}
onCancel={() => {
if (blocker.state === 'blocked') {
blocker.reset();
}
}}
onConfirm={() => {
if (blocker.state === 'blocked') {
blocker.proceed();
}
}}
/>
</>
);
}
function EditorLoading(): ReactNode {
return (
<div className="lx-roles__split">
<section className="lx-role-list lx-role-list--loading">
<PageSkeleton rows={9} />
</section>
<section className="lx-role-editor lx-role-editor--loading">
<PageSkeleton rows={10} />
</section>
</div>
);
}
/** 角色编辑器页(/system/roles/:id):左列表常驻高亮 + 右 Tab 编辑器 + 底部保存条 */
export function RoleEditorPage(): ReactNode {
const params = useParams<{ id: string }>();
const roleId = params.id ?? null;
const navigate = useNavigate();
const canManage = usePermission(Permission.ROLE_MANAGE);
const tree = usePermissionTreeQuery();
const peers = useRoleListQuery(PEER_ROLE_QUERY);
const detail = useRoleDetailQuery(roleId);
const selectPeer = (nextRoleId: string): void => {
if (nextRoleId === roleId) {
return;
}
void navigate(roleDetailPath(nextRoleId));
};
const peerRoles: readonly RoleListItem[] = useMemo(() => peers.data?.items ?? [], [peers.data]);
if (roleId === null) {
return <ErrorState title="角色不存在" message="链接缺少角色标识,请返回角色列表重新选择。" />;
}
if (detail.isPending) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
if (!detail.isSuccess) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
return (
<div className="lx-roles">
<EditorContent
key={detail.data.id}
detail={detail.data}
tree={tree.data}
treeLoading={tree.isPending}
treeError={tree.error}
onRetryTree={() => {
void tree.refetch();
}}
peers={peerRoles}
peersLoading={peers.isPending}
peersError={peers.error}
onRetryPeers={() => {
void peers.refetch();
}}
onSelectPeer={selectPeer}
canManage={canManage}
/>
</div>
);
}
@@ -0,0 +1,387 @@
/* eslint-disable max-lines-per-function -- 编辑器四个 Tab 的内容区集中导出,便于与设计稿逐区对照 */
import { Input, Tabs, TextArea } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { IconAlert, IconLock } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { formatRoleDateTime } from './role-display';
import type { RoleDraftErrors } from './role-utils';
import { RolePermissionTree } from './RolePermissionTree';
import { RoleScopePicker } from './RoleScopePicker';
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
import { RoleStatusTag } from './RoleStatusTag';
import type { RoleEditorState } from './use-role-editor';
export type RoleEditorTabKey = 'basic' | 'permissions' | 'scope' | 'special';
export interface RoleEditorTabsProps {
detail: RoleDetail;
tree: PermissionTree | undefined;
treeLoading: boolean;
treeError: Error | null;
onRetryTree: () => void;
editor: RoleEditorState;
/** 系统角色:权限 / 范围 / 特殊权限锁定,仅显示名可改 */
locked: boolean;
/** 无 role:manage:所有写控件只读 */
readonly: boolean;
activeTab: RoleEditorTabKey;
onChangeTab: (key: RoleEditorTabKey) => void;
errors: RoleDraftErrors;
onSpecialRequest: (code: string, granted: boolean) => void;
}
function FieldError({ message }: { message: string | undefined }): ReactNode {
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
}
function BasicTab({
detail,
editor,
readonly,
locked,
errors,
}: {
detail: RoleDetail;
editor: RoleEditorState;
readonly: boolean;
locked: boolean;
errors: RoleDraftErrors;
}): ReactNode {
const nameLocked = readonly;
const descLocked = readonly || locked;
return (
<div className="lx-role-editor__basic">
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>基本信息</h3>
<span>用于列表展示、成员分配与审计检索</span>
</header>
<div className="lx-role-editor__grid">
<label className="lx-role-field">
<span className="lx-role-field__label">
角色名称
<i className="lx-role-field__required">*</i>
<span className="lx-role-field__hint">{editor.draft.name.length}/64</span>
</span>
<Input
value={editor.draft.name}
maxLength={64}
placeholder="例如:审核员"
disabled={nameLocked}
onChange={editor.setName}
/>
<FieldError message={errors.name} />
</label>
<label className="lx-role-field">
<span className="lx-role-field__label">角色编码</span>
<span className="lx-role-field__readonly">
<IconLock size={13} />
{detail.code}
</span>
<span className="lx-role-field__sub">创建后不可修改,接口与审计使用该编码</span>
</label>
</div>
<label className="lx-role-field">
<span className="lx-role-field__label">
角色说明
<span className="lx-role-field__hint">{editor.draft.description.length}/255</span>
</span>
<TextArea
value={editor.draft.description}
maxLength={255}
autosize={{ minRows: 2, maxRows: 4 }}
placeholder="一句话说明该角色的职责"
disabled={descLocked}
onChange={editor.setDescription}
/>
<FieldError message={errors.description} />
</label>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>绑定用户</h3>
<span>停用或删除角色前请先转交</span>
</header>
<div className="lx-role-editor__bind-head">
<b>{detail.userCount} 名用户</b>
<span>绑定后立即获得该角色的权限与数据范围</span>
</div>
{detail.boundUsers.length === 0 ? (
<p className="lx-role-editor__muted">暂未绑定用户</p>
) : (
<ul className="lx-role-editor__users">
{detail.boundUsers.map((user) => (
<li key={user.id} className="lx-role-editor__user">
<span
className={
user.status === 'disabled'
? 'lx-role-editor__user-avatar is-disabled'
: 'lx-role-editor__user-avatar'
}
>
{user.name.slice(0, 1)}
</span>
<span className="lx-role-editor__user-name">{user.name}</span>
<span className="lx-role-editor__user-account">{user.account}</span>
<span className="lx-role-editor__user-dept">{user.departmentName}</span>
<span
className={
user.status === 'active'
? 'lx-status-tag is-active'
: 'lx-status-tag is-disabled'
}
>
{user.status === 'active' ? '在职' : '停用'}
</span>
</li>
))}
</ul>
)}
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>生效与审计</h3>
</header>
<p className="lx-role-editor__note">
<IconAlert size={14} />
权限与数据范围变更在保存后写入审计日志(role.updated),绑定用户下一次请求即生效。
</p>
<p className="lx-role-editor__meta-line">
当前状态 <RoleStatusTag status={detail.status} /> · 最近更新{' '}
{formatRoleDateTime(detail.updatedAt)}
</p>
</section>
</div>
);
}
function TreeState({
loading,
error,
onRetry,
}: {
loading: boolean;
error: Error | null;
onRetry: () => void;
}): ReactNode {
if (loading) {
return <p className="lx-role-editor__muted">权限点目录加载中…</p>;
}
if (error !== null) {
return (
<p className="lx-role-editor__muted">
权限点目录加载失败。
<button type="button" className="lx-role-editor__link" onClick={onRetry}>
重新加载
</button>
</p>
);
}
return <p className="lx-role-editor__muted">权限点目录暂不可用</p>;
}
function PermissionsTab({
tree,
treeLoading,
treeError,
onRetryTree,
editor,
locked,
readonly,
}: Pick<
RoleEditorTabsProps,
'tree' | 'treeLoading' | 'treeError' | 'onRetryTree' | 'editor' | 'locked' | 'readonly'
>): ReactNode {
if (tree === undefined) {
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
}
return (
<div className="lx-role-editor__perm">
<RolePermissionTree
tree={tree}
selected={new Set(editor.draft.permissions)}
special={new Set(editor.draft.specialPermissions)}
conflicts={editor.conflictCodes}
readonly={readonly || locked}
onTogglePermission={editor.togglePermission}
onToggleModule={editor.toggleModule}
/>
{locked ? (
<p className="lx-role-editor__note">
<IconLock size={14} />
系统内置角色的权限由系统保证,不支持在此调整。
</p>
) : null}
</div>
);
}
function ScopeTab({
editor,
locked,
readonly,
}: Pick<RoleEditorTabsProps, 'editor' | 'locked' | 'readonly'>): ReactNode {
return (
<div className="lx-role-editor__scope">
<header className="lx-role-editor__block-head">
<h3>数据范围</h3>
<span>决定该角色可见的客户与数据边界,与权限点同时生效取交集</span>
</header>
<RoleScopePicker
value={{
type: editor.draft.dataScope.type,
targetIds: editor.draft.dataScope.targetIds,
}}
readonly={readonly || locked}
onChange={editor.setScopeType}
/>
</div>
);
}
function SpecialTab({
tree,
treeLoading,
treeError,
onRetryTree,
editor,
detail,
locked,
readonly,
onSpecialRequest,
}: Pick<
RoleEditorTabsProps,
| 'tree'
| 'treeLoading'
| 'treeError'
| 'onRetryTree'
| 'editor'
| 'detail'
| 'locked'
| 'readonly'
| 'onSpecialRequest'
>): ReactNode {
if (tree === undefined) {
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
}
return (
<div className="lx-role-editor__special">
<header className="lx-role-editor__block-head">
<h3>特殊权限</h3>
<span>默认关闭、单独授予、不随角色复制带入</span>
</header>
<RoleSpecialPermissions
tree={tree}
granted={new Set(editor.draft.specialPermissions)}
readonly={readonly || locked}
holderCount={detail.userCount}
onRequest={onSpecialRequest}
/>
{locked ? (
<p className="lx-role-editor__note">
<IconLock size={14} />
系统内置角色的特殊权限由系统保证,不支持在此调整。
</p>
) : null}
</div>
);
}
function tabLabel(label: string, badge?: string, danger = false): ReactNode {
return (
<span className="lx-role-editor__tab-label">
{label}
{badge === undefined ? null : (
<span
className={danger ? 'lx-role-editor__tab-badge is-danger' : 'lx-role-editor__tab-badge'}
>
{badge}
</span>
)}
</span>
);
}
/** 编辑器头部 Tabs:基本信息 / 权限点 / 数据范围 / 特殊权限 */
export function RoleEditorTabs(props: RoleEditorTabsProps): ReactNode {
const totalPermissions =
props.tree?.totalPermissions ??
props.detail.permissionCount + props.detail.specialPermissionCount;
const selectedCount =
props.editor.draft.permissions.length + props.editor.draft.specialPermissions.length;
return (
<Tabs
className="lx-role-editor__tabs"
type="line"
activeKey={props.activeTab}
onChange={(key) => {
props.onChangeTab(key as RoleEditorTabKey);
}}
>
<Tabs.TabPane key="basic" itemKey="basic" tab={tabLabel('基本信息')}>
{props.activeTab === 'basic' ? (
<BasicTab
detail={props.detail}
editor={props.editor}
readonly={props.readonly}
locked={props.locked}
errors={props.errors}
/>
) : null}
</Tabs.TabPane>
<Tabs.TabPane
key="permissions"
itemKey="permissions"
tab={tabLabel(
'权限点',
`${String(selectedCount)} / ${String(totalPermissions)}`,
props.editor.conflicts.length > 0,
)}
>
{props.activeTab === 'permissions' ? (
<PermissionsTab
tree={props.tree}
treeLoading={props.treeLoading}
treeError={props.treeError}
onRetryTree={props.onRetryTree}
editor={props.editor}
locked={props.locked}
readonly={props.readonly}
/>
) : null}
</Tabs.TabPane>
<Tabs.TabPane key="scope" itemKey="scope" tab={tabLabel('数据范围')}>
{props.activeTab === 'scope' ? (
<ScopeTab editor={props.editor} locked={props.locked} readonly={props.readonly} />
) : null}
</Tabs.TabPane>
<Tabs.TabPane
key="special"
itemKey="special"
tab={tabLabel(
'特殊权限',
`${String(props.editor.draft.specialPermissions.length)} / ${String(
props.tree?.specialPermissions.length ?? 6,
)}`,
)}
>
{props.activeTab === 'special' ? (
<SpecialTab
tree={props.tree}
treeLoading={props.treeLoading}
treeError={props.treeError}
onRetryTree={props.onRetryTree}
editor={props.editor}
detail={props.detail}
locked={props.locked}
readonly={props.readonly}
onSpecialRequest={props.onSpecialRequest}
/>
) : null}
</Tabs.TabPane>
</Tabs>
);
}
@@ -0,0 +1,306 @@
/* eslint-disable max-lines-per-function -- 列表卡包含筛选态、行菜单与骨架,集中在一处便于对齐状态 */
import { Button, Dropdown, Skeleton } from '@douyinfe/semi-ui-19';
import type { RoleListItem } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import {
IconCopy,
IconDelete,
IconEdit,
IconMore,
IconPause,
IconPlay,
IconShield,
IconView,
} from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { scopeLabelOf } from './role-display';
import { RoleStatusTag } from './RoleStatusTag';
export type RoleRowAction = 'view' | 'rename' | 'edit' | 'copy' | 'disable' | 'enable' | 'delete';
export interface RoleListPanelProps {
roles: readonly RoleListItem[];
/** 未筛选的角色总数,用于区分「没有匹配」与「还没有自定义角色」 */
totalCount: number;
loading: boolean;
error: Error | null;
selectedRoleId: string | null;
/** 卡头右侧说明:内置 / 自定义计数或「当前:角色名」 */
note: string;
footerNote?: string;
canManage: boolean;
showRowMenu: boolean;
isFiltered: boolean;
onSelect: (roleId: string) => void;
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
onCreate: () => void;
onResetFilters: () => void;
onRetry: () => void;
}
function rolePermissionText(role: RoleListItem): ReactNode {
return (
<>
<b>{role.permissionCount}</b> 普通
{role.specialPermissionCount > 0 ? (
<span className="lx-role-row__special"> · ★{role.specialPermissionCount}</span>
) : null}
</>
);
}
function RowMenu({
role,
canManage,
onRowAction,
}: {
role: RoleListItem;
canManage: boolean;
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
}): ReactNode {
const [visible, setVisible] = useState(false);
if (!canManage) {
return null;
}
const trigger = (action: RoleRowAction): void => {
setVisible(false);
onRowAction(action, role);
};
const menuItems: ReactNode[] = role.isSystem
? [
<Dropdown.Item
key="view"
icon={<IconView size={14} />}
onClick={() => {
trigger('view');
}}
>
查看角色
</Dropdown.Item>,
<Dropdown.Item
key="rename"
icon={<IconEdit size={14} />}
onClick={() => {
trigger('rename');
}}
>
改名
</Dropdown.Item>,
]
: [
<Dropdown.Item
key="edit"
icon={<IconEdit size={14} />}
onClick={() => {
trigger('edit');
}}
>
编辑
</Dropdown.Item>,
<Dropdown.Item
key="copy"
icon={<IconCopy size={14} />}
onClick={() => {
trigger('copy');
}}
>
复制
</Dropdown.Item>,
<Dropdown.Item
key="toggle"
icon={role.status === 'active' ? <IconPause size={14} /> : <IconPlay size={14} />}
onClick={() => {
trigger(role.status === 'active' ? 'disable' : 'enable');
}}
>
{role.status === 'active' ? '停用' : '启用'}
</Dropdown.Item>,
<Dropdown.Divider key="divider" />,
<Dropdown.Item
key="delete"
type="danger"
icon={<IconDelete size={14} />}
disabled={role.userCount > 0}
onClick={() => {
trigger('delete');
}}
>
{role.userCount > 0 ? '删除(有绑定用户)' : '删除'}
</Dropdown.Item>,
];
return (
<Dropdown
trigger="click"
position="bottomRight"
visible={visible}
onVisibleChange={setVisible}
render={<Dropdown.Menu>{menuItems}</Dropdown.Menu>}
>
<Button
className="lx-role-row__more"
theme="borderless"
size="small"
icon={<IconMore size={16} />}
aria-label={`${role.name} 更多操作`}
/>
</Dropdown>
);
}
function ListBody({
roles,
loading,
error,
selectedRoleId,
canManage,
showRowMenu,
isFiltered,
onSelect,
onRowAction,
onCreate,
onResetFilters,
onRetry,
}: Pick<
RoleListPanelProps,
| 'roles'
| 'loading'
| 'error'
| 'selectedRoleId'
| 'canManage'
| 'showRowMenu'
| 'isFiltered'
| 'onSelect'
| 'onRowAction'
| 'onCreate'
| 'onResetFilters'
| 'onRetry'
>): ReactNode {
if (loading) {
return (
<div className="lx-role-list__state" aria-busy="true">
<Skeleton placeholder={<Skeleton.Paragraph rows={9} />} loading />
</div>
);
}
if (error !== null) {
return (
<ErrorState
className="lx-role-list__state"
title="角色列表加载失败"
message={error.message}
onRetry={onRetry}
/>
);
}
if (roles.length === 0) {
if (isFiltered) {
return (
<PageEmpty
title="没有匹配的角色"
description="换个角色名称、编码或说明再试,也可以清除当前筛选。"
action={<Button onClick={onResetFilters}>清除筛选</Button>}
/>
);
}
return (
<PageEmpty
title="还没有自定义角色"
description="内置角色由系统维护不可删除;可按岗位新建自定义角色并分配权限。"
action={
<Button theme="solid" type="primary" onClick={onCreate}>
新建角色
</Button>
}
/>
);
}
return (
<ul className="lx-role-list__items" role="listbox" aria-label="角色">
{roles.map((role) => {
const selected = role.id === selectedRoleId;
return (
<li key={role.id}>
<div
role="option"
aria-selected={selected}
tabIndex={0}
className={[
'lx-role-row',
selected ? 'is-selected' : '',
role.status === 'disabled' ? 'is-disabled' : '',
]
.filter((item) => item !== '')
.join(' ')}
onClick={() => {
onSelect(role.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(role.id);
}
}}
>
<span className="lx-role-row__icon" aria-hidden="true">
<IconShield size={16} />
</span>
<span className="lx-role-row__main">
<span className="lx-role-row__name">
<RoleStatusTag status={role.status} dotOnly />
<b>{role.name}</b>
{role.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
{role.status === 'disabled' ? <span className="lx-role-tag">停用</span> : null}
</span>
<span className="lx-role-row__meta">
{role.code}
<span className="lx-role-row__dot">·</span>
{scopeLabelOf(role.dataScope.type)}
</span>
</span>
<span className="lx-role-row__side">
<span className="lx-role-row__perm">{rolePermissionText(role)}</span>
<span className="lx-role-row__users">{role.userCount} 人绑定</span>
</span>
{showRowMenu ? (
<span
className="lx-role-row__menu"
onClick={(event) => {
event.stopPropagation();
}}
>
<RowMenu role={role} canManage={canManage} onRowAction={onRowAction} />
</span>
) : null}
</div>
</li>
);
})}
</ul>
);
}
/** 左侧角色列表卡:卡头说明 + 列表 / 三态 + 页脚统计 */
export function RoleListPanel(props: RoleListPanelProps): ReactNode {
return (
<section className="lx-role-list" aria-label="角色列表">
<header className="lx-role-list__head">
<span className="lx-role-list__title">角色</span>
<span className="lx-role-list__note">{props.note}</span>
</header>
<div className="lx-role-list__body">
<ListBody {...props} />
</div>
<footer className="lx-role-list__foot">
<span className="lx-role-list__range">共 {props.roles.length} 个角色</span>
<span className="lx-role-list__foot-note">
{props.footerNote ??
`启用 ${String(props.roles.filter((role) => role.status === 'active').length)} · 停用 ${String(props.roles.filter((role) => role.status === 'disabled').length)}`}
</span>
</footer>
</section>
);
}
@@ -0,0 +1,530 @@
/* eslint-disable max-lines-per-function, complexity -- 新建页负责模板预填、表单草稿与创建编排,集中便于对照原型 */
import { Button, Input, TextArea } from '@douyinfe/semi-ui-19';
import {
isPermissionCode,
type PermissionCode,
type PermissionModuleNode,
type PermissionNode,
type RoleDetail,
type RoleListItem,
} from '@lingxi/shared';
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
import { IconAdd, IconBack, IconCopy, IconDocs, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import {
useCreateRoleMutation,
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
} from '@/hooks/queries/use-roles';
import { roleDetailPath, ROUTES } from '@/routes';
import { SpecialPermissionDialog } from './role-dialogs';
import { scopeLabelOf } from './role-display';
import {
createEmptyDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
type RoleDraftErrors,
validateCreateDraft,
} from './role-utils';
import { RolePermissionTree } from './RolePermissionTree';
import { RoleScopePicker } from './RoleScopePicker';
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
const TEMPLATE_ROLE_QUERY = {
limit: 100,
status: 'active',
sort: 'created_at',
order: 'desc',
} as const;
const BLANK_TEMPLATE = '__blank__';
interface TemplateListProps {
templates: readonly RoleListItem[];
loading: boolean;
error: Error | null;
selectedKey: string;
onSelect: (key: string) => void;
onRetry: () => void;
}
function TemplateList({
templates,
loading,
error,
selectedKey,
onSelect,
onRetry,
}: TemplateListProps): ReactNode {
return (
<section className="lx-role-templates" aria-label="角色模板">
<header className="lx-role-templates__head">
<span className="lx-role-templates__title">角色模板</span>
<span className="lx-role-templates__note">
{templates.length === 0 ? '仅空白' : `${String(templates.length + 1)} 个`}
</span>
</header>
<div className="lx-role-templates__body">
{loading ? (
<div className="lx-role-templates__state">
<PageSkeleton rows={8} />
</div>
) : error !== null ? (
<ErrorState
className="lx-role-templates__state"
title="角色模板加载失败"
message={error.message}
onRetry={onRetry}
/>
) : (
<div role="listbox" aria-label="选择模板">
<button
type="button"
role="option"
aria-selected={selectedKey === BLANK_TEMPLATE}
className={
selectedKey === BLANK_TEMPLATE ? 'lx-role-template is-active' : 'lx-role-template'
}
onClick={() => {
onSelect(BLANK_TEMPLATE);
}}
>
<span className="lx-role-template__icon" aria-hidden="true">
<IconDocs size={16} />
</span>
<span className="lx-role-template__main">
<span className="lx-role-template__name">从空白开始</span>
<span className="lx-role-template__desc">不预置任何权限,逐项配置</span>
<span className="lx-role-template__meta">
<span>权限 0</span>
<i>·</i>
<span>未设置范围</span>
<i>·</i>
<span>特殊 0</span>
</span>
</span>
</button>
{templates.map((role) => {
const active = selectedKey === role.id;
return (
<button
key={role.id}
type="button"
role="option"
aria-selected={active}
className={active ? 'lx-role-template is-active' : 'lx-role-template'}
onClick={() => {
onSelect(role.id);
}}
>
<span className="lx-role-template__icon" aria-hidden="true">
<IconShield size={16} />
</span>
<span className="lx-role-template__main">
<span className="lx-role-template__name">
{role.name}
{role.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
</span>
<span className="lx-role-template__desc">{role.description}</span>
<span className="lx-role-template__meta">
<span>权限 {role.permissionCount}</span>
<i>·</i>
<span>{scopeLabelOf(role.dataScope.type)}</span>
<i>·</i>
<span className={role.specialPermissionCount > 0 ? 'has-special' : ''}>
{role.specialPermissionCount > 0
? `★特殊 ${String(role.specialPermissionCount)}`
: '特殊 0'}
</span>
</span>
</span>
</button>
);
})}
</div>
)}
</div>
<footer className="lx-role-templates__foot">
模板只带入普通权限与数据范围,★ 特殊权限需创建后单独授予
</footer>
</section>
);
}
function NewFormHeader({
templateRole,
fromCopy,
templateLoading,
}: {
templateRole: RoleDetail | undefined;
fromCopy: boolean;
templateLoading: boolean;
}): ReactNode {
const title =
templateRole === undefined
? '从空白开始创建'
: fromCopy
? `复制「${templateRole.name}」`
: `从「${templateRole.name}」模板创建`;
const sub =
templateRole === undefined
? '未预置任何权限与数据范围,按需逐项配置'
: fromCopy
? '已带入权限与数据范围,特殊权限需创建后单独授予'
: templateRole.description;
return (
<header className="lx-role-new__head">
<div>
<h2>{title}</h2>
<p>{templateLoading ? '正在读取来源角色…' : sub}</p>
</div>
<span className="lx-role-tag lx-role-tag--brand">
{templateRole === undefined ? '空白配置' : fromCopy ? '复制角色' : '已套用模板'}
</span>
</header>
);
}
function FieldError({ message }: { message: string | undefined }): ReactNode {
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
}
/** 新建 / 复制角色页(/system/roles/new?from=):左模板列表 + 右表单 */
export function RoleNewPage(): ReactNode {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const fromRoleId = searchParams.get('from');
const listQuery = useRoleListQuery(TEMPLATE_ROLE_QUERY);
const tree = usePermissionTreeQuery();
const createRole = useCreateRoleMutation();
const templates = useMemo(
() => (listQuery.data?.items ?? []).filter((role) => !role.isSystem),
[listQuery.data],
);
const [selectedKey, setSelectedKey] = useState<string>(fromRoleId ?? BLANK_TEMPLATE);
const [draft, setDraft] = useState<RoleDraft>(createEmptyDraft);
const [errors, setErrors] = useState<RoleDraftErrors>({});
const [specialDialog, setSpecialDialog] = useState<{
permission: PermissionNode;
granted: boolean;
} | null>(null);
const [prefilledId, setPrefilledId] = useState<string | null>(null);
const templateRoleId = selectedKey === BLANK_TEMPLATE ? null : selectedKey;
const templateDetail = useRoleDetailQuery(templateRoleId);
const fromCopy = fromRoleId !== null && fromRoleId === selectedKey;
const conflicts = findDraftSodConflicts(draft);
const conflictCodes = useMemo(() => {
const codes = new Set<string>();
for (const conflict of conflicts) {
codes.add(conflict.permissions[0]);
codes.add(conflict.permissions[1]);
}
return codes;
}, [conflicts]);
// 模板数据到达后一次性预填(渲染期对齐状态,避免 effect 级联渲染)
const templateRole = templateDetail.data;
if (templateRoleId === null) {
if (prefilledId !== null) {
setPrefilledId(null);
setDraft(createEmptyDraft());
setErrors({});
}
} else if (templateRole !== undefined && prefilledId !== templateRole.id) {
setPrefilledId(templateRole.id);
setDraft(draftFromDetail(templateRole, { includeSpecial: false, nameSuffix: ' 副本' }));
setErrors({});
}
const togglePermission = (code: string): void => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.permissions.includes(code);
return {
...current,
permissions: has
? current.permissions.filter((item) => item !== code)
: [...current.permissions, code],
};
});
};
const toggleModule = (module: PermissionModuleNode, selectAll: boolean): void => {
const normalCodes: PermissionCode[] = module.permissions
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
.map((permission) => permission.code)
.filter(isPermissionCode);
setDraft((current) => {
const next = new Set(current.permissions);
for (const code of normalCodes) {
if (selectAll) {
next.add(code);
} else {
next.delete(code);
}
}
return { ...current, permissions: [...next] };
});
};
const submit = (): void => {
const validation = validateCreateDraft(draft);
setErrors(validation.errors);
if (validation.input === null) {
notifyWarning(validation.errors.name ?? '请检查角色信息');
return;
}
if (conflicts.length > 0) {
notifyWarning(`存在职责互斥:${conflicts[0]?.label ?? ''},请调整权限点`);
return;
}
createRole.mutate(validation.input, {
onSuccess: (result) => {
notifySuccess('角色已创建,正在进入编辑器');
void navigate(roleDetailPath(result.role.id), { replace: true });
},
});
};
return (
<div className="lx-roles">
<header className="lx-roles__header">
<button
type="button"
className="lx-roles__back"
aria-label="返回角色列表"
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
<IconBack size={18} />
</button>
<div className="lx-roles__head-txt">
<h1>新建角色</h1>
<p>从模板开始或从空白配置,创建后可继续调整权限与数据范围</p>
</div>
<div className="lx-roles__header-actions">
<Button
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
取消
</Button>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
loading={createRole.isPending}
onClick={submit}
>
创建角色
</Button>
</div>
</header>
<div className="lx-roles__split">
<TemplateList
templates={templates}
loading={listQuery.isPending}
error={listQuery.error}
selectedKey={selectedKey}
onSelect={(key) => {
setSelectedKey(key);
setPrefilledId(null);
}}
onRetry={() => {
void listQuery.refetch();
}}
/>
<section className="lx-role-new" aria-label="新建角色表单">
<NewFormHeader
templateRole={templateDetail.data}
fromCopy={fromCopy}
templateLoading={templateDetail.isPending && templateRoleId !== null}
/>
<div className="lx-role-new__body">
{fromCopy ? (
<div className="lx-role-new__alert is-info">
<IconCopy size={15} />
<span>
已复制「{templateDetail.data?.name ?? ''}」的权限与数据范围,特殊权限未带入。
</span>
</div>
) : null}
{conflicts.length > 0 ? (
<div className="lx-role-new__alert is-danger">
<span>
命中职责互斥:{conflicts.map((conflict) => conflict.label).join(';')}
,请调整冲突权限点后重试。
</span>
</div>
) : null}
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>基本信息</h3>
<span>名称与说明用于列表展示和成员分配</span>
</header>
<div className="lx-role-editor__grid">
<label className="lx-role-field">
<span className="lx-role-field__label">
角色名称
<i className="lx-role-field__required">*</i>
<span className="lx-role-field__hint">{draft.name.length}/64</span>
</span>
<Input
value={draft.name}
maxLength={64}
placeholder="例如:审核员"
onChange={(value) => {
setDraft((current) => ({ ...current, name: value }));
setErrors({});
}}
/>
<FieldError message={errors.name} />
</label>
</div>
<label className="lx-role-field">
<span className="lx-role-field__label">
角色说明
<span className="lx-role-field__hint">{draft.description.length}/255</span>
</span>
<TextArea
value={draft.description}
maxLength={255}
autosize={{ minRows: 2, maxRows: 4 }}
placeholder="一句话说明该角色的职责"
onChange={(value) => {
setDraft((current) => ({ ...current, description: value }));
}}
/>
<FieldError message={errors.description} />
</label>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>权限点</h3>
<span>模板已带入的权限点可继续增减</span>
</header>
{tree.data === undefined ? (
<p className="lx-role-editor__muted">
{tree.isPending
? '权限点目录加载中…'
: '权限点目录加载失败,创建后可在编辑器中继续配置。'}
</p>
) : (
<RolePermissionTree
tree={tree.data}
selected={new Set(draft.permissions)}
special={new Set(draft.specialPermissions)}
conflicts={conflictCodes}
readonly={false}
onTogglePermission={togglePermission}
onToggleModule={toggleModule}
/>
)}
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>数据范围</h3>
<span>与权限点同时生效,取交集</span>
</header>
<RoleScopePicker
value={{ type: draft.dataScope.type, targetIds: draft.dataScope.targetIds }}
readonly={false}
onChange={(type) => {
setDraft((current) => ({
...current,
dataScope: {
type,
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
},
}));
}}
/>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>特殊权限</h3>
<span>默认全关,创建后也可在编辑器中授予</span>
</header>
{tree.data === undefined ? (
<p className="lx-role-editor__muted">特殊权限目录加载中…</p>
) : (
<RoleSpecialPermissions
tree={tree.data}
granted={new Set(draft.specialPermissions)}
readonly={false}
holderCount={0}
onRequest={(code, granted) => {
const permission = tree.data.specialPermissions.find(
(item) => item.code === code,
);
if (permission !== undefined) {
setSpecialDialog({ permission, granted });
}
}}
/>
)}
</section>
</div>
<footer className="lx-role-new__foot">
<span className="lx-role-new__foot-note">
已选 {draft.permissions.length + draft.specialPermissions.length} /{' '}
{tree.data?.totalPermissions ?? 30} 权限点 · {scopeLabelOf(draft.dataScope.type)} ·
特殊权限 {draft.specialPermissions.length}
</span>
<span className="lx-role-new__foot-hint">创建后进入编辑器继续配置</span>
</footer>
</section>
</div>
<SpecialPermissionDialog
visible={specialDialog !== null}
permission={specialDialog?.permission ?? null}
granted={specialDialog?.granted ?? false}
holderCount={0}
onCancel={() => {
setSpecialDialog(null);
}}
onConfirm={() => {
if (specialDialog === null) {
return;
}
const code = specialDialog.permission.code;
if (!isPermissionCode(code)) {
setSpecialDialog(null);
return;
}
setDraft((current) => ({
...current,
specialPermissions: specialDialog.granted
? [...current.specialPermissions, code]
: current.specialPermissions.filter((item) => item !== code),
}));
setSpecialDialog(null);
notifySuccess(
specialDialog.granted
? `已选择「${specialDialog.permission.name}」,创建后生效`
: `已取消「${specialDialog.permission.name}」`,
);
}}
/>
</div>
);
}
@@ -0,0 +1,187 @@
import type { PermissionTree } from '@lingxi/shared';
import {
PERMISSION_CATALOG,
permissionsByModule,
permissionTreeSchema,
SOD_PAIRS,
} from '@lingxi/shared';
import { notifyWarning } from '@lingxi/ui';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RolePermissionTree } from './RolePermissionTree';
vi.mock('@lingxi/ui', () => ({
notifyWarning: vi.fn(),
}));
// jsdom 无 canvas,Semi 整包会加载 Lottie;测试只渲染树,替换所需基础组件
vi.mock('@douyinfe/semi-ui-19', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
Input: ({
value,
placeholder,
onChange,
}: {
value?: string;
placeholder?: string;
onChange?: (value: string) => void;
}) => (
<input
value={value ?? ''}
placeholder={placeholder}
onChange={(event) => {
onChange?.(event.target.value);
}}
/>
),
}));
const mockedNotifyWarning = vi.mocked(notifyWarning);
function buildTree(): PermissionTree {
const roleNode = (permission: (typeof PERMISSION_CATALOG)[number]) => ({
code: permission.code,
name: permission.name,
description: permission.description,
isSpecial: permission.isSpecial,
});
return permissionTreeSchema.parse({
modules: permissionsByModule().map((module) => ({
module: module.module,
moduleLabel: module.moduleLabel,
sortOrder: module.sortOrder,
permissions: module.permissions.map(roleNode),
})),
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(
roleNode,
),
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
});
}
interface RenderOptions {
selected?: string[];
special?: string[];
conflicts?: string[];
readonly?: boolean;
}
function renderTree(options: RenderOptions = {}): {
onTogglePermission: ReturnType<typeof vi.fn>;
onToggleModule: ReturnType<typeof vi.fn>;
container: HTMLElement;
} {
const onTogglePermission = vi.fn();
const onToggleModule = vi.fn();
const { container } = render(
<RolePermissionTree
tree={buildTree()}
selected={new Set(options.selected ?? [])}
special={new Set(options.special ?? [])}
conflicts={new Set(options.conflicts ?? [])}
readonly={options.readonly ?? false}
onTogglePermission={onTogglePermission}
onToggleModule={onToggleModule}
/>,
);
return { onTogglePermission, onToggleModule, container };
}
afterEach(() => {
cleanup();
mockedNotifyWarning.mockReset();
});
describe('权限点树', () => {
it('按目录渲染 10 个模块与已选计数', () => {
const { container } = renderTree({ selected: ['script:read'] });
const tools = container.querySelector('.lx-role-tree__tools');
expect(tools?.textContent).toContain('已选');
expect(tools?.textContent).toContain('30');
expect(screen.getByText('脚本')).toBeTruthy();
expect(screen.getByText('素材')).toBeTruthy();
expect(screen.getByText('组织与角色')).toBeTruthy();
expect(screen.getByText('审计')).toBeTruthy();
expect(container.querySelector('.lx-role-module__count')?.textContent).toContain('1 / 3');
});
it('默认展开已选模块,点击模块标题展开其余模块', () => {
renderTree({ selected: ['script:read'] });
expect(screen.getByText('查看脚本')).toBeTruthy();
expect(screen.queryByText('查看素材任务')).toBeNull();
fireEvent.click(screen.getByText('素材'));
expect(screen.getByText('查看素材任务')).toBeTruthy();
});
it('点击普通权限点行触发切换', () => {
const { onTogglePermission } = renderTree({ selected: ['script:read'] });
fireEvent.click(screen.getByText('查看脚本'));
expect(onTogglePermission).toHaveBeenCalledWith('script:read');
});
it('点击模块三态复选框触发整模块选中', () => {
const { container, onToggleModule } = renderTree({ selected: ['script:read'] });
const module = container.querySelector('[data-module="script"]');
expect(module).not.toBeNull();
const check = module?.querySelector('.lx-role-module__check');
expect(check?.getAttribute('aria-checked')).toBe('mixed');
if (check !== null && check !== undefined) {
fireEvent.click(check);
}
expect(onToggleModule).toHaveBeenCalledTimes(1);
expect(onToggleModule.mock.calls[0]?.[1]).toBe(true);
});
it('特殊权限行不切换,只提示需单独授予', () => {
const { container, onTogglePermission } = renderTree({});
fireEvent.click(screen.getByText('导出交付'));
const specialRow = container.querySelector('[data-code="delivery:export"]');
expect(specialRow).not.toBeNull();
if (specialRow !== null) {
fireEvent.click(specialRow);
}
expect(onTogglePermission).not.toHaveBeenCalledWith('delivery:export');
expect(mockedNotifyWarning).toHaveBeenCalledTimes(1);
});
it('SoD 冲突行高亮', () => {
const { container } = renderTree({
selected: ['script:write', 'script:review'],
conflicts: ['script:write', 'script:review'],
});
expect(container.querySelector('[data-code="script:write"]')?.className).toContain(
'is-conflict',
);
expect(container.querySelector('[data-code="script:read"]')?.className).not.toContain(
'is-conflict',
);
});
it('只读态禁用交互', () => {
const { onTogglePermission } = renderTree({ selected: ['script:read'], readonly: true });
fireEvent.click(screen.getByText('查看脚本'));
expect(onTogglePermission).not.toHaveBeenCalled();
});
it('搜索权限点后只保留匹配模块并自动展开', () => {
renderTree({});
fireEvent.change(screen.getByPlaceholderText('搜索权限点名称或编码'), {
target: { value: '发起导出' },
});
expect(screen.queryByText('脚本')).toBeNull();
expect(screen.getByText('导出交付')).toBeTruthy();
expect(screen.getByText('发起导出')).toBeTruthy();
});
});
@@ -0,0 +1,381 @@
/* eslint-disable max-lines-per-function -- 权限树包含工具条、模块三态与行渲染,集中在一处便于对齐目录 */
import { Button, Input } from '@douyinfe/semi-ui-19';
import type { PermissionModuleNode, PermissionTree } from '@lingxi/shared';
import { notifyWarning } from '@lingxi/ui';
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
import type { KeyboardEvent, ReactNode } from 'react';
import { useMemo, useState } from 'react';
export interface RolePermissionTreeProps {
tree: PermissionTree;
/** 普通权限点(特殊权限不得混入) */
selected: ReadonlySet<string>;
/** 特殊权限点(树内只读展示,授予在「特殊权限」区单独确认) */
special: ReadonlySet<string>;
/** SoD 冲突权限点编码,树内高亮 */
conflicts: ReadonlySet<string>;
readonly: boolean;
onTogglePermission: (code: string) => void;
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
}
function moduleSelectedCount(
module: PermissionModuleNode,
selected: ReadonlySet<string>,
special: ReadonlySet<string>,
): number {
return module.permissions.filter((permission) =>
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code),
).length;
}
function normalPermissionsOf(module: PermissionModuleNode): string[] {
return module.permissions
.filter((permission) => !permission.isSpecial)
.map((permission) => permission.code);
}
function initialExpandedModules(
tree: PermissionTree,
selected: ReadonlySet<string>,
special: ReadonlySet<string>,
): ReadonlySet<string> {
const expanded = new Set<string>();
for (const module of tree.modules) {
if (moduleSelectedCount(module, selected, special) > 0) {
expanded.add(module.module);
}
}
if (expanded.size === 0) {
for (const module of tree.modules.slice(0, 2)) {
expanded.add(module.module);
}
}
return expanded;
}
function matchesKeyword(
module: PermissionModuleNode,
keyword: string,
): PermissionModuleNode | null {
if (keyword === '') {
return module;
}
const permissions = module.permissions.filter((permission) =>
`${permission.name} ${permission.code} ${permission.description}`
.toLowerCase()
.includes(keyword),
);
return permissions.length === 0 ? null : { ...module, permissions };
}
function CheckStateIcon({ state }: { state: 'on' | 'off' | 'half' }): ReactNode {
return <span className={`lx-role-check is-${state}`} aria-hidden="true" />;
}
function PermissionRow({
code,
name,
description,
isSpecial,
checked,
conflict,
readonly,
onToggle,
onSpecialHint,
}: {
code: string;
name: string;
description: string;
isSpecial: boolean;
checked: boolean;
conflict: boolean;
readonly: boolean;
onToggle: () => void;
onSpecialHint: () => void;
}): ReactNode {
const interactive = !readonly && !isSpecial;
const activate = (): void => {
if (readonly) {
return;
}
if (isSpecial) {
onSpecialHint();
return;
}
onToggle();
};
const className = [
'lx-role-perm-row',
checked ? 'is-checked' : '',
isSpecial ? 'is-special' : '',
conflict ? 'is-conflict' : '',
interactive ? 'is-interactive' : '',
]
.filter((item) => item !== '')
.join(' ');
return (
<div
className={className}
role="checkbox"
aria-checked={checked}
aria-disabled={!interactive}
tabIndex={interactive ? 0 : -1}
data-code={code}
onClick={activate}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activate();
}
}}
>
<span className="lx-role-perm-row__check">
<CheckStateIcon state={checked ? 'on' : 'off'} />
</span>
<span className="lx-role-perm-row__main">
<span className="lx-role-perm-row__name">
{name}
{isSpecial ? <span className="lx-role-tag lx-role-tag--special">★ 特殊</span> : null}
</span>
<span className="lx-role-perm-row__desc">{description}</span>
{isSpecial ? (
<span className="lx-role-perm-row__hint">
特殊权限需在「特殊权限」中单独授予并二次确认
</span>
) : null}
</span>
<code className="lx-role-perm-row__code">{code}</code>
</div>
);
}
function ModuleBlock({
module,
selected,
special,
conflicts,
readonly,
expanded,
onToggleExpanded,
onTogglePermission,
onToggleModule,
}: {
module: PermissionModuleNode;
selected: ReadonlySet<string>;
special: ReadonlySet<string>;
conflicts: ReadonlySet<string>;
readonly: boolean;
expanded: boolean;
onToggleExpanded: () => void;
onTogglePermission: (code: string) => void;
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
}): ReactNode {
const normalCodes = normalPermissionsOf(module);
const selectedNormal = normalCodes.filter((code) => selected.has(code)).length;
const state: 'on' | 'off' | 'half' =
selectedNormal === 0 ? 'off' : selectedNormal === normalCodes.length ? 'on' : 'half';
const specialCount = module.permissions.filter((permission) => permission.isSpecial).length;
const totalSelected = moduleSelectedCount(module, selected, special);
return (
<div
className={expanded ? 'lx-role-module is-open' : 'lx-role-module'}
data-module={module.module}
>
<div className="lx-role-module__head">
<span
className={[
'lx-role-module__check',
readonly ? 'is-readonly' : '',
normalCodes.length === 0 ? 'is-locked' : '',
]
.filter((item) => item !== '')
.join(' ')}
role="checkbox"
aria-checked={state === 'on' ? true : state === 'half' ? 'mixed' : false}
aria-disabled={readonly || normalCodes.length === 0}
tabIndex={readonly ? -1 : 0}
title={
normalCodes.length === 0
? '该模块权限点均为特殊权限,请在「特殊权限」中授予'
: undefined
}
onClick={(event) => {
event.stopPropagation();
if (readonly || normalCodes.length === 0) {
return;
}
onToggleModule(module, state !== 'on');
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
if (readonly || normalCodes.length === 0) {
return;
}
onToggleModule(module, state !== 'on');
}}
>
<CheckStateIcon state={state} />
</span>
<button
type="button"
className="lx-role-module__toggle"
aria-expanded={expanded}
onClick={onToggleExpanded}
>
<span className="lx-role-module__name">{module.moduleLabel}</span>
{specialCount > 0 ? (
<span className="lx-role-module__star" title="该模块含特殊权限">
★ {specialCount}
</span>
) : null}
<span
className={
totalSelected === 0 ? 'lx-role-module__count' : 'lx-role-module__count is-active'
}
>
{totalSelected} / {module.permissions.length}
</span>
<span className="lx-role-module__caret">
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</span>
</button>
</div>
{expanded ? (
<div className="lx-role-module__body">
{module.permissions.map((permission) => (
<PermissionRow
key={permission.code}
code={permission.code}
name={permission.name}
description={permission.description}
isSpecial={permission.isSpecial}
checked={
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code)
}
conflict={conflicts.has(permission.code)}
readonly={readonly}
onToggle={() => {
onTogglePermission(permission.code);
}}
onSpecialHint={() => {
notifyWarning('特殊权限需在「特殊权限」中单独授予并二次确认');
}}
/>
))}
</div>
) : null}
</div>
);
}
/** 权限点树:10 模块 / 30 点,模块三态、搜索、折叠;特殊权限树内只读 */
export function RolePermissionTree({
tree,
selected,
special,
conflicts,
readonly,
onTogglePermission,
onToggleModule,
}: RolePermissionTreeProps): ReactNode {
const [keyword, setKeyword] = useState('');
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() =>
initialExpandedModules(tree, selected, special),
);
const normalizedKeyword = keyword.trim().toLowerCase();
const visibleModules = useMemo(() => {
const matched: PermissionModuleNode[] = [];
for (const module of tree.modules) {
const result = matchesKeyword(module, normalizedKeyword);
if (result !== null) {
matched.push(result);
}
}
return matched;
}, [tree, normalizedKeyword]);
const isExpanded = (module: string): boolean => normalizedKeyword !== '' || expanded.has(module);
const setAllExpanded = (open: boolean): void => {
setExpanded(open ? new Set(tree.modules.map((module) => module.module)) : new Set());
};
const toggleExpanded = (module: string): void => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(module)) {
next.delete(module);
} else {
next.add(module);
}
return next;
});
};
return (
<div className="lx-role-tree">
<div className="lx-role-tree__tools">
<Input
className="lx-role-tree__search"
value={keyword}
prefix={<IconSearch size={15} />}
placeholder="搜索权限点名称或编码"
aria-label="搜索权限点"
showClear
onChange={(value) => {
setKeyword(value);
}}
/>
<span className="lx-role-tree__selected">
已选 <b>{selected.size + special.size}</b> / {tree.totalPermissions}
</span>
<span className="lx-role-tree__links">
<Button
theme="borderless"
size="small"
onClick={() => {
setAllExpanded(true);
}}
>
展开全部
</Button>
<Button
theme="borderless"
size="small"
onClick={() => {
setAllExpanded(false);
}}
>
收起全部
</Button>
</span>
</div>
{visibleModules.length === 0 ? (
<p className="lx-role-tree__empty">没有匹配的权限点,换个名称或编码再试。</p>
) : (
<div className="lx-role-tree__modules">
{visibleModules.map((module) => (
<ModuleBlock
key={module.module}
module={module}
selected={selected}
special={special}
conflicts={conflicts}
readonly={readonly}
expanded={isExpanded(module.module)}
onToggleExpanded={() => {
toggleExpanded(module.module);
}}
onTogglePermission={onTogglePermission}
onToggleModule={onToggleModule}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import type { DataScopeType } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { ROLE_SCOPE_OPTIONS, scopeOptionOf } from './role-display';
export interface RoleScopeValue {
type: DataScopeType;
targetIds: readonly string[];
}
export interface RoleScopePickerProps {
value: RoleScopeValue;
readonly: boolean;
onChange: (type: DataScopeType) => void;
}
/**
* 六类数据范围单选卡片。
* A3 前客户组目录为空:选中「指定客户组」时展示空态,不伪造客户组数据。
*/
export function RoleScopePicker({ value, readonly, onChange }: RoleScopePickerProps): ReactNode {
const selected = scopeOptionOf(value.type);
return (
<div className="lx-role-scope">
<div className="lx-role-scope__grid" role="radiogroup" aria-label="数据范围">
{ROLE_SCOPE_OPTIONS.map((option) => {
const active = option.type === value.type;
return (
<button
key={option.type}
type="button"
role="radio"
aria-checked={active}
disabled={readonly}
className={active ? 'lx-role-scope__option is-active' : 'lx-role-scope__option'}
onClick={() => {
if (!readonly && !active) {
onChange(option.type);
}
}}
>
<span className="lx-role-scope__radio" aria-hidden="true" />
<span className="lx-role-scope__copy">
<span className="lx-role-scope__name">{option.label}</span>
<span className="lx-role-scope__desc">{option.description}</span>
</span>
{option.needsTargets ? <span className="lx-role-scope__need">需选客户组</span> : null}
</button>
);
})}
</div>
{value.type === 'customer_groups' ? (
<div className="lx-role-scope__groups">
<div className="lx-role-scope__groups-head">
<b>客户组</b>
<span>
{value.targetIds.length === 0
? '选择客户组后按组批量授权'
: `已指定 ${String(value.targetIds.length)} 个客户组`}
</span>
</div>
{value.targetIds.length === 0 ? (
<div className="lx-role-scope__empty">
<b>暂无可选客户组</b>
<span>
客户组将在客户模块上线后可选择;当前角色先按「指定客户组」保存,后续再补充授权范围。
</span>
</div>
) : (
<p className="lx-role-scope__note">
已指定 {value.targetIds.length} 个客户组。客户组名称与覆盖客户将在客户模块上线后展示。
</p>
)}
</div>
) : null}
<p className="lx-role-scope__foot">
<span className="lx-role-scope__foot-mark" aria-hidden="true" />
当前范围:{selected.label}
。数据范围随用户所属部门或负责客户动态计算,变更后立即对绑定用户生效。
</p>
</div>
);
}
@@ -0,0 +1,77 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import { Switch } from '@douyinfe/semi-ui-19';
import type { PermissionTree } from '@lingxi/shared';
import { IconAlert, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { specialPermissionRiskOf } from './role-display';
export interface RoleSpecialPermissionsProps {
tree: PermissionTree;
granted: ReadonlySet<string>;
readonly: boolean;
/** 当前角色绑定用户数(授予 / 撤销的影响面) */
holderCount: number;
onRequest: (code: string, granted: boolean) => void;
}
/** 特殊权限区:6 个独立开关,授予 / 撤销由父级弹二次确认,不随复制带入 */
export function RoleSpecialPermissions({
tree,
granted,
readonly,
holderCount,
onRequest,
}: RoleSpecialPermissionsProps): ReactNode {
return (
<div className="lx-role-special">
<div className="lx-role-special__banner">
<IconAlert size={16} />
<span>特殊权限默认关闭,不随角色复制带入;授予后记录审计,并立即对绑定用户生效。</span>
</div>
<div className="lx-role-special__list">
{tree.specialPermissions.map((permission) => {
const active = granted.has(permission.code);
return (
<div
key={permission.code}
className={active ? 'lx-role-special__row is-granted' : 'lx-role-special__row'}
>
<div className="lx-role-special__main">
<div className="lx-role-special__name">
{permission.name}
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
</div>
<code className="lx-role-special__code">{permission.code}</code>
<p className="lx-role-special__desc">{permission.description}</p>
<p className="lx-role-special__risk">
<IconAlert size={13} />
{specialPermissionRiskOf(permission.code)}
</p>
</div>
<div className="lx-role-special__side">
<Switch
checked={active}
disabled={readonly}
aria-label={permission.name}
onChange={(checked) => {
if (!readonly && checked !== active) {
onRequest(permission.code, checked);
}
}}
/>
<span className="lx-role-special__holder">
{active ? <IconStar size={12} /> : null}
持有者 <b>{active ? holderCount : 0}</b> 名
</span>
</div>
</div>
);
})}
</div>
<p className="lx-role-special__note">
特殊权限与普通权限分开授予:普通权限决定能做什么,特殊权限决定能否让数据离开平台或影响全部客户。
</p>
</div>
);
}
@@ -0,0 +1,31 @@
import type { RoleStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { roleStatusLabel } from './role-display';
export interface RoleStatusTagProps {
status: RoleStatus;
/** 列表行内只显示状态点,详情展示完整标签 */
dotOnly?: boolean;
}
export function RoleStatusTag({ status, dotOnly = false }: RoleStatusTagProps): ReactNode {
const active = status === 'active';
return (
<span
className={
active
? dotOnly
? 'lx-status-dot'
: 'lx-status-tag is-active'
: dotOnly
? 'lx-status-dot is-disabled'
: 'lx-status-tag is-disabled'
}
title={roleStatusLabel(status)}
>
<span className="lx-status-tag__dot" aria-hidden="true" />
{dotOnly ? null : roleStatusLabel(status)}
</span>
);
}
@@ -0,0 +1,396 @@
/* eslint-disable max-lines-per-function, complexity -- 列表页负责主从联动、筛选、菜单与五类弹窗编排,集中便于追踪数据流 */
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd, IconSearch, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { Can } from '@/components/Can';
import {
useCreateRoleMutation,
useDeleteRoleMutation,
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
useUpdateRoleMutation,
} from '@/hooks/queries/use-roles';
import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, ROUTES } from '@/routes';
import { PermissionCatalogDrawer } from './PermissionCatalogDrawer';
import {
CopyRoleDialog,
DeleteRoleDialog,
DisableRoleDialog,
RenameRoleDialog,
} from './role-dialogs';
import {
DEFAULT_ROLE_LIST_FILTERS,
filterRoles,
hasActiveRoleFilters,
type RoleListFilters,
summarizeRoles,
} from './role-display';
import { RoleDetailPanel } from './RoleDetailPanel';
import { RoleListPanel, type RoleRowAction } from './RoleListPanel';
/** 角色管理列表查询口径:角色 ≤ 20,一次拉全量后本地筛选(分页由列表卡兜底) */
const ROLE_LIST_QUERY = {
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
} as const;
type DialogState = { kind: 'disable' | 'delete' | 'rename' | 'copy'; role: RoleListItem } | null;
function RoleFilterBar({
filters,
stats,
onChange,
onReset,
}: {
filters: RoleListFilters;
stats: ReturnType<typeof summarizeRoles>;
onChange: (next: Partial<RoleListFilters>) => void;
onReset: () => void;
}): ReactNode {
return (
<section className="lx-role-filters" aria-label="筛选角色">
<Input
className="lx-role-filters__keyword"
value={filters.keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索角色名称或说明"
aria-label="搜索角色"
showClear
onChange={(value) => {
onChange({ keyword: value });
}}
/>
<div className="lx-seg" role="group" aria-label="角色状态">
{[
{ value: 'all' as const, label: '全部', count: stats.total },
{ value: 'active' as const, label: '启用', count: stats.active },
{ value: 'disabled' as const, label: '停用', count: stats.disabled },
].map((option) => (
<button
key={option.value}
type="button"
className={option.value === filters.status ? 'lx-seg__item is-active' : 'lx-seg__item'}
aria-pressed={option.value === filters.status}
onClick={() => {
onChange({ status: option.value });
}}
>
{option.label}
<i className="lx-role-filters__count">{option.count}</i>
</button>
))}
</div>
<label className="lx-role-filters__special">
<Switch
size="small"
checked={filters.specialOnly}
onChange={(checked) => {
onChange({ specialOnly: checked });
}}
/>
只看含特殊权限
</label>
<div className="lx-role-filters__stats" aria-label="角色统计">
<span className="lx-role-filters__chip">
<i>绑定用户</i>
<b>{stats.boundUsers}</b>
</span>
<span className="lx-role-filters__chip">
<i>特殊权限授予</i>
<b>{stats.specialGrants}</b>
</span>
{hasActiveRoleFilters(filters) ? (
<Button theme="borderless" size="small" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
</section>
);
}
/** 角色管理列表页:左角色列表 + 右只读详情面板(/system/roles) */
export function RolesPage(): ReactNode {
const navigate = useNavigate();
const canManage = usePermission(Permission.ROLE_MANAGE);
const tree = usePermissionTreeQuery();
const listQuery = useRoleListQuery(ROLE_LIST_QUERY);
const roles = useMemo(() => listQuery.data?.items ?? [], [listQuery.data]);
const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialog, setDialog] = useState<DialogState>(null);
const [catalogOpen, setCatalogOpen] = useState(false);
const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]);
const stats = useMemo(() => summarizeRoles(roles), [roles]);
const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId)
? selectedId
: (filteredRoles[0]?.id ?? null);
const detail = useRoleDetailQuery(effectiveSelectedId);
const copySource = useRoleDetailQuery(dialog?.kind === 'copy' ? dialog.role.id : null);
const updateRole = useUpdateRoleMutation();
const deleteRole = useDeleteRoleMutation();
const createRole = useCreateRoleMutation();
const openDialog = (kind: 'disable' | 'delete' | 'rename' | 'copy', role: RoleListItem): void => {
setDialog({ kind, role });
};
const handleRowAction = (action: RoleRowAction, role: RoleListItem): void => {
setSelectedId(role.id);
if (action === 'view' || action === 'edit') {
void navigate(roleDetailPath(role.id));
return;
}
if (action === 'enable') {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用,${String(role.userCount)} 名用户下一次请求生效`);
},
},
);
return;
}
openDialog(action, role);
};
const selectedDetail: RoleDetail | undefined = detail.data;
const activeRole = dialog?.role ?? selectedDetail ?? null;
return (
<div className="lx-roles">
<header className="lx-roles__header">
<div>
<h1>角色权限</h1>
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
</div>
<div className="lx-roles__header-actions">
<Button
icon={<IconShield size={14} />}
onClick={() => {
setCatalogOpen(true);
}}
>
权限点目录
</Button>
<Can permission={Permission.ROLE_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={() => {
void navigate(ROUTES.system.roleNew);
}}
>
新建角色
</Button>
</Can>
</div>
</header>
<RoleFilterBar
filters={filters}
stats={stats}
onChange={(next) => {
setFilters((current) => ({ ...current, ...next }));
}}
onReset={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
/>
<div className="lx-roles__split">
<RoleListPanel
roles={filteredRoles}
totalCount={roles.length}
loading={listQuery.isPending}
error={listQuery.error}
selectedRoleId={effectiveSelectedId}
note={`内置 ${String(stats.systemCount)} · 自定义 ${String(stats.total - stats.systemCount)}`}
canManage={canManage}
showRowMenu
isFiltered={hasActiveRoleFilters(filters)}
onSelect={setSelectedId}
onRowAction={handleRowAction}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
onRetry={() => {
void listQuery.refetch();
}}
/>
<RoleDetailPanel
roleId={effectiveSelectedId}
detail={selectedDetail}
tree={tree.data}
loading={detail.isPending}
error={detail.error}
canManage={canManage}
onRetry={() => {
void detail.refetch();
}}
onEdit={(roleId) => {
void navigate(roleDetailPath(roleId));
}}
onCopy={(role) => {
openDialog('copy', role);
}}
onRename={(role) => {
openDialog('rename', role);
}}
onDisable={(role) => {
openDialog('disable', role);
}}
onEnable={(role) => {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用`);
},
},
);
}}
onOpenCatalog={() => {
setCatalogOpen(true);
}}
/>
</div>
<DisableRoleDialog
key={`disable-${dialog?.kind === 'disable' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'disable'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(reason) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{
roleId: dialog.role.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDialog(null);
notifySuccess(
`「${dialog.role.name}」已停用,${String(dialog.role.userCount)} 名用户下一次请求生效`,
);
},
},
);
}}
/>
<DeleteRoleDialog
key={`delete-${dialog?.kind === 'delete' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'delete'}
role={activeRole}
loading={deleteRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={() => {
if (dialog === null) {
return;
}
deleteRole.mutate(dialog.role.id, {
onSuccess: () => {
setSelectedId(null);
setDialog(null);
notifySuccess('角色已删除');
},
});
}}
/>
<RenameRoleDialog
key={`rename-${dialog?.kind === 'rename' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'rename'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{ roleId: dialog.role.id, input: { name } },
{
onSuccess: () => {
setDialog(null);
notifySuccess('角色名称已更新');
},
},
);
}}
/>
<CopyRoleDialog
key={`copy-${
dialog?.kind === 'copy'
? `${dialog.role.id}-${copySource.data?.id ?? 'loading'}`
: 'closed'
}`}
visible={dialog?.kind === 'copy'}
source={copySource.data ?? null}
sourceLoading={copySource.isPending}
loading={createRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
const source = copySource.data;
if (source === undefined) {
return;
}
createRole.mutate(
{
name,
description: source.description,
permissions: [...source.permissions],
dataScope: {
type: source.dataScope.type,
...(source.dataScope.targetIds === undefined
? {}
: { targetIds: [...source.dataScope.targetIds] }),
},
},
{
onSuccess: (result) => {
setDialog(null);
notifySuccess('已创建「' + result.role.name + '」,可在编辑器中继续调整权限');
void navigate(roleDetailPath(result.role.id));
},
},
);
}}
/>
<PermissionCatalogDrawer
visible={catalogOpen}
tree={tree.data}
role={selectedDetail ?? null}
onClose={() => {
setCatalogOpen(false);
}}
/>
</div>
);
}
@@ -0,0 +1,573 @@
/* eslint-disable max-lines-per-function, complexity -- 角色二次确认弹窗模板固定,集中在一处便于统一文案口径 */
import { Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import type { PermissionNode, RoleDetail, RoleListItem } from '@lingxi/shared';
import { IconAlert, IconShield, IconStar } from '@lingxi/ui/icons';
import type { ComponentType } from 'react';
import type { ReactNode } from 'react';
import { useState } from 'react';
import type { RolePermissionMeta } from './role-display';
import {
permissionNameOf,
roleStatusLabel,
scopeLabelOf,
specialPermissionRiskOf,
} from './role-display';
import type { RoleDraftDiff } from './role-utils';
/** 停用 / 删除 / 改名弹窗所需的最小角色字段(列表项与详情均满足) */
export type RoleDialogTarget = Pick<
RoleListItem,
'id' | 'name' | 'code' | 'status' | 'userCount' | 'dataScope' | 'isSystem'
>;
export interface RoleObjectCardProps {
name: string;
meta: string;
status?: string;
icon?: ComponentType<{ size?: number | string }>;
}
/** 弹窗内的角色对象卡 */
export function RoleObjectCard({
name,
meta,
status,
icon: Icon = IconShield,
}: RoleObjectCardProps): ReactNode {
return (
<div className="lx-role-dialog__object">
<span className="lx-role-dialog__object-icon" aria-hidden="true">
<Icon size={16} />
</span>
<span className="lx-role-dialog__object-copy">
<span className="lx-role-dialog__object-title">{name}</span>
<span className="lx-role-dialog__object-meta">{meta}</span>
</span>
{status === undefined ? null : <span className="lx-role-tag">{status}</span>}
</div>
);
}
function DialogWarn({
children,
tone = 'warning',
}: {
children: ReactNode;
tone?: 'warning' | 'danger' | 'info';
}): ReactNode {
return (
<div className={`lx-role-dialog__warn is-${tone}`}>
<IconAlert size={15} />
<div>{children}</div>
</div>
);
}
// 弹窗内容由父级通过 key 控制重挂载:打开时重置初始值,关闭时卸载,避免 effect 同步状态。
export interface DisableRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: (reason: string) => void;
}
/** 停用角色:影响用户数 + 勾选知晓;停用可随时恢复 */
export function DisableRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: DisableRoleDialogProps): ReactNode {
const [acknowledged, setAcknowledged] = useState(false);
const [reason, setReason] = useState('');
return (
<Modal
visible={visible}
title="停用角色"
className="lx-role-modal"
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">
停用可随时恢复,不会删除角色及其权限配置
</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="danger"
disabled={!acknowledged || loading}
loading={loading}
onClick={() => {
onConfirm(reason.trim());
}}
>
停用角色
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${scopeLabelOf(role.dataScope.type)} · ${String(role.userCount)} 名用户`}
status={roleStatusLabel(role.status)}
/>
)}
<DialogWarn>
停用后,{role?.userCount ?? 0}{' '}
名绑定用户将在下一次请求即失去该角色权限;停用不会删除角色配置。
</DialogWarn>
<label className="lx-role-dialog__field">
<span>停用原因(选填,写入审计)</span>
<Input
value={reason}
maxLength={200}
placeholder="例如:岗位调整、角色合并"
onChange={setReason}
/>
</label>
<Checkbox
checked={acknowledged}
onChange={(event) => {
setAcknowledged(event.target.checked === true);
}}
>
我已知晓停用后这些用户下一次请求即失去该角色权限
</Checkbox>
</div>
</Modal>
);
}
export interface DeleteRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/** 删除角色:仅 0 绑定用户可用,删除不可恢复 */
export function DeleteRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: DeleteRoleDialogProps): ReactNode {
return (
<Modal
visible={visible}
title="删除角色"
className="lx-role-modal"
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">删除后不可恢复,审计日志保留授权记录</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="danger"
onClick={onConfirm}
disabled={loading || (role?.userCount ?? 0) > 0}
loading={loading}
>
删除角色
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${String(role.userCount)} 名用户`}
status="自定义"
/>
)}
<DialogWarn tone="danger">
删除后不可恢复,该角色的权限配置会一并删除,授权记录会保留在审计日志中。
</DialogWarn>
<p className="lx-role-dialog__hint">
{role !== null && role.userCount > 0
? '该角色仍有绑定用户,无法删除;请先将用户调整到其他角色,或改为停用。'
: '该角色当前没有绑定用户,可从模板重新创建。'}
</p>
</div>
</Modal>
);
}
export interface RenameRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: (name: string) => void;
}
/** 系统角色改名:仅显示名可改 */
export function RenameRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: RenameRoleDialogProps): ReactNode {
const [name, setName] = useState(() => role?.name ?? '');
const trimmed = name.trim();
return (
<Modal
visible={visible}
title="修改角色名称"
className="lx-role-modal"
width={520}
onCancel={onCancel}
closeOnEsc={!loading}
footer={
<>
<span className="lx-role-dialog__foot-note">改名会记录审计</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="primary"
disabled={trimmed === '' || loading}
loading={loading}
onClick={() => {
onConfirm(trimmed);
}}
>
保存名称
</Button>
</>
}
>
<div className="lx-role-dialog">
<label className="lx-role-dialog__field">
<span>角色名称</span>
<Input
value={name}
maxLength={64}
onChange={setName}
onEnterPress={() => {
if (trimmed !== '' && !loading) {
onConfirm(trimmed);
}
}}
/>
</label>
<DialogWarn tone="info">
超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。
</DialogWarn>
</div>
</Modal>
);
}
export interface CopyRoleDialogProps {
visible: boolean;
source: RoleDetail | null;
sourceLoading: boolean;
loading: boolean;
onCancel: () => void;
onConfirm: (name: string) => void;
}
/** 复制角色:带入普通权限与数据范围,特殊权限不随复制带入 */
export function CopyRoleDialog({
visible,
source,
sourceLoading,
loading,
onCancel,
onConfirm,
}: CopyRoleDialogProps): ReactNode {
const [name, setName] = useState(() => (source === null ? '' : `${source.name} 副本`));
const trimmed = name.trim();
return (
<Modal
visible={visible}
title="复制角色"
className="lx-role-modal"
width={520}
onCancel={onCancel}
closeOnEsc={!loading}
footer={
<>
<span className="lx-role-dialog__foot-note">新角色创建后状态为启用</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="primary"
disabled={trimmed === '' || loading || sourceLoading || source === null}
loading={loading}
onClick={() => {
onConfirm(trimmed);
}}
>
创建副本
</Button>
</>
}
>
<div className="lx-role-dialog">
{source === null ? (
<p className="lx-role-dialog__hint">
{sourceLoading ? '正在读取来源角色…' : '来源角色不可用'}
</p>
) : (
<RoleObjectCard
name={source.name}
meta={`${source.code} · ${scopeLabelOf(source.dataScope.type)} · ${String(source.permissionCount)} 个权限点`}
status={roleStatusLabel(source.status)}
/>
)}
<label className="lx-role-dialog__field">
<span>新角色名称</span>
<Input value={name} maxLength={64} placeholder="请输入新角色名称" onChange={setName} />
</label>
<DialogWarn tone="warning">
将带入 {source?.permissionCount ?? 0} 个普通权限点与「
{source === null ? '—' : scopeLabelOf(source.dataScope.type)}
」数据范围;特殊权限不会随复制带入, 需创建后单独授予。
</DialogWarn>
</div>
</Modal>
);
}
export interface SpecialPermissionDialogProps {
visible: boolean;
permission: PermissionNode | null;
granted: boolean;
holderCount: number;
onCancel: () => void;
onConfirm: () => void;
}
/** 特殊权限授予 / 撤销二次确认(保存前不落库,确认后更新草稿) */
export function SpecialPermissionDialog({
visible,
permission,
granted,
holderCount,
onCancel,
onConfirm,
}: SpecialPermissionDialogProps): ReactNode {
return (
<Modal
visible={visible}
title={granted ? '授予特殊权限' : '撤销特殊权限'}
className="lx-role-modal"
width={520}
maskClosable={false}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">
{granted ? '授予后立即生效' : '撤销后立即生效'}
</span>
<Button onClick={onCancel}>取消</Button>
<Button theme="solid" type={granted ? 'primary' : 'danger'} onClick={onConfirm}>
{granted ? '确认授予' : '确认撤销'}
</Button>
</>
}
>
<div className="lx-role-dialog">
<DialogWarn tone={granted ? 'warning' : 'danger'}>
{granted
? `将为 ${String(holderCount)} 名用户授予「${permission?.name ?? ''}」特殊权限。该权限不随角色复制带入,授予后记录审计。`
: `撤销后 ${String(holderCount)} 名用户将立即失去「${permission?.name ?? ''}」权限,撤销会记录审计。`}
</DialogWarn>
{permission === null ? null : (
<RoleObjectCard
name={permission.name}
meta={permission.code}
status="特殊权限"
icon={IconStar}
/>
)}
<p className="lx-role-dialog__hint">
{permission === null
? ''
: `${permission.description}。${specialPermissionRiskOf(permission.code)}。`}
</p>
</div>
</Modal>
);
}
export interface SaveRoleDialogProps {
visible: boolean;
role: RoleDetail | null;
diff: RoleDraftDiff | null;
meta: ReadonlyMap<string, RolePermissionMeta> | null;
baseName: string;
draftName: string;
baseScope: string;
draftScope: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function ChangeRow({ kind, text }: { kind: string; text: string }): ReactNode {
return (
<div className="lx-role-dialog__change">
<span className="lx-role-dialog__change-kind">{kind}</span>
<span className="lx-role-dialog__change-text">{text}</span>
</div>
);
}
/** 保存前影响面确认:权限 diff + 基本信息 + 数据范围 + 影响用户数 */
export function SaveRoleDialog({
visible,
role,
diff,
meta,
baseName,
draftName,
baseScope,
draftScope,
loading,
onCancel,
onConfirm,
}: SaveRoleDialogProps): ReactNode {
const names = (codes: readonly string[]): string =>
codes.map((code) => (meta === null ? code : permissionNameOf(meta, code))).join('、');
return (
<Modal
visible={visible}
title="保存角色变更"
className="lx-role-modal"
width={560}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">可在审计日志中按角色检索本次变更</span>
<Button onClick={onCancel} disabled={loading}>
继续编辑
</Button>
<Button theme="solid" type="primary" onClick={onConfirm} loading={loading}>
保存并生效
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${String(role.userCount)} 名用户`}
status={roleStatusLabel(role.status)}
/>
)}
<div className="lx-role-dialog__changes">
<div className="lx-role-dialog__changes-head">
变更摘要
<span>{diff?.totalChanges ?? 0} 项</span>
</div>
<div className="lx-role-dialog__change-list">
{diff === null ? null : (
<>
{diff.addedPermissions.length + diff.addedSpecial.length > 0 ? (
<ChangeRow
kind="新增"
text={names([...diff.addedPermissions, ...diff.addedSpecial])}
/>
) : null}
{diff.removedPermissions.length + diff.removedSpecial.length > 0 ? (
<ChangeRow
kind="移除"
text={names([...diff.removedPermissions, ...diff.removedSpecial])}
/>
) : null}
{diff.nameChanged ? (
<ChangeRow kind="名称" text={`${baseName} → ${draftName}`} />
) : null}
{diff.descriptionChanged ? <ChangeRow kind="说明" text="已更新角色说明" /> : null}
{diff.scopeChanged ? (
<ChangeRow kind="范围" text={`${baseScope} → ${draftScope}`} />
) : null}
{!diff.hasPermissionChanges &&
!diff.hasSpecialChanges &&
!diff.nameChanged &&
!diff.descriptionChanged &&
!diff.scopeChanged ? (
<ChangeRow kind="—" text="本次没有实际变更" />
) : null}
</>
)}
</div>
</div>
<p className="lx-role-dialog__hint">
保存后,{role?.userCount ?? 0} 名用户将在下一次请求生效,变更会记录审计(role.updated)。
</p>
</div>
</Modal>
);
}
export interface LeaveRoleDialogProps {
visible: boolean;
roleName: string;
onCancel: () => void;
onConfirm: () => void;
}
/** 未保存离开提示(路由拦截 / 返回列表 / 切换角色共用) */
export function LeaveRoleDialog({
visible,
roleName,
onCancel,
onConfirm,
}: LeaveRoleDialogProps): ReactNode {
return (
<Modal
visible={visible}
title="放弃未保存的修改?"
className="lx-role-modal"
width={480}
maskClosable={false}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">「{roleName}」有未保存的修改</span>
<Button onClick={onCancel}>继续编辑</Button>
<Button theme="solid" type="danger" onClick={onConfirm}>
放弃并离开
</Button>
</>
}
>
<div className="lx-role-dialog">
<DialogWarn tone="warning">
当前角色的权限、数据范围与基本信息修改尚未保存,离开后这些修改将丢失。
</DialogWarn>
</div>
</Modal>
);
}
@@ -0,0 +1,154 @@
import type { PermissionTree, RoleListItem } from '@lingxi/shared';
import { permissionTreeSchema, roleListItemSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
buildPermissionMetaMap,
DEFAULT_ROLE_LIST_FILTERS,
filterRoles,
formatRoleDateTime,
hasActiveRoleFilters,
permissionNameOf,
scopeLabelOf,
summarizePermissionModules,
summarizeRoles,
} from './role-display';
let index = 0;
function buildRole(overrides: Record<string, unknown> = {}): RoleListItem {
index += 1;
return roleListItemSchema.parse({
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: `role_${String(index)}`,
name: `角色 ${String(index)}`,
description: '角色说明',
isSystem: false,
status: 'active',
permissionCount: 4,
specialPermissionCount: 0,
userCount: 3,
dataScope: { type: 'department' },
createdAt: '2026-10-27T02:40:00.000Z',
updatedAt: '2026-10-27T02:40:00.000Z',
...overrides,
});
}
const tree: PermissionTree = permissionTreeSchema.parse({
modules: [
{
module: 'script',
moduleLabel: '脚本',
sortOrder: 10,
permissions: [
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
{ code: 'script:write', name: '编辑与生成脚本', description: '编辑', isSpecial: false },
{ code: 'script:review', name: '审核脚本', description: '审核', isSpecial: false },
],
},
{
module: 'delivery',
moduleLabel: '导出交付',
sortOrder: 40,
permissions: [
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
},
],
specialPermissions: [
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
sodPairs: [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '不得同时拥有',
permissions: ['script:write', 'script:review'],
},
],
totalPermissions: 5,
});
describe('权限点摘要与元数据', () => {
it('按 10 模块口径统计已选 / 总数(含特殊权限)', () => {
const summary = summarizePermissionModules(tree, [
'script:read',
'script:review',
'delivery:export',
]);
expect(summary).toEqual([
{ module: 'script', moduleLabel: '脚本', selected: 2, total: 3 },
{ module: 'delivery', moduleLabel: '导出交付', selected: 1, total: 2 },
]);
});
it('权限点编码映射到名称,缺失时回退编码', () => {
const meta = buildPermissionMetaMap(tree);
expect(permissionNameOf(meta, 'delivery:export')).toBe('发起导出');
expect(meta.get('delivery:export')?.isSpecial).toBe(true);
expect(permissionNameOf(meta, 'ghost:code')).toBe('ghost:code');
});
});
describe('数据范围与时间展示', () => {
it('六类数据范围名称与未知类型兜底', () => {
expect(scopeLabelOf('customer_groups')).toBe('指定客户组');
expect(scopeLabelOf('department_and_children')).toBe('本部门及下级');
expect(scopeLabelOf('created_by_me')).toBe('本人创建');
});
it('ISO 时间转 YYYY-MM-DD HH:mm,空值与非法值显示占位符', () => {
expect(formatRoleDateTime('2026-10-27T02:40:00.000Z')).toMatch(/^2026-10-27 \d{2}:40$/);
expect(formatRoleDateTime(null)).toBe('—');
expect(formatRoleDateTime('not-a-date')).toBe('—');
});
});
describe('角色统计与筛选', () => {
it('汇总角色 / 启用 / 停用 / 绑定用户 / 特殊授予 / 内置数', () => {
const stats = summarizeRoles([
buildRole({ isSystem: true, userCount: 2, specialPermissionCount: 6 }),
buildRole({ status: 'disabled', userCount: 0 }),
buildRole({ userCount: 5, specialPermissionCount: 1 }),
]);
expect(stats).toEqual({
total: 3,
active: 2,
disabled: 1,
boundUsers: 7,
specialGrants: 7,
systemCount: 1,
});
});
it('按关键词 / 状态 / 只看特殊权限筛选', () => {
const roles = [
buildRole({ name: '审核员', code: 'reviewer', description: '脚本与素材审核' }),
buildRole({ name: '设计师', code: 'designer', status: 'disabled' }),
buildRole({ name: '行业配置', specialPermissionCount: 1 }),
];
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, keyword: '审核' }).map(
(role) => role.code,
),
).toEqual(['reviewer']);
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, status: 'disabled' }).map(
(role) => role.name,
),
).toEqual(['设计师']);
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true }).map(
(role) => role.name,
),
).toEqual(['行业配置']);
});
it('识别是否存在生效筛选', () => {
expect(hasActiveRoleFilters(DEFAULT_ROLE_LIST_FILTERS)).toBe(false);
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, keyword: ' ' })).toBe(false);
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true })).toBe(true);
});
});
@@ -0,0 +1,251 @@
import type { DataScopeType, PermissionTree, RoleListItem, RoleStatus } from '@lingxi/shared';
/** 六类数据范围(PRD §5.4.3、技术架构 §6.2)的展示口径 */
export interface RoleScopeOption {
type: DataScopeType;
label: string;
description: string;
/** 是否需要在选中后指定客户组(A3 前客户组无数据) */
needsTargets: boolean;
}
export const ROLE_SCOPE_OPTIONS: readonly RoleScopeOption[] = [
{
type: 'all',
label: '全部客户',
description: '平台内全部客户',
needsTargets: false,
},
{
type: 'department_and_children',
label: '本部门及下级',
description: '用户所属部门及下级部门',
needsTargets: false,
},
{
type: 'department',
label: '本部门',
description: '仅用户所属部门',
needsTargets: false,
},
{
type: 'customer_groups',
label: '指定客户组',
description: '按客户组批量授权',
needsTargets: true,
},
{
type: 'owned_customers',
label: '负责客户',
description: '客户负责人为本人',
needsTargets: false,
},
{
type: 'created_by_me',
label: '本人创建',
description: '仅本人创建的数据',
needsTargets: false,
},
];
const SCOPE_OPTION_BY_TYPE: ReadonlyMap<DataScopeType, RoleScopeOption> = new Map(
ROLE_SCOPE_OPTIONS.map((option) => [option.type, option]),
);
export function scopeOptionOf(type: DataScopeType): RoleScopeOption {
const option = SCOPE_OPTION_BY_TYPE.get(type);
// DataScopeType 是封闭枚举,映射表必然覆盖;兜底仅防御后端返回脏数据
return (
option ?? { type, label: '未知范围', description: '请重新选择数据范围', needsTargets: false }
);
}
export function scopeLabelOf(type: DataScopeType): string {
return scopeOptionOf(type).label;
}
export function scopeDescriptionOf(type: DataScopeType): string {
return scopeOptionOf(type).description;
}
export const ROLE_STATUS_LABEL: Record<RoleStatus, string> = {
active: '启用',
disabled: '停用',
};
export function roleStatusLabel(status: RoleStatus): string {
return ROLE_STATUS_LABEL[status];
}
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
/** ISO 时间 → `YYYY-MM-DD HH:mm`;空值显示占位符 */
export function formatRoleDateTime(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
}
export interface RoleModuleSummary {
module: string;
moduleLabel: string;
selected: number;
total: number;
}
/** 权限点摘要:按目录模块统计已选 / 总数(含特殊权限,与列表计数口径一致) */
export function summarizePermissionModules(
tree: PermissionTree,
selected: readonly string[],
): RoleModuleSummary[] {
const selectedSet = new Set(selected);
return tree.modules.map((node) => ({
module: node.module,
moduleLabel: node.moduleLabel,
selected: node.permissions.filter((permission) => selectedSet.has(permission.code)).length,
total: node.permissions.length,
}));
}
export interface RolePermissionMeta {
code: string;
name: string;
moduleLabel: string;
description: string;
isSpecial: boolean;
}
/** 权限点编码 → 目录元数据(权限树与 SoD 提示共用,避免各页面重复查找) */
export function buildPermissionMetaMap(
tree: PermissionTree,
): ReadonlyMap<string, RolePermissionMeta> {
const map = new Map<string, RolePermissionMeta>();
for (const node of tree.modules) {
for (const permission of node.permissions) {
map.set(permission.code, {
code: permission.code,
name: permission.name,
moduleLabel: node.moduleLabel,
description: permission.description,
isSpecial: permission.isSpecial,
});
}
}
for (const permission of tree.specialPermissions) {
if (!map.has(permission.code)) {
map.set(permission.code, {
code: permission.code,
name: permission.name,
moduleLabel: '特殊权限',
description: permission.description,
isSpecial: true,
});
}
}
return map;
}
/** 权限点名称;目录缺失时回退编码,保证弹窗与摘要不出现空白 */
export function permissionNameOf(
meta: ReadonlyMap<string, RolePermissionMeta>,
code: string,
): string {
return meta.get(code)?.name ?? code;
}
/** 特殊权限风险说明(授予 / 撤销确认弹窗展示) */
export const SPECIAL_PERMISSION_RISK: Record<string, string> = {
'delivery:export': '持有者可批量导出客户数据,仅授予确需对外交付的人员',
'customer_field:approve': '持有者可批准客户数据结构变更,需与申请权限分离',
'industry:publish': '发布后影响全部客户,请与行业包编辑权限分离授予',
'ai:configure': '持有者可修改模型路由与预算额度,需与业务操作分离',
'audit:read': '审计日志包含全平台操作记录,仅授予审计与合规岗位',
'audit:export': '导出后数据离开平台,每次导出均留痕',
};
export function specialPermissionRiskOf(code: string): string {
return SPECIAL_PERMISSION_RISK[code] ?? '特殊权限默认关闭,授予后立即生效并记录审计';
}
export interface RoleStatistics {
total: number;
active: number;
disabled: number;
boundUsers: number;
specialGrants: number;
systemCount: number;
}
export function summarizeRoles(roles: readonly RoleListItem[]): RoleStatistics {
let active = 0;
let boundUsers = 0;
let specialGrants = 0;
let systemCount = 0;
for (const role of roles) {
if (role.status === 'active') {
active += 1;
}
boundUsers += role.userCount;
specialGrants += role.specialPermissionCount;
if (role.isSystem) {
systemCount += 1;
}
}
return {
total: roles.length,
active,
disabled: roles.length - active,
boundUsers,
specialGrants,
systemCount,
};
}
export interface RoleListFilters {
keyword: string;
status: 'all' | 'active' | 'disabled';
specialOnly: boolean;
}
export const DEFAULT_ROLE_LIST_FILTERS: RoleListFilters = {
keyword: '',
status: 'all',
specialOnly: false,
};
/** 角色列表筛选(角色 ≤ 20,前端本地过滤,避免逐字请求) */
export function filterRoles(
roles: readonly RoleListItem[],
filters: RoleListFilters,
): RoleListItem[] {
const keyword = filters.keyword.trim().toLowerCase();
return roles.filter((role) => {
if (filters.status !== 'all' && role.status !== filters.status) {
return false;
}
if (filters.specialOnly && role.specialPermissionCount === 0) {
return false;
}
if (keyword === '') {
return true;
}
const haystack = `${role.name} ${role.code} ${role.description}`.toLowerCase();
return haystack.includes(keyword);
});
}
export function hasActiveRoleFilters(filters: RoleListFilters): boolean {
return filters.keyword.trim() !== '' || filters.status !== 'all' || filters.specialOnly;
}
@@ -0,0 +1,196 @@
import type { RoleDetail } from '@lingxi/shared';
import { roleDetailSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
conflictsFromErrorDetails,
createEmptyDraft,
diffRoleDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
saveResultMessage,
validateCreateDraft,
validateUpdateDraft,
} from './role-utils';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
function buildDetail(overrides: Record<string, unknown> = {}): RoleDetail {
return roleDetailSchema.parse({
id: ROLE_ID,
code: 'account_owner',
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
isSystem: false,
status: 'active',
permissionCount: 5,
specialPermissionCount: 1,
userCount: 5,
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
permissions: [
'customer:read',
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
],
specialPermissions: ['delivery:export'],
boundUsers: [],
createdAt: '2026-10-25T01:30:00.000Z',
updatedAt: '2026-10-25T01:30:00.000Z',
...overrides,
});
}
function draftOf(overrides: Partial<RoleDraft> = {}): RoleDraft {
return {
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
permissions: [
'customer:read',
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
],
specialPermissions: ['delivery:export'],
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
...overrides,
};
}
describe('模板 / 复制预填', () => {
it('默认不带入特殊权限,名称可加副本后缀', () => {
const draft = draftFromDetail(buildDetail(), { nameSuffix: ' 副本' });
expect(draft.name).toBe('客户负责人 副本');
expect(draft.permissions).toHaveLength(5);
expect(draft.specialPermissions).toEqual([]);
expect(draft.dataScope).toEqual({ type: 'customer_groups', targetIds: [GROUP_ID] });
});
it('编辑器初始化时保留特殊权限', () => {
const draft = draftFromDetail(buildDetail(), { includeSpecial: true });
expect(draft.specialPermissions).toEqual(['delivery:export']);
});
it('空白草稿使用最小数据范围', () => {
const draft = createEmptyDraft();
expect(draft.permissions).toEqual([]);
expect(draft.specialPermissions).toEqual([]);
expect(draft.dataScope.type).toBe('created_by_me');
});
});
describe('草稿 diff', () => {
it('完全一致时不是脏状态', () => {
const diff = diffRoleDraft(draftOf(), draftOf());
expect(diff.isDirty).toBe(false);
expect(diff.totalChanges).toBe(0);
});
it('识别权限新增 / 移除、特殊权限与数据范围变化', () => {
const base = draftOf();
const next = draftOf({
permissions: ['customer:read', 'material:read'],
specialPermissions: ['delivery:export', 'audit:read'],
dataScope: { type: 'department', targetIds: [] },
description: '更新说明',
});
const diff = diffRoleDraft(base, next);
expect(diff.addedPermissions).toEqual(['material:read']);
expect(diff.removedPermissions).toEqual([
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
]);
expect(diff.addedSpecial).toEqual(['audit:read']);
expect(diff.removedSpecial).toEqual([]);
expect(diff.scopeChanged).toBe(true);
expect(diff.descriptionChanged).toBe(true);
expect(diff.isDirty).toBe(true);
expect(diff.totalAdded).toBe(2);
expect(diff.totalRemoved).toBe(4);
expect(diff.hasSpecialChanges).toBe(true);
});
});
describe('职责互斥(SoD)', () => {
it('命中脚本生成 × 审核', () => {
const conflicts = findDraftSodConflicts(
draftOf({ permissions: ['script:write', 'script:review'] }),
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.pairId).toBe('script-write-review');
});
it('无交集时返回空数组', () => {
expect(findDraftSodConflicts(draftOf())).toEqual([]);
});
it('解析 40304 错误体 details.conflicts', () => {
const conflicts = conflictsFromErrorDetails({
conflicts: [
{
pairId: 'industry-manage-publish',
label: '行业包编辑 × 行业包发布',
permissions: ['industry:manage', 'industry:publish'],
},
{ pairId: 'bad', label: '非法', permissions: ['ghost:one', 'ghost:two'] },
],
});
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.permissions).toEqual(['industry:manage', 'industry:publish']);
expect(conflictsFromErrorDetails(null)).toEqual([]);
expect(conflictsFromErrorDetails({ conflicts: 'bad' })).toEqual([]);
});
});
describe('保存入参校验', () => {
it('名称为空时返回字段错误', () => {
const result = validateCreateDraft(createEmptyDraft());
expect(result.input).toBeNull();
expect(result.errors.name).toContain('请输入角色名称');
});
it('新建入参把特殊权限放到独立字段,非客户组范围不带 targetIds', () => {
const result = validateCreateDraft(
draftOf({ dataScope: { type: 'department', targetIds: [GROUP_ID] } }),
);
expect(result.input === null).toBe(false);
expect(result.input?.permissions).toHaveLength(5);
expect(result.input?.specialPermissions).toEqual(['delivery:export']);
expect(result.input?.dataScope).toEqual({ type: 'department' });
});
it('更新系统角色只提交显示名', () => {
const detail = buildDetail({ isSystem: true, code: 'super_admin', name: '超级管理员' });
const result = validateUpdateDraft(detail, draftOf({ name: '超级管理员(改名)' }));
expect(result.input).toEqual({ name: '超级管理员(改名)' });
});
it('更新普通角色提交完整权限快照与数据范围', () => {
const detail = buildDetail();
const result = validateUpdateDraft(detail, draftOf({ name: '' }));
expect(result.input).toBeNull();
expect(result.errors.name).toBeTruthy();
const valid = validateUpdateDraft(detail, draftOf());
expect(valid.input?.permissions).toHaveLength(5);
expect(valid.input?.dataScope).toEqual({
type: 'customer_groups',
targetIds: [GROUP_ID],
});
});
it('保存结果文案包含影响用户数', () => {
expect(
saveResultMessage({ role: buildDetail(), affectedUserCount: 3, warnings: [] }),
).toContain('3 名用户');
expect(saveResultMessage({ role: buildDetail(), affectedUserCount: 0, warnings: [] })).toBe(
'角色已保存',
);
});
});
@@ -0,0 +1,259 @@
import {
type CreateRoleInput,
createRoleInputSchema,
type DataScopeType,
findSodConflicts,
isPermissionCode,
type PermissionCode,
type RoleDataScopeInput,
type RoleDetail,
type RoleSaveResult,
type SodConflict,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
/** 编辑器 / 新建页共用的角色草稿(权限与特殊权限分桶,数据范围单选) */
export interface RoleDraft {
name: string;
description: string;
permissions: readonly PermissionCode[];
specialPermissions: readonly PermissionCode[];
dataScope: {
type: DataScopeType;
targetIds: readonly string[];
};
}
export interface DraftFromDetailOptions {
/** 复制 / 模板场景:特殊权限不随角色带入(PRD §5.4.3) */
includeSpecial?: boolean;
/** 名称后缀,例如「 副本」 */
nameSuffix?: string;
}
export function draftFromDetail(
detail: RoleDetail,
options: DraftFromDetailOptions = {},
): RoleDraft {
const suffix = options.nameSuffix ?? '';
return {
name: `${detail.name}${suffix}`,
description: detail.description,
permissions: [...detail.permissions],
specialPermissions: options.includeSpecial === true ? [...detail.specialPermissions] : [],
dataScope: {
type: detail.dataScope.type,
targetIds: [...(detail.dataScope.targetIds ?? [])],
},
};
}
/** 空白草稿:默认最小数据范围(本人创建),特殊权限全关 */
export function createEmptyDraft(): RoleDraft {
return {
name: '',
description: '',
permissions: [],
specialPermissions: [],
dataScope: { type: 'created_by_me', targetIds: [] },
};
}
export interface RoleDraftDiff {
addedPermissions: string[];
removedPermissions: string[];
addedSpecial: string[];
removedSpecial: string[];
nameChanged: boolean;
descriptionChanged: boolean;
scopeChanged: boolean;
totalAdded: number;
totalRemoved: number;
totalChanges: number;
hasPermissionChanges: boolean;
hasSpecialChanges: boolean;
isDirty: boolean;
}
function changedCodes(
base: readonly PermissionCode[],
next: readonly PermissionCode[],
): { added: PermissionCode[]; removed: PermissionCode[] } {
const baseSet = new Set(base);
const nextSet = new Set(next);
return {
added: next.filter((code) => !baseSet.has(code)),
removed: base.filter((code) => !nextSet.has(code)),
};
}
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) {
return false;
}
const rightSet = new Set(right);
return left.every((code) => rightSet.has(code));
}
/** 草稿与基线对比:权限 diff + 基本信息 + 数据范围(保存影响面与脏检查同源) */
export function diffRoleDraft(base: RoleDraft, draft: RoleDraft): RoleDraftDiff {
const permissionDiff = changedCodes(base.permissions, draft.permissions);
const specialDiff = changedCodes(base.specialPermissions, draft.specialPermissions);
const nameChanged = base.name !== draft.name;
const descriptionChanged = base.description !== draft.description;
const scopeChanged =
base.dataScope.type !== draft.dataScope.type ||
!sameStringSet(base.dataScope.targetIds, draft.dataScope.targetIds);
const totalAdded = permissionDiff.added.length + specialDiff.added.length;
const totalRemoved = permissionDiff.removed.length + specialDiff.removed.length;
const hasPermissionChanges = permissionDiff.added.length + permissionDiff.removed.length > 0;
const hasSpecialChanges = specialDiff.added.length + specialDiff.removed.length > 0;
return {
addedPermissions: permissionDiff.added,
removedPermissions: permissionDiff.removed,
addedSpecial: specialDiff.added,
removedSpecial: specialDiff.removed,
nameChanged,
descriptionChanged,
scopeChanged,
totalAdded,
totalRemoved,
totalChanges:
totalAdded +
totalRemoved +
(nameChanged ? 1 : 0) +
(descriptionChanged ? 1 : 0) +
(scopeChanged ? 1 : 0),
hasPermissionChanges,
hasSpecialChanges,
isDirty: totalAdded + totalRemoved > 0 || nameChanged || descriptionChanged || scopeChanged,
};
}
/** 前端实时 SoD 校验(超管豁免由调用方按 isSystem 传入空) */
export function findDraftSodConflicts(draft: RoleDraft): SodConflict[] {
return findSodConflicts([...draft.permissions, ...draft.specialPermissions]);
}
export interface RoleDraftErrors {
name?: string;
description?: string;
}
export interface RoleDraftValidation<TInput> {
input: TInput | null;
errors: RoleDraftErrors;
}
function collectFieldErrors(
issues: readonly { path: readonly PropertyKey[]; message: string }[],
): RoleDraftErrors {
const errors: RoleDraftErrors = {};
for (const issue of issues) {
const field = issue.path[0];
if (field === 'name' && errors.name === undefined) {
errors.name = issue.message;
}
if (field === 'description' && errors.description === undefined) {
errors.description = issue.message;
}
}
return errors;
}
function dataScopePayload(draft: RoleDraft): RoleDataScopeInput {
if (draft.dataScope.type !== 'customer_groups') {
return { type: draft.dataScope.type };
}
return { type: draft.dataScope.type, targetIds: [...draft.dataScope.targetIds] };
}
/** 新建入参:与后端共用 createRoleInputSchema 做同源校验 */
export function validateCreateDraft(draft: RoleDraft): RoleDraftValidation<CreateRoleInput> {
const parsed = createRoleInputSchema.safeParse({
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: dataScopePayload(draft),
});
if (parsed.success) {
return { input: parsed.data, errors: {} };
}
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
}
/**
* 更新入参:系统角色仅显示名可改;普通角色提交完整权限快照与数据范围。
* 后端按字段级 diff 计算影响面,因此这里始终发送当前草稿(未变更字段也一致)。
*/
export function validateUpdateDraft(
detail: RoleDetail,
draft: RoleDraft,
): RoleDraftValidation<UpdateRoleInput> {
const input: UpdateRoleInput = detail.isSystem
? { name: draft.name.trim() }
: {
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: dataScopePayload(draft),
};
const parsed = updateRoleInputSchema.safeParse(input);
if (parsed.success) {
return { input: parsed.data, errors: {} };
}
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isPermissionPair(value: unknown): value is readonly [PermissionCode, PermissionCode] {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === 'string' &&
typeof value[1] === 'string' &&
isPermissionCode(value[0]) &&
isPermissionCode(value[1])
);
}
/**
* 解析 40304 错误体的 `details.conflicts`(后端与前端同一契约):
* 用于保存竞态时把服务端判定回填到 SoD 错误条与冲突高亮。
*/
export function conflictsFromErrorDetails(details: unknown): SodConflict[] {
if (!isRecord(details) || !Array.isArray(details.conflicts)) {
return [];
}
const conflicts: SodConflict[] = [];
for (const item of details.conflicts) {
if (!isRecord(item)) {
continue;
}
if (typeof item.pairId !== 'string' || typeof item.label !== 'string') {
continue;
}
if (!isPermissionPair(item.permissions)) {
continue;
}
conflicts.push({
pairId: item.pairId,
label: item.label,
permissions: [item.permissions[0], item.permissions[1]],
});
}
return conflicts;
}
/** 保存成功结果:特殊权限变化时用于 toast 文案 */
export function saveResultMessage(result: RoleSaveResult): string {
const affected = result.affectedUserCount;
const base = '角色已保存';
return affected > 0 ? `${base},${String(affected)} 名用户将在下一次请求生效` : base;
}
@@ -0,0 +1,173 @@
/* eslint-disable max-lines-per-function -- 编辑器状态与动作集中在一个 Hook,便于表单行为单点维护 */
import {
isPermissionCode,
type PermissionCode,
type PermissionModuleNode,
type RoleDetail,
} from '@lingxi/shared';
import { useCallback, useMemo, useState } from 'react';
import {
diffRoleDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
type RoleDraftDiff,
} from './role-utils';
export interface RoleEditorState {
base: RoleDraft;
draft: RoleDraft;
diff: RoleDraftDiff;
/** SoD 冲突(系统角色豁免,恒为空) */
conflicts: ReturnType<typeof findDraftSodConflicts>;
conflictCodes: ReadonlySet<string>;
isDirty: boolean;
setName: (name: string) => void;
setDescription: (description: string) => void;
togglePermission: (code: string) => void;
toggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
setScopeType: (type: RoleDraft['dataScope']['type']) => void;
setTargetIds: (targetIds: readonly string[]) => void;
setSpecialPermission: (code: string, granted: boolean) => void;
reset: () => void;
markSaved: () => void;
}
function cloneDraft(draft: RoleDraft): RoleDraft {
return {
...draft,
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: {
type: draft.dataScope.type,
targetIds: [...draft.dataScope.targetIds],
},
};
}
/** 角色编辑器状态:草稿 + 基线 + SoD / diff 派生值(纯计算见 role-utils) */
export function useRoleEditor(detail: RoleDetail): RoleEditorState {
const [base, setBase] = useState<RoleDraft>(() =>
draftFromDetail(detail, { includeSpecial: true }),
);
const [draft, setDraft] = useState<RoleDraft>(() =>
draftFromDetail(detail, { includeSpecial: true }),
);
const setName = useCallback((name: string) => {
setDraft((current) => ({ ...current, name }));
}, []);
const setDescription = useCallback((description: string) => {
setDraft((current) => ({ ...current, description }));
}, []);
const togglePermission = useCallback((code: string) => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.permissions.includes(code);
return {
...current,
permissions: has
? current.permissions.filter((item) => item !== code)
: [...current.permissions, code],
};
});
}, []);
const toggleModule = useCallback((module: PermissionModuleNode, selectAll: boolean) => {
const normalCodes: PermissionCode[] = module.permissions
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
.map((permission) => permission.code)
.filter(isPermissionCode);
setDraft((current) => {
const next = new Set(current.permissions);
for (const code of normalCodes) {
if (selectAll) {
next.add(code);
} else {
next.delete(code);
}
}
return { ...current, permissions: [...next] };
});
}, []);
const setScopeType = useCallback((type: RoleDraft['dataScope']['type']) => {
setDraft((current) => ({
...current,
dataScope: {
type,
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
},
}));
}, []);
const setTargetIds = useCallback((targetIds: readonly string[]) => {
setDraft((current) => ({
...current,
dataScope: { type: current.dataScope.type, targetIds: [...targetIds] },
}));
}, []);
const setSpecialPermission = useCallback((code: string, granted: boolean) => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.specialPermissions.includes(code);
if (granted === has) {
return current;
}
return {
...current,
specialPermissions: granted
? [...current.specialPermissions, code]
: current.specialPermissions.filter((item) => item !== code),
};
});
}, []);
const reset = useCallback(() => {
setDraft(cloneDraft(base));
}, [base]);
const markSaved = useCallback(() => {
setBase(cloneDraft(draft));
}, [draft]);
const diff = useMemo(() => diffRoleDraft(base, draft), [base, draft]);
const conflicts = useMemo(
() => (detail.isSystem ? [] : findDraftSodConflicts(draft)),
[detail.isSystem, draft],
);
const conflictCodes = useMemo(() => {
const codes = new Set<string>();
for (const conflict of conflicts) {
codes.add(conflict.permissions[0]);
codes.add(conflict.permissions[1]);
}
return codes;
}, [conflicts]);
return {
base,
draft,
diff,
conflicts,
conflictCodes,
isDirty: diff.isDirty,
setName,
setDescription,
togglePermission,
toggleModule,
setScopeType,
setTargetIds,
setSpecialPermission,
reset,
markSaved,
};
}
+39
View File
@@ -24,6 +24,21 @@ const UserNewPage = lazy(async () => {
return { default: module.UserNewPage }; return { default: module.UserNewPage };
}); });
const RolesPage = lazy(async () => {
const module = await import('@/pages/system/roles/RolesPage');
return { default: module.RolesPage };
});
const RoleEditorPage = lazy(async () => {
const module = await import('@/pages/system/roles/RoleEditorPage');
return { default: module.RoleEditorPage };
});
const RoleNewPage = lazy(async () => {
const module = await import('@/pages/system/roles/RoleNewPage');
return { default: module.RoleNewPage };
});
function withSkeleton(node: ReactNode): ReactNode { function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>; return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
} }
@@ -82,6 +97,30 @@ export const router = createBrowserRouter([
</RequirePermission> </RequirePermission>
), ),
}, },
{
path: 'system/roles',
element: (
<RequirePermission permission={Permission.ROLE_VIEW}>
{withSkeleton(<RolesPage />)}
</RequirePermission>
),
},
{
path: 'system/roles/new',
element: (
<RequirePermission permission={Permission.ROLE_MANAGE}>
{withSkeleton(<RoleNewPage />)}
</RequirePermission>
),
},
{
path: 'system/roles/:id',
element: (
<RequirePermission permission={Permission.ROLE_VIEW}>
{withSkeleton(<RoleEditorPage />)}
</RequirePermission>
),
},
{ {
path: ':moduleKey/:itemKey?', path: ':moduleKey/:itemKey?',
lazy: async () => ({ lazy: async () => ({
+21 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes'; import {
loginPath,
roleDetailPath,
roleNewPath,
ROUTES,
sanitizeRedirect,
userDetailPath,
} from './routes';
describe('路由常量与登录回跳', () => { describe('路由常量与登录回跳', () => {
it('用户路由遵循 /system/users 模块化约定', () => { it('用户路由遵循 /system/users 模块化约定', () => {
@@ -11,6 +18,19 @@ describe('路由常量与登录回跳', () => {
); );
}); });
it('角色路由包含列表 / 新建 / 编辑器深链', () => {
expect(ROUTES.system.roles).toBe('/system/roles');
expect(ROUTES.system.roleNew).toBe('/system/roles/new');
expect(ROUTES.system.roleDetail).toBe('/system/roles/:id');
expect(roleDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
'/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ',
);
expect(roleNewPath()).toBe('/system/roles/new');
expect(roleNewPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
'/system/roles/new?from=01HZZZZZZZZZZZZZZZZZZZZZZZ',
);
});
it('sanitizeRedirect 只接受站内路径', () => { it('sanitizeRedirect 只接受站内路径', () => {
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new'); expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active'); expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
+18
View File
@@ -12,6 +12,9 @@ export const ROUTES = {
users: '/system/users', users: '/system/users',
userNew: '/system/users/new', userNew: '/system/users/new',
userDetail: '/system/users/:id', userDetail: '/system/users/:id',
roles: '/system/roles',
roleNew: '/system/roles/new',
roleDetail: '/system/roles/:id',
}, },
unauthorized: '/401', unauthorized: '/401',
forbidden: '/403', forbidden: '/403',
@@ -26,6 +29,9 @@ export type RoutePath =
| typeof ROUTES.system.users | typeof ROUTES.system.users
| typeof ROUTES.system.userNew | typeof ROUTES.system.userNew
| typeof ROUTES.system.userDetail | typeof ROUTES.system.userDetail
| typeof ROUTES.system.roles
| typeof ROUTES.system.roleNew
| typeof ROUTES.system.roleDetail
| typeof ROUTES.unauthorized | typeof ROUTES.unauthorized
| typeof ROUTES.forbidden | typeof ROUTES.forbidden
| typeof ROUTES.notFound | typeof ROUTES.notFound
@@ -36,6 +42,18 @@ export function userDetailPath(userId: string): string {
return `${ROUTES.system.users}/${encodeURIComponent(userId)}`; return `${ROUTES.system.users}/${encodeURIComponent(userId)}`;
} }
/** 角色详情 / 编辑器深链:`/system/roles/{id}` */
export function roleDetailPath(roleId: string): string {
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
}
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
export function roleNewPath(sourceRoleId?: string): string {
return sourceRoleId === undefined
? ROUTES.system.roleNew
: `${ROUTES.system.roleNew}?from=${encodeURIComponent(sourceRoleId)}`;
}
/** /**
* 校验回跳地址:只允许站内绝对路径(以单个 `/` 开头), * 校验回跳地址:只允许站内绝对路径(以单个 `/` 开头),
* 拒绝协议相对地址(`//evil.com`)与外部 URL,避免开放重定向。 * 拒绝协议相对地址(`//evil.com`)与外部 URL,避免开放重定向。
+1 -1
View File
@@ -24,7 +24,7 @@ const me: MeResponse = {
}, },
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }], roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'], permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' }, dataScopes: [{ type: 'all' }],
mustChangePassword: false, mustChangePassword: false,
}; };
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
# ADR-001 角色权限模型:权限点目录、特殊权限、SoD 与六类数据范围
| 项 | 内容 |
| --- | --- |
| 状态 | 已接受(2026-11-03,H1 权限门用户确认) |
| 决策人 | 产品 / 技术负责人(用户) |
| 记录人 | 工程 Lead |
| 关联 | 开发计划 V0.1 §3.4 H1 门、§6.3 A2、§7.2、§10.3、§13 DP-14;技术架构 V0.4 §5.5 / §6.1–6.3 / §7.1 / §8.7;PRD V1.6 §5.4.3、R-16~R-18 |
| 影响范围 | `packages/shared` 权限点与数据范围契约、`identity` 模块、`prisma` 迁移与种子、`apps/web` 角色管理页、A3 客户模块、后续所有受保护端点 |
## 背景
A2「角色权限与数据范围」开卡前必须完成 H1 权限门(开发计划 §3.4):确认权限点目录、六类数据范围、SoD 互斥对、特殊权限授予规则,否则权限模型返工会影响全部页面。
现状与文档存在差异:
- `packages/shared/src/permissions.ts` 是 A0 骨架,共 25 个 `read/write` 风格编码;缺少 `role:view`、`customer_field:apply`、`customer:transfer`、`customer_group:manage`、`asset:delete`。
- 开发计划附录 B 使用 `view/manage` 风格并列出更多编码;但其自身注明「最终以 `packages/shared` 为准」。
- PRD §5.4.3 的权限粒度更细(如图片生成 / 视频生成拆开),完整实现会显著增加权限点数量与配置成本。
- A3 客户模块尚未开发,而六类数据范围中的「指定客户组 / 负责客户 / 本人创建」依赖客户与客户组数据。
## 决策
### D1 权限点目录:保留现有编码,只增不改
以 `packages/shared` 现有 25 个编码为基线,补齐 A2 必需的 5 个编码:
`role:view`、`customer_field:apply`、`customer:transfer`、`customer_group:manage`、`asset:delete`。
合计 **30 个权限点 / 10 个模块**。命名保持 `<模块>:<动作>` 与 `read/write` 动作词;后续新增权限点通过代码目录注册并同步 `permissions` 表,**不手写迁移**。
### D2 特殊权限:M0 五项 6 编码
| 特殊权限 | 编码 |
| --- | --- |
| 导出 | `delivery:export` |
| AI 配置 | `ai:configure` |
| 审计查看 | `audit:read` |
| 审计导出 | `audit:export` |
| 行业包发布 | `industry:publish` |
| 字段审批 | `customer_field:approve` |
规则:默认关闭、单独授予、不随角色复制带入、授予与撤销二次确认并写入审计。PRD 中的「跨客户复用授权」「批量删除」按开发计划 DP-14 留到 M2/M3,届时先回写技术架构并补编码,**A2 不实现**。
### D3 SoD 三对互斥:按现有编码映射
| 互斥对 | 编码 |
| --- | --- |
| 脚本生成与审核 | `script:write` × `script:review` |
| 行业包编辑与发布 | `industry:manage` × `industry:publish` |
| 字段申请与审批 | `customer_field:apply` × `customer_field:approve` |
保存角色时校验,命中返回 `40304`;仅角色级校验(同一角色不可同时持有),超级管理员豁免。用户通过多个角色组合持有的情况不在 A2 校验范围(PRD 表述为「不可同属一个角色」)。
### D4 六类数据范围:全链路实现,客户集合通过端口隔离
A2 建立 `data_scopes` 表、六类范围解析引擎、`ScopeContext` 注入链路与 Repository 客户维度强制参数;角色保存时持久化范围类型与目标(指定客户组可多个)。
客户集合解析通过 `CustomerDirectory` 端口完成:
- A2 实现 `EmptyCustomerDirectory`,在 A3 客户模块落地前返回空集;
- 测试使用 fake 客户目录覆盖非空场景;
- A3 接入真实 `customers` / `customer_groups` 后替换实现,`ScopeContext` 与 Repository 契约不变。
失败处理:数据范围解析失败一律 403,**不降级为「全部客户」**。多角色取并集;任一角色为「全部客户」则结果为全部。
### D5 超级管理员:隐式全集
`is_system = true` 的角色自动拥有全部权限点、全部特殊权限与全部数据范围,不依赖、也不可编辑 `role_permissions` / `data_scopes` 记录;仅显示名可改,不可删除、不可停用。
角色数量 > 20、超管人数 > 2 仅做非阻断提示(PRD 为「建议值」)。相关建议值当前以共享常量表达,A4 系统配置落地后迁入 `system_configs`(R-26 单一存放处)。
### D6 变更审计:A2 结构化日志,A5 落库
角色变更(新建 / 编辑 / 复制 / 停用 / 删除 / 特殊权限授予)记录结构化日志:操作人、账号、IP、trace_id、目标角色、权限点前后值、特殊权限前后值、影响用户数。
同时写 `outbox_events`(`role.permissions.changed`,payload 含受影响用户 ID)作为「受影响用户通知」占位,消费方随 A5/A6 接入。审计表落库、检索与导出由 A5 完成;`@Audit` 装饰器签名保持不变。
## 后果
### 正面
- A1 已实现代码零迁移,A2 可在现有 25 编码上平滑扩展。
- 六类范围与 ScopeContext 一次建好,A3 只需实现 `CustomerDirectory` 与客户表,避免范围逻辑二次返工。
- 超管隐式全集避免新增权限点后超管缺权,减少权限事故。
- 权限目录代码化 + 启动同步,新增权限点无需手写迁移。
### 代价与约束
- 权限点命名保留 `read/write`,与开发计划附录 B 的 `view/manage` 不一致;后续文档引用以 `packages/shared` 为准,附录 B 需在下一版修订时对齐(不改语义)。
- A2 的 `客户组 / 负责客户 / 本人创建` 三类范围在 A3 前无可选目标,UI 需提供空态;A2 验收以解析引擎与 fake 客户目录单测为准,不等同于真实客户隔离验收。
- 「审计查看」作为特殊权限后,普通角色默认无法查看审计;符合 PRD「审计单独授权」原则。
- 权限点数(30)与 PRD 操作级粒度仍有差距,图片 / 视频生成等细分待 M1 前按需补充,遵循「只增不改」。
## 待办 / 回写
| 项 | 责任 | 时点 |
| --- | --- | --- |
| 开发计划附录 B 权限点表与 ADR-001 对齐(命名/增补) | 产品 / 架构 | A2 收口时 |
| 技术架构 §6.3 补 `role:view`、`customer_field:apply` 等编码说明 | 架构 | A2 收口时 |
| A3 实现 `CustomerDirectory` 真实客户目录并补范围验收 | 研发 | A3 |
| DP-14 两项特殊权限编码与架构回写 | 架构 | M2/M3 前 |
| 角色数量 / 超管数量建议值迁入 `system_configs` | 研发 | A4 |
| 审计落库、检索、导出 | 研发 | A5 |
+15
View File
@@ -0,0 +1,15 @@
# ADR 索引
> 架构决策记录(Architecture Decision Record)。格式:`ADR-xxx-标题.md`,编号连续不复用。
> 一个 ADR 只记录一个决策及其后果;被推翻时保留原文并标「已废弃 / 被 ADR-xxx 取代」。
| 编号 | 决策 | 状态 | 日期 | 关联 |
| --- | --- | --- | --- | --- |
| [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 |
## 新增流程
1. 复制 `ADR-001` 的章节结构:背景 → 决策 → 后果 → 待办 / 回写。
2. 编号取当前最大值 +1,文件名用中文短标题。
3. 涉及多模块或权限 / 数据口径的决策,须在开发计划的对应 H 门确认后记录。
4. 更新本索引与根 `README.md` 文档索引。
@@ -0,0 +1,129 @@
# 灵犀 A2 角色权限与数据范围 · 实施报告 V0.1
| 项 | 内容 |
| --- | --- |
| 实施日期 | 2026-11-03 |
| 批次 | A2(角色权限与数据范围) |
| 状态 | 已完成,门禁通过;未推送 |
| 依据 | 开发计划 V0.1 §3.4 H1 门、§6.3、§7.2、§10.3、附录 B;技术架构 V0.4 §5.5 / §6.1–6.3 / §7.1 / §8.7;PRD V1.6 §5.4.3、R-16~R-18 |
| 决策记录 | [ADR-001 权限点目录与六类数据范围](../adr/ADR-001-权限点目录与六类数据范围.md) |
| 视觉基线 | A0「方向 A · 云白蓝」+ A1 已选定「B 主从分栏」;本轮 UI 门选定 **B 主从分栏** |
---
## 1 范围与完成度
| 任务 | 内容 | 状态 |
| --- | --- | --- |
| A2-BE-01 | 角色 CRUD / 复制 / 停用 / 删除、系统角色保护、重名校验 | ✅ |
| A2-BE-02 | 权限点目录 30 个 / 10 模块代码注册,启动同步 `permissions` 表,`GET /permissions` 树 | ✅ |
| A2-BE-03 | 角色权限绑定、特殊权限 6 编码单独授予、特殊权限分桶校验 | ✅ |
| A2-BE-04 | 六类数据范围、`data_scopes`、`ScopeContext`、`CustomerDirectory` 端口、fail-closed | ✅(A3 前客户集为空集) |
| A2-BE-05 | SoD 三对保存即校验(40304)、变更审计(前后值 / 影响用户数)、outbox 占位通知 | ✅ |
| A2-FE-01 | 角色列表、权限点树、六类范围、特殊权限区、保存影响面、SoD 冲突 | ✅ |
| A2-FE-02 | 模板 / 复制 / 停用 / 删除 / 系统角色改名 / 绑定用户 / 只读态 | ✅ |
| A2-QA-01 | 角色端点权限三态、SoD 三对、即时生效、特殊权限分桶、浏览器 e2e | ✅ |
H1 权限门口径(用户确认):权限点保留现有 `read/write` 编码只补缺(30 个 / 10 模块);特殊权限五项 6 编码;SoD 三对按现有编码映射;六类数据范围全链路 + A3 前空客户集;超管隐式全集;A2 审计先结构化日志、A5 落库。详见 ADR-001。
---
## 2 交付物
### 2.1 共享契约(`packages/shared`)
- `permissions.ts`:30 个权限点常量与元数据目录(`module` / `moduleLabel` / `name` / `description` / `isSpecial` / `sortOrder`)、10 个模块顺序、`SPECIAL_PERMISSIONS`(6)、`SOD_PAIRS`(3)、`PERMISSION_CATALOG`、`permissionsByModule()`、`splitPermissions()`、`findSodConflicts()`、软上限常量。
- `schemas/roles.ts`:`RoleListItem` / `RoleDetail` / `RoleBoundUser` / `RoleSaveResult` / `CreateRoleInput` / `UpdateRoleInput` / `RoleListQuery` / `PermissionTree` / SoD pair;写接口 `.strict()`,特殊权限分桶校验。
- `schemas/identity.ts`:`DataScope` 增 `targetIds` / `customerIds`;`MeResponse.dataScopes: DataScope[]`(多角色并集,超管 `[{type:'all'}]`)。
- 模块与权限点显示名与 `design/a2-ui-v1/brief.md` 逐条对齐;权限点只增不改。
### 2.2 数据库与种子
- 迁移 `prisma/migrations/20261103025415_a2_role_permissions_data_scopes/`(expand):新增 `permissions`(目录,code 主键)与 `data_scopes`(role_id + scope_type + target_id,复合唯一索引、FK);未删改既有字段。空库与存量库各验证一次。
- `prisma/seed.ts` 幂等更新:30 权限点、10 角色、角色权限映射(普通权限数 24/12/9/4/2/4/3/5/4/5)、data_scopes(含客户负责人 `customer_groups` 空目标行)、特殊权限 8 项授予(超管 6 + 平台管理员 `audit:read` + AI 工程师 `ai:configure`);`prisma/SEED.md` 同步。
- 说明:种子用户沿用 A1 的 11 个示例账号(12 条角色绑定),不扩种原型示例的 28 名用户。
### 2.3 后端(`apps/api`)
- identity 模块:`GET/POST /roles`、`GET/PATCH/DELETE /roles/{id}`、`GET /permissions`;`GET /roles` 默认 `status=active` 兼容 A1 用户页角色多选,同时接受 `role:view` / `user:view`;角色管理页显式传 `status=all`。
- 权限解析:每请求实时读 DB;`is_system` 超管隐式全部权限点、特殊权限与全部数据范围;停用角色即时失效;未登记权限点被丢弃。
- 数据范围:`ScopeGuard` 解析六类范围为 `ScopeContext`;`CustomerDirectory` 端口 + `EmptyCustomerDirectory`(A3 前空集,不降级);`customerScopeWhere` / `isCustomerVisible` / `assertCustomerAccess` / `requireScopeContext` 与单测;多角色并集,任一 `all` 即全部;解析失败 40302。
- SoD:三对互斥保存即校验,命中 40304 并返回 `details.conflicts`;系统角色不可改权限/范围/状态,仅显示名可改;超管只在系统角色自身豁免。
- 审计:角色新建/更新/停用/删除记录前后值、权限 diff、影响用户数、IP/UA/trace_id;`outbox_events` 写 `role.permissions.changed`(含 affectedUserIds)占位通知。
- 测试:`roles.service.spec.ts`、`permission-catalog.service.spec.ts`、`scope.spec.ts`、`roles.int.spec.ts`。
### 2.4 前端(`apps/web`)
- 路由:`/system/roles`(`role:view`,左 400 角色列表 + 右详情)、`/system/roles/:id`(编辑器,Tab:基本信息/权限点/数据范围/特殊权限 + 底部唯一保存)、`/system/roles/new`(`role:manage`,左模板 + 右表单);导航 `/system/roles` 权限由 `ROLE_MANAGE` 调整为 `ROLE_VIEW`;面包屑补角色三页。
- API/Query:`api/roles.ts`、`hooks/queries/use-roles.ts`、`query-keys`、`endpoints`;全部用 shared Zod schema 解析。
- 页面能力:权限树 10 模块/30 点三态 + 搜索 + 展开;特殊权限 6 项二次确认;数据范围六类单选 + 客户组空态;模板预填(特殊不带入);复制/停用/删除/系统角色改名;保存前影响确认;SoD 实时冲突与 40304 回填;未保存离开拦截;权限点目录抽屉;只读态;加载/空/错误态;1024 适配。
- 样式:`styles/roles.css`、`styles/role-editor.css`,复用 `--lx-*` token 与现有 `.lx-*` 组件类;未新增依赖、未内联 SVG。
### 2.5 UI 原型与决策
- 三套候选原型:`design/a2-ui-v1/`(A 独立编辑器 / B 主从分栏 / C 卡片墙 + 向导),共享口径 `brief.md`,对比页 `index.html` + `style-explorer-*.html`。
- 独立视觉评审:A 8 / B 8 / C 7,均无阻断;推荐 B。用户于 2026-11-03 选定 **B 主从分栏**,实现以 `design/a2-ui-v1/b/` 为准。
- 实现截图:`design/a2-ui-v1/shots/impl/final/`(clean seed);QA e2e 截图:`design/a2-ui-v1/shots/impl/e2e/`(含测试数据)。
---
## 3 验证结果
| 门禁 | 命令 | 结果 |
| --- | --- | --- |
| 格式 | `pnpm format:check` | ✅ |
| Lint | `pnpm lint` | ✅ 0 warning |
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
| 单测 | `pnpm test` | ✅ shared 119 / api 85 / web 102,共 306 |
| 集成 | `pnpm test:int` | ✅ 3 文件 / 27 条(health 1 + identity 13 + roles 13) |
| 浏览器 e2e | `A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts` | ✅ 1 passed(18.9s),复跑 1 passed(19.3s) |
| OpenAPI | `pnpm openapi:export` | ✅ 新增 `/roles`、`/roles/{id}`、`/permissions` 路径 |
A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)、40401、40903、40302 fail-closed;SoD 三对 40304 + `details.conflicts`、失败不落库、系统角色豁免边界;特殊权限分桶与单独授予、复制不带入;同一 access token 的改权限/授特殊/停用/恢复即时生效;outbox 变更审计。
---
## 4 本地运行
```bash
pnpm check:dev # 确认 PG 5432 / Redis 6379
pnpm db:reset # 迁移 + 种子(admin / lingxi2026,首次登录须改密)
pnpm dev # API :3000 + Web :5173
# 浏览器打开 http://127.0.0.1:5173/login → 管理中心 / 角色权限
```
种子账号:`admin` 超管、`jiang.lan` 平台管理员等 11 个账号;初始密码 `lingxi2026`,首次登录强制修改。
---
## 5 已知差异与遗留
| 编号 | 项 | 说明 / 建议 |
| --- | --- | --- |
| 1 | 客户组 / 负责客户 / 本人创建 | A2 已落解析引擎与 `ScopeContext`,但 A3 前 `CustomerDirectory` 返回空集;「指定客户组」只能先保存范围、A3 后再补授权对象。A3 接入真实客户目录并补范围验收。 |
| 2 | 审计落库 | A2 角色变更审计为结构化 Pino 日志 + outbox 事件;审计表落库、检索、导出按 A5。A2-QA-01「越权访问有审计记录」当前以结构化日志 + outbox 为准。 |
| 3 | DP-14 两项特殊权限 | 「跨客户复用授权」「批量删除」暂未实现,待 M2/M3 前回写架构并补编码。 |
| 4 | 导航模块权限 | 管理中心模块级 permission 仍为 `USER_VIEW`;仅 `role:view` 无 `user:view` 的用户侧栏不可见该模块(路由直达可用)。建议后续统一模块级导航口径。 |
| 5 | 面包屑 | 角色编辑器面包屑末级为静态「角色编辑」,未显示角色名;原型为角色名,可在后续接入路由 handle 或上下文数据。 |
| 6 | 原型示例数据 | 原型「28 名绑定用户」为 UI 示例;种子实际 11 个账号(12 条绑定)。实现页显示真实种子数据。 |
| 7 | OpenAPI schema | 路径已导出,请求/响应 schema 仍缺(Zod 入参无 DTO 类),沿用 A1 遗留;前端直接使用 shared 类型。 |
| 8 | e2e 数据 | A2 e2e 每次新增 1 个仅停用的 `qa2.roles.*` 用户(用户不可删除,与 A1 同口径),本地库会累积停用用户;角色在解绑后删除。 |
| 9 | 前端可访问性 | Semi Modal 复用 `semi-modal-title` id,关闭动画期间两个弹窗同时挂载可能导致可访问名串台;e2e 已用按钮/文案定位规避,建议后续给 Modal 传唯一 `aria-label`。 |
---
## 6 回滚方式
- 代码:整体回滚本批 commit;前端三页、共享契约、identity 角色接口与测试一并回退。
- 数据库:本批为 expand 迁移,回滚代码不需要删表;如需彻底回退,用 `prisma migrate resolve` 标记后手工 drop `permissions` / `data_scopes`(仅本地)。
- 数据:种子可重跑;角色权限映射幂等重建;`data_scopes` 随角色删除级联。
- 配置:未新增环境变量。
---
## 7 下一步
1. **A3 客户与行业目录**:实现真实 `CustomerDirectory`、客户组 / 负责客户 / 本人创建范围验收;替换用户停用前客户转交占位。
2. **A5 审计**:`@Audit` 与角色变更审计落库、检索、导出,A2 e2e 增加审计断言。
3. **DP-14**:M2/M3 前回写架构并补「跨客户复用授权」「批量删除」编码。
4. **工程补强**:OpenAPI schema 由 shared Zod 生成;模块级导航权限口径统一;角色编辑器面包屑显示角色名;Semi Modal 唯一 aria-label。
@@ -10,7 +10,7 @@
| 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 | | 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 |
| 版本 | V0.1(初稿,待评审) | | 版本 | V0.1(初稿,待评审) |
| 日期 | 2026-11-02 | | 日期 | 2026-11-02 |
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 待启动 | | 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);下一步 A3 |
| 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) | | 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) |
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 | | 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** | | 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
@@ -379,7 +379,7 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 | | **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
| **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 | | **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 |
| **A2** | 未开始 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 | | **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 |
| **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 | | **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
| **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 | | **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 |
| **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 | | **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 |
@@ -395,6 +395,8 @@ flowchart LR
> **A0 进度(2026-11-02)**:✅ 已完成。10/10 项 WBS 通过;本批门禁 `format:check / lint / typecheck / test / build / test:int / openapi:export / openapi:diff / e2e` 与本地 CI `pnpm ci:local` 全绿;`/health`、`/api/docs`、web 布局与登录壳、worker 启动均实测通过。实现记录与偏差见 [A0 实施报告](./灵犀_A0工程骨架与契约基线_实施报告_V0.1.md)。环境适配:本地 PG/Redis 复用已有 Docker、对象存储使用公网 RustFS、种子仅 `system_configs`(`Q-A0-01`)、Gitea 暂无 runner 时以 `pnpm ci:local` 为准。 > **A0 进度(2026-11-02)**:✅ 已完成。10/10 项 WBS 通过;本批门禁 `format:check / lint / typecheck / test / build / test:int / openapi:export / openapi:diff / e2e` 与本地 CI `pnpm ci:local` 全绿;`/health`、`/api/docs`、web 布局与登录壳、worker 启动均实测通过。实现记录与偏差见 [A0 实施报告](./灵犀_A0工程骨架与契约基线_实施报告_V0.1.md)。环境适配:本地 PG/Redis 复用已有 Docker、对象存储使用公网 RustFS、种子仅 `system_configs`(`Q-A0-01`)、Gitea 暂无 runner 时以 `pnpm ci:local` 为准。
> >
> **A1 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 95 / api 60 / web 55),`test:int` 7 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:六类数据范围(A2)、客户转交真实接口(A3)、审计落库(A5)。 > **A1 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 95 / api 60 / web 55),`test:int` 7 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:六类数据范围(A2)、客户转交真实接口(A3)、审计落库(A5)。
>
> **A2 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;H1 权限门已确认并记录 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md);UI 门三套原型独立评审 A 8 / B 8 / C 7,用户选定 **B 主从分栏**。`format:check / lint / typecheck / test` 全绿(shared 119 / api 85 / web 102),`test:int` 27 项(真实 PG / Redis),A2-QA-01 浏览器 e2e 1/1(复跑通过);角色可配、SoD 40304、特殊权限默认关闭、六类范围解析与 `ScopeContext` 全链路、系统角色保护、变更审计与 outbox 均实测。实现记录、口径与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。已知遗留:A3 前客户目录为空集、A5 审计落库、DP-14 两项特殊权限、导航模块级权限口径。
### 5.3 批次依赖图 ### 5.3 批次依赖图
@@ -561,6 +563,8 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 | | A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 |
| A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 | | A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 |
> **A2 完成情况(2026-11-03)**:8/8 项 WBS 完成。H1 权限门确认:30 权限点 / 10 模块(保留 `read/write` 只补缺)、特殊权限五项 6 编码、SoD 三对、六类数据范围全链路、超管隐式全集、A2 审计先结构化日志;见 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md)。UI 三套原型评审 A 8 / B 8 / C 7,用户选定 B 主从分栏。`test:int` 27 项、A2 e2e 1/1,门禁全绿;实现与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。
**本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。 **本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。
### 6.4 A3 客户与行业目录 ### 6.4 A3 客户与行业目录
+27 -1
View File
@@ -3,9 +3,10 @@
## 现有用例 ## 现有用例
| 文件 | 说明 | 默认是否执行 | | 文件 | 说明 | 默认是否执行 |
| --------------------- | ---------------------------------- | ----------------- | | --------------------- | --------------------------------------------- | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | | `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | | `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
## A1 账号生命周期用例 ## A1 账号生命周期用例
@@ -33,3 +34,28 @@ A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts --reporter=list
- 门控未开启(无 `A1_E2E=1`)时整文件 `test.skip`,`pnpm test:e2e` 与 CI 行为不变。 - 门控未开启(无 `A1_E2E=1`)时整文件 `test.skip`,`pnpm test:e2e` 与 CI 行为不变。
- 若在 `vite preview`(4173)上执行,需把 API 的 `WEB_ORIGIN` 同时包含 `http://127.0.0.1:4173`,否则跨域请求被拦;本用例默认打 5173 dev,避免改动 API 配置。 - 若在 `vite preview`(4173)上执行,需把 API 的 `WEB_ORIGIN` 同时包含 `http://127.0.0.1:4173`,否则跨域请求被拦;本用例默认打 5173 dev,避免改动 API 配置。
## A2 角色管理用例(A2-QA-01)
覆盖一条完整旅程(真实 API + 真实 PG/Redis + 真实浏览器):
1. 管理员登录 → `/system/roles` → 新建角色(名称、2 个普通权限、数据范围「本部门」)→ 保存进入编辑器。
2. 编辑器改权限点;授予特殊权限(`delivery:export` 开关)触发**二次确认**,保存前再确认一次(特殊权限双确认)。
3. SoD 冲突:同时勾选 `script:write` × `script:review` → 顶部「命中职责互斥」拦截 + 底部「存在职责互斥,保存不可用」且保存按钮禁用;解除后恢复可保存。
4. 用 API 断言**权限即时生效**:绑定用户同一 access token 的 `/me` 在下一次请求即包含 `user:view` 与 `delivery:export`,`GET /users` 由 403 → 200;`GET /roles/{id}` 的普通权限与特殊权限分桶正确。
5. 权限三态(UI 侧):该用户有 `user:view` 但无 `role:view`,访问 `/system/roles` 显示 403 页。
6. 停用确认(勾选知晓 + 停用原因)→ 编辑器状态变「停用」;API 断言 `/me` 权限清空、`GET /users` 返回 403 `40301`;列表「停用」筛选下可见该角色且行内标记「停用」。
7. 数据自建自清:`beforeAll` 按前缀清理上次异常中断的遗留角色/用户;`afterAll` 解绑并停用 `qa2.roles.*` 用户、删除 `A2E2E-角色-*` 角色(不停用 admin、不改 admin 密码)。
### 前置与运行
前置与 A1 相同(API `http://127.0.0.1:3000/api/v1`、Web `http://127.0.0.1:5173` 非 demo 会话、已 `db:reset` + seed):
```bash
A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
```
- 门控未开启(无 `A2_E2E=1`)时整文件 `test.skip`,`pnpm test:e2e` / CI 行为不变。
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
+711
View File
@@ -0,0 +1,711 @@
/// <reference lib="dom" />
/**
* A2-QA-01 · 角色管理端到端用例(真实 API + 真实 PG/Redis + 真实浏览器)
*
* 覆盖(对应开发计划 §6.3 A2-QA-01 / A2-FE-01 / A2-FE-02 验收要点):
* ① 管理员登录 → /system/roles → 新建角色(填名称、勾 2 个权限、选「本部门」)→ 保存 → 进入编辑器
* ② 编辑器改权限点 + 授予特殊权限:特殊权限二次确认(开关一次 + 保存前一次)
* ③ SoD 冲突:同时勾选 script:write × script:review → 顶部拦截文案 + 底部「保存不可用」禁用保存
* ④ 保存后 API 断言权限即时生效:绑定用户同一 access token 的 /me 立即包含新权限与特殊权限
* ⑤ 无 role:view 的账号访问 /system/roles → 浏览器 403 页(权限三态 UI 侧)
* ⑥ 停用确认(勾选知晓 + 停用原因)→ 列表状态变为「停用」;API 断言用户下一次请求失去权限(40301)
*
* 运行前置(本地):
* - API:http://127.0.0.1:3000/api/v1(真实 PG + Redis)
* - Web:http://127.0.0.1:5173(`pnpm --filter @lingxi/web dev`,非 demo 会话)
* - 门控:仅在 A2_E2E=1 时执行,否则整文件 skip,不影响 CI 与 e2e/smoke.spec.ts
*
* 运行命令:
* A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
*
* 测试数据:管理员 actor 默认种子超管 jiang.lan(初始口令 lingxi2026 → 首次运行改为
* QaE2eAdmin2026,与 A1 e2e 共用约定,不要用 admin);用例自建 1 个 `qa2.roles.*` 用户与
* 1 个 `A2E2E-角色-*` 角色,结束后解绑 / 停用用户并删除角色(自建自清),不修改 admin 密码。
*/
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
type APIRequestContext,
type APIResponse,
expect,
type Locator,
type Page,
request,
test,
} from '@playwright/test';
const API_BASE = process.env.A2_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A2_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A2_E2E === '1';
const ACTOR_ACCOUNT = process.env.A2_E2E_ACTOR_ACCOUNT ?? 'jiang.lan';
const ACTOR_PASSWORD = process.env.A2_E2E_ACTOR_PASSWORD ?? 'QaE2eAdmin2026';
const SEED_PASSWORD = process.env.A2_E2E_SEED_PASSWORD ?? 'lingxi2026';
const QA_INITIAL_PASSWORD = 'QaInit2026x';
const QA_PASSWORD = 'QaRole2026x';
const SCREENSHOT_DIR = resolve('design/a2-ui-v1/shots/impl/e2e');
/** 角色 / 用户前缀:用于自清理与识别遗留数据(不影响 seed 与其他用例) */
const ROLE_PREFIX = 'A2E2E-角色-';
const QA_ACCOUNT_PREFIX = 'qa2.roles.';
test.skip(!ENABLED, 'A2_E2E=1 才运行角色管理 e2e(依赖 API :3000 + web :5173 + PG/Redis)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 类型与断言辅助(仅依赖 @playwright/test,避免 e2e 引入额外运行时依赖)
// ---------------------------------------------------------------------------
interface ApiResult {
status: number;
body: unknown;
}
interface QaUser {
id: string;
account: string;
}
let apiContext: APIRequestContext | null = null;
let actorToken: string | null = null;
let bootstrapError: string | null = null;
let qaUser: QaUser | null = null;
let departmentId = '';
let materialOperatorRoleId = '';
let createdRoleId = '';
let createdRoleName = '';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, key: string, label: string): string {
const value = source[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label}.${key} 缺失或不是字符串`);
}
return value;
}
function optionalNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key];
return typeof value === 'number' ? value : null;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function requireContext(): APIRequestContext {
if (apiContext === null) {
throw new Error('APIRequestContext 未初始化');
}
return apiContext;
}
function requireActor(): string {
if (actorToken === null) {
throw new Error('管理员会话未就绪');
}
return actorToken;
}
function requireQaUser(): QaUser {
if (qaUser === null) {
throw new Error('QA 测试用户未创建');
}
return qaUser;
}
/** 发送 API 请求:body JSON 字符串化,避免把 unknown 直接塞进 Playwright 的 Serializable 类型 */
async function apiCall(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
options: { data?: unknown; token?: string } = {},
): Promise<ApiResult> {
const context = requireContext();
const headers: Record<string, string> = {};
if (options.token !== undefined) {
headers.Authorization = `Bearer ${options.token}`;
}
let body: string | undefined;
if (options.data !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.data);
}
const url = `${API_BASE}${path}`;
let response: APIResponse;
if (method === 'GET') {
response = await context.get(url, { headers });
} else if (method === 'POST') {
response = await context.post(url, { headers, data: body });
} else if (method === 'PATCH') {
response = await context.patch(url, { headers, data: body });
} else {
response = await context.delete(url, { headers });
}
return { status: response.status(), body: await readBody(response) };
}
async function readBody(response: APIResponse): Promise<unknown> {
const text = await response.text();
if (text.length === 0) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function errorInfo(body: unknown): { code: number | null; message: string } {
if (!isRecord(body)) {
return { code: null, message: '' };
}
const message = typeof body.message === 'string' ? body.message : '';
return { code: optionalNumber(body, 'code'), message };
}
interface LoginResult {
status: number;
accessToken: string;
refreshToken: string;
mustChangePassword: boolean;
permissions: string[];
}
function parseLoginBody(body: unknown): LoginResult {
const record = requireRecord(body, '登录响应');
const me = requireRecord(record.me, '登录响应.me');
return {
status: 200,
accessToken: requireString(record, 'accessToken', '登录响应'),
refreshToken: requireString(record, 'refreshToken', '登录响应'),
mustChangePassword: me.mustChangePassword === true,
permissions: stringArray(me.permissions),
};
}
async function loginRaw(account: string, password: string): Promise<LoginResult> {
const result = await apiCall('POST', '/auth/login', { data: { account, password } });
if (result.status !== 200) {
return {
status: result.status,
accessToken: '',
refreshToken: '',
mustChangePassword: false,
permissions: [],
};
}
return parseLoginBody(result.body);
}
async function changePassword(
token: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const result = await apiCall('POST', '/auth/change-password', {
token,
data: { currentPassword, newPassword },
});
expect(result.status, `修改密码(HTTP ${result.status})`).toBe(204);
}
interface MeResult {
status: number;
code: number;
permissions: string[];
roles: string[];
}
async function getMe(token: string): Promise<MeResult> {
const result = await apiCall('GET', '/me', { token });
if (result.status !== 200) {
return {
status: result.status,
code: errorInfo(result.body).code ?? 0,
permissions: [],
roles: [],
};
}
const record = requireRecord(result.body, '/me');
const roles = Array.isArray(record.roles)
? record.roles
.map((item) => (isRecord(item) && typeof item.code === 'string' ? item.code : null))
.filter((code): code is string => code !== null)
: [];
return {
status: 200,
code: 0,
permissions: stringArray(record.permissions),
roles,
};
}
/** 管理员 actor:优先用已改密口令,其次用种子初始口令完成一次性首登改密(与 A1 e2e 同一约定) */
async function ensureActorToken(): Promise<string> {
const attempts: { password: string; label: string }[] = [
{ password: ACTOR_PASSWORD, label: '已改密口令' },
{ password: SEED_PASSWORD, label: '种子初始口令' },
];
for (const attempt of attempts) {
const login = await loginRaw(ACTOR_ACCOUNT, attempt.password);
if (login.status === 429) {
throw new Error(`actor ${ACTOR_ACCOUNT} 登录被限流(42901),请等待锁定窗口或重建 seed`);
}
if (login.status !== 200) {
continue;
}
if (!login.mustChangePassword) {
return login.accessToken;
}
await changePassword(login.accessToken, attempt.password, ACTOR_PASSWORD);
const relogin = await loginRaw(ACTOR_ACCOUNT, ACTOR_PASSWORD);
expect(relogin.status, 'actor 改密后重新登录').toBe(200);
return relogin.accessToken;
}
throw new Error(
`无法用 ${ACTOR_ACCOUNT} 取得管理员会话:本地已改密时请设置 A2_E2E_ACTOR_ACCOUNT / A2_E2E_ACTOR_PASSWORD`,
);
}
/** 角色列表 + 内置角色 id(material_operator 用作清理时给用户解绑后的兜底角色) */
async function fetchRoleIds(token: string): Promise<void> {
const result = await apiCall('GET', '/roles?status=all', { token });
expect(result.status, 'GET /roles?status=all').toBe(200);
const items = requireRecord(result.body, '角色列表').items;
if (!Array.isArray(items)) {
throw new Error('角色列表 items 不是数组');
}
for (const item of items) {
if (!isRecord(item)) {
continue;
}
if (item.code === 'material_operator' && typeof item.id === 'string') {
materialOperatorRoleId = item.id;
}
}
if (materialOperatorRoleId === '') {
throw new Error('内置角色 material_operator 缺失');
}
}
async function fetchDepartmentId(token: string): Promise<void> {
const result = await apiCall('GET', '/departments/tree', { token });
expect(result.status, 'GET /departments/tree').toBe(200);
const items = requireRecord(result.body, '部门树').items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error('部门树为空');
}
const root = items.at(0);
const children = isRecord(root) ? root.children : undefined;
if (Array.isArray(children) && children.length > 0) {
const leaf = children.at(0);
if (isRecord(leaf) && typeof leaf.id === 'string') {
departmentId = leaf.id;
return;
}
}
if (isRecord(root) && typeof root.id === 'string') {
departmentId = root.id;
return;
}
throw new Error('无法从部门树确定部门 ID');
}
function randomSuffix(): string {
return Math.random().toString(36).slice(2, 8);
}
function randomPhone(): string {
const tail = String(Math.floor(10_000_000 + Math.random() * 89_999_999));
return `137${tail}`;
}
async function createQaUser(): Promise<QaUser> {
const account = `${QA_ACCOUNT_PREFIX}${randomSuffix()}`;
const result = await apiCall('POST', '/users', {
token: requireActor(),
data: {
name: 'A2 角色 e2e 用户',
phone: randomPhone(),
account,
password: QA_INITIAL_PASSWORD,
departmentId,
roleIds: [materialOperatorRoleId],
},
});
expect(result.status, `创建 QA 用户 ${account}(HTTP ${result.status})`).toBe(201);
const id = requireString(requireRecord(result.body, '创建用户响应'), 'id', '创建用户响应');
return { id, account };
}
/** 首登强制改密一次性完成,后续用例可使用固定测试口令 */
async function preparePassword(user: QaUser): Promise<void> {
const first = await loginRaw(user.account, QA_INITIAL_PASSWORD);
expect(first.status, `首登 ${user.account}`).toBe(200);
await changePassword(first.accessToken, QA_INITIAL_PASSWORD, QA_PASSWORD);
}
async function updateUserRoles(userId: string, roleIds: string[]): Promise<void> {
const result = await apiCall('PATCH', `/users/${encodeURIComponent(userId)}`, {
token: requireActor(),
data: { roleIds },
});
expect(result.status, `更新用户角色(HTTP ${result.status})`).toBe(200);
}
async function disableUser(userId: string): Promise<void> {
await apiCall('POST', `/users/${encodeURIComponent(userId)}/disable`, {
token: requireActor(),
data: { reason: 'A2 角色 e2e 自动清理' },
});
}
async function deleteRole(roleId: string): Promise<void> {
await apiCall('DELETE', `/roles/${encodeURIComponent(roleId)}`, { token: requireActor() });
}
/** 清理上一次异常中断遗留的 e2e 用户与角色(前缀隔离,尽力而为) */
async function cleanupLeftovers(): Promise<void> {
const token = requireActor();
const users = await apiCall('GET', '/users?status=all&limit=100&keyword=qa2.roles.', { token });
if (users.status === 200) {
const items = requireRecord(users.body, '遗留用户').items;
if (Array.isArray(items)) {
for (const item of items) {
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.account !== 'string') {
continue;
}
if (!item.account.startsWith(QA_ACCOUNT_PREFIX)) {
continue;
}
try {
await updateUserRoles(item.id, [materialOperatorRoleId]);
await disableUser(item.id);
} catch {
// 尽力而为:遗留数据清理失败不阻断用例,真实断言仍会暴露问题。
}
}
}
}
const roles = await apiCall(
'GET',
`/roles?status=all&limit=100&keyword=${encodeURIComponent(ROLE_PREFIX)}`,
{
token,
},
);
if (roles.status === 200) {
const items = requireRecord(roles.body, '遗留角色').items;
if (Array.isArray(items)) {
for (const item of items) {
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.name !== 'string') {
continue;
}
if (!item.name.startsWith(ROLE_PREFIX)) {
continue;
}
try {
await deleteRole(item.id);
} catch {
// 同上。
}
}
}
}
}
// ---------------------------------------------------------------------------
// 浏览器辅助
// ---------------------------------------------------------------------------
async function screenshot(page: Page, name: string): Promise<void> {
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${name}.png`), fullPage: false });
}
async function fillLogin(page: Page, account: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByPlaceholder('请输入账号或手机号').fill(account);
await page.getByPlaceholder('请输入密码').fill(password);
}
async function submitLogin(page: Page): Promise<void> {
await page.getByTestId('login-submit').click();
}
async function loginAsAdmin(page: Page): Promise<void> {
await fillLogin(page, ACTOR_ACCOUNT, ACTOR_PASSWORD);
await submitLogin(page);
await expect(page).toHaveURL(/\/system\/users/, { timeout: 15_000 });
}
/** 权限点行(编辑器 / 新建页共用:div[role=checkbox][data-code]) */
function permissionRow(page: Page, code: string): Locator {
return page.locator(`[data-code="${code}"]`);
}
// ---------------------------------------------------------------------------
// 测试数据编排
// ---------------------------------------------------------------------------
test.beforeAll(async () => {
if (!ENABLED) {
return;
}
await mkdir(SCREENSHOT_DIR, { recursive: true });
apiContext = await request.newContext();
try {
const web = await apiContext.get(`${WEB_BASE}/login`);
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
actorToken = await ensureActorToken();
const me = await getMe(actorToken);
expect(me.status, 'actor GET /me').toBe(200);
await fetchRoleIds(actorToken);
await fetchDepartmentId(actorToken);
await cleanupLeftovers();
qaUser = await createQaUser();
await preparePassword(qaUser);
} catch (error) {
bootstrapError = error instanceof Error ? error.message : String(error);
}
});
test.afterAll(async () => {
if (!ENABLED) {
return;
}
try {
if (qaUser !== null) {
// 先解绑(角色删除前必须 0 绑定用户),再停用用户
try {
await updateUserRoles(qaUser.id, [materialOperatorRoleId]);
} catch {
// 用户可能已不存在 / 已被停用,继续尝试停用与删除角色。
}
try {
await disableUser(qaUser.id);
} catch {
// 已停用时重复停用会返回 40903。
}
}
if (createdRoleId !== '') {
await deleteRole(createdRoleId);
}
} catch {
// 清理失败不覆盖真实断言结果;残留数据由下一次运行 beforeAll 按前缀清理。
}
await apiContext?.dispose();
apiContext = null;
});
test.beforeEach(() => {
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
});
// ---------------------------------------------------------------------------
// ①–⑥ 角色管理主流程(单条旅程,保证 UI 状态与 API 断言同源)
// ---------------------------------------------------------------------------
test('角色管理:新建 → 编辑器 → SoD 拦截 → 特殊权限二次确认 → 保存即时生效 → 停用与列表状态', async ({
page,
browser,
}) => {
test.setTimeout(180_000);
createdRoleName = `${ROLE_PREFIX}内容运营${randomSuffix()}`;
// ① 管理员登录 → /system/roles
await loginAsAdmin(page);
await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
await screenshot(page, '01-roles-list');
// ① 新建角色:填名称 / 说明、勾 2 个权限、选「本部门」
await page.getByRole('button', { name: '新建角色' }).first().click();
await expect(page).toHaveURL(/\/system\/roles\/new/);
await expect(page.getByRole('heading', { name: '新建角色' })).toBeVisible();
await page.getByPlaceholder('例如:审核员').fill(createdRoleName);
await page
.getByPlaceholder('一句话说明该角色的职责')
.fill('A2 e2e 自动创建,用于验证角色管理与权限即时生效');
await expect(permissionRow(page, 'script:read')).toBeVisible({ timeout: 15_000 });
await permissionRow(page, 'script:read').click();
await permissionRow(page, 'material:read').click();
// 数据范围:本部门(六类单选卡片,role=radio)
await page.getByRole('radio', { name: /仅用户所属部门/ }).click();
await expect(page.getByText('当前范围:本部门')).toBeVisible();
await screenshot(page, '02-role-new-filled');
await page.getByRole('button', { name: '创建角色' }).click();
await expect(page).toHaveURL(/\/system\/roles\/(?!new)[0-9A-Za-z]{26}/, { timeout: 15_000 });
const matched = /\/system\/roles\/(?<id>[0-9A-Za-z]{26})/.exec(page.url());
createdRoleId = matched?.groups?.id ?? '';
expect(createdRoleId, '创建成功后应进入角色编辑器并拿到角色 id').not.toBe('');
await expect(page.locator('.lx-role-editor')).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: '停用', exact: true })).toBeVisible();
await screenshot(page, '03-role-editor-created');
// 绑定 QA 用户并登录(为「权限即时生效」准备同一 access token 的观察者)
const user = requireQaUser();
await updateUserRoles(user.id, [createdRoleId]);
const userLogin = await loginRaw(user.account, QA_PASSWORD);
expect(userLogin.status, 'QA 用户登录').toBe(200);
const tokenBefore = userLogin.accessToken;
const meBefore = await getMe(tokenBefore);
expect(meBefore.permissions).toContain('script:read');
expect(meBefore.permissions).toContain('material:read');
expect(meBefore.permissions).not.toContain('user:view');
// ② / ③ 编辑器权限点:先制造 SoD 冲突(script:write × script:review)
await page.getByRole('tab', { name: /^权限点/ }).click();
await expect(permissionRow(page, 'script:write')).toBeVisible({ timeout: 15_000 });
await permissionRow(page, 'script:write').click();
await permissionRow(page, 'script:review').click();
await expect(page.getByText(/命中职责互斥:脚本生成 × 脚本审核/)).toBeVisible();
await expect(page.getByText('存在职责互斥,保存不可用')).toBeVisible();
const footerSave = page
.locator('.lx-role-editor__foot-actions')
.getByRole('button', { name: '保存', exact: true });
await expect(footerSave).toBeDisabled();
await screenshot(page, '04-sod-conflict-blocked');
// 解除冲突,改为给角色追加 user:view(普通权限)
await permissionRow(page, 'script:review').click();
await permissionRow(page, 'script:write').click();
await expect(page.getByText(/命中职责互斥/)).toHaveCount(0);
// 回到基线后编辑器「未做修改」、保存禁用;再加 user:view 制造新的可保存变更
await expect(footerSave).toBeDisabled();
const treeSearch = page.getByPlaceholder('搜索权限点名称或编码');
await treeSearch.fill('user:view');
await expect(permissionRow(page, 'user:view')).toBeVisible();
await permissionRow(page, 'user:view').click();
await treeSearch.fill('');
await expect(footerSave).toBeEnabled();
// ② 特殊权限:开关触发第一次二次确认(不落库,仅更新草稿)
// 注:Semi Modal 复用 `semi-modal-title` id 且关闭动画期间两个弹窗会同时挂载,
// getByRole('dialog', { name }) 的 aria-labelledby 会指向先挂载的旧标题,
// 因此这里用「按钮 / 标题文案」定位,避免动画窗口内的可访问名串台。
await page.getByRole('tab', { name: /^特殊权限/ }).click();
await page.getByRole('switch', { name: '发起导出' }).click();
const confirmGrant = page.getByRole('button', { name: '确认授予', exact: true });
await expect(confirmGrant).toHaveCount(1);
await expect(page.getByText('授予特殊权限')).toBeVisible();
await screenshot(page, '05-special-permission-confirm');
await confirmGrant.click();
// 等第一次确认弹窗卸载,避免遮罩残留拦截下一次点击
await expect(confirmGrant).toHaveCount(0);
// 保存:命中特殊权限变更 → 第二次二次确认 → 保存前影响面确认
await footerSave.click();
await expect(confirmGrant).toHaveCount(1);
await confirmGrant.click();
const saveConfirm = page.getByRole('button', { name: '保存并生效', exact: true });
await expect(saveConfirm).toBeVisible();
await expect(page.getByText('保存角色变更')).toBeVisible();
await expect(page.getByText('新增', { exact: true })).toBeVisible();
await screenshot(page, '06-save-confirm');
await saveConfirm.click();
await expect(page.getByText(/角色已保存/).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.lx-role-editor__foot').getByText('未做修改')).toBeVisible();
await screenshot(page, '07-saved');
// ④ API 断言即时生效:同一 access token 的 /me 在下一次请求即包含新权限与特殊权限
const meAfter = await getMe(tokenBefore);
expect(meAfter.status).toBe(200);
expect(meAfter.permissions).toContain('user:view');
expect(meAfter.permissions).toContain('delivery:export');
expect(meAfter.permissions).not.toContain('script:write');
expect(meAfter.permissions).not.toContain('script:review');
const usersAfter = await apiCall('GET', '/users', { token: tokenBefore });
expect(usersAfter.status, '授予 user:view 后绑定用户下一次请求即可访问 /users').toBe(200);
const roleDetail = await apiCall('GET', `/roles/${createdRoleId}`, {
token: requireActor(),
});
expect(roleDetail.status).toBe(200);
const roleRecord = requireRecord(roleDetail.body, '角色详情');
expect(stringArray(roleRecord.permissions).sort()).toEqual([
'material:read',
'script:read',
'user:view',
]);
expect(stringArray(roleRecord.specialPermissions)).toEqual(['delivery:export']);
expect(roleRecord.userCount).toBe(1);
// ⑤ 权限三态 UI 侧:绑定用户有 user:view 但没有 role:view → /system/roles 显示 403 页
const scopedContext = await browser.newContext({
baseURL: WEB_BASE,
viewport: { width: 1440, height: 900 },
});
try {
const scopedPage = await scopedContext.newPage();
await fillLogin(scopedPage, user.account, QA_PASSWORD);
await submitLogin(scopedPage);
await expect(scopedPage).toHaveURL(/\/system\/users/, { timeout: 15_000 });
await scopedPage.goto('/system/roles');
await expect(scopedPage).toHaveURL(/\/403/, { timeout: 15_000 });
await expect(scopedPage.getByTestId('error-403')).toBeVisible();
await screenshot(scopedPage, '08-roles-forbidden');
} finally {
await scopedContext.close();
}
// ⑥ 停用确认:勾选知晓 + 填原因 → 停用
await page.getByRole('button', { name: '停用', exact: true }).click();
const disableDialog = page.getByRole('dialog', { name: '停用角色' });
await expect(disableDialog).toBeVisible();
await screenshot(page, '09-disable-confirm');
await disableDialog.getByPlaceholder('例如:岗位调整、角色合并').fill('A2 e2e 验证停用即时生效');
await disableDialog.getByText('我已知晓停用后这些用户下一次请求即失去该角色权限').click();
await disableDialog.getByRole('button', { name: '停用角色', exact: true }).click();
await expect(page.getByText(/已停用,1 名用户下一次请求生效/).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('.lx-status-tag.is-disabled').first()).toBeVisible();
await screenshot(page, '10-role-disabled-editor');
// ⑥ 停用后 API:绑定用户下一次请求失去权限(40301),/me 权限清空
const meDisabled = await getMe(tokenBefore);
expect(meDisabled.status).toBe(200);
expect(meDisabled.permissions).toEqual([]);
const usersDenied = await apiCall('GET', '/users', { token: tokenBefore });
expect(usersDenied.status, '停用角色后绑定用户访问 /users').toBe(403);
expect(errorInfo(usersDenied.body).code).toBe(40301);
// ⑥ 列表状态变化:停用筛选下可见该角色,且行内标记「停用」
await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible();
const disabledSeg = page
.getByRole('group', { name: '角色状态' })
.getByRole('button', { name: /^停用/ });
await disabledSeg.click();
const roleRow = page.locator('.lx-role-row', { hasText: createdRoleName });
await expect(roleRow).toBeVisible();
await expect(roleRow).toContainText('停用');
await screenshot(page, '11-roles-list-disabled');
});
+101 -2
View File
@@ -77,7 +77,7 @@
"bearer": [] "bearer": []
} }
], ],
"summary": "当前用户、角色、权限点、数据范围", "summary": "当前用户、角色、权限点、数据范围(多角色并集)",
"tags": [ "tags": [
"identity-me" "identity-me"
] ]
@@ -287,6 +287,7 @@
}, },
"/api/v1/roles": { "/api/v1/roles": {
"get": { "get": {
"description": "默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围",
"operationId": "RolesController_list", "operationId": "RolesController_list",
"parameters": [], "parameters": [],
"responses": { "responses": {
@@ -299,10 +300,108 @@
"bearer": [] "bearer": []
} }
], ],
"summary": "角色列表(绑定用)", "summary": "角色列表(游标分页 + 筛选 + 排序)",
"tags": [ "tags": [
"identity-roles" "identity-roles"
] ]
},
"post": {
"description": "权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予",
"operationId": "RolesController_create",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "新建角色",
"tags": [
"identity-roles"
]
}
},
"/api/v1/roles/{id}": {
"get": {
"operationId": "RolesController_detail",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "角色详情(权限点、特殊权限、数据范围、绑定用户)",
"tags": [
"identity-roles"
]
},
"patch": {
"description": "系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示",
"operationId": "RolesController_update",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "更新角色 / 启停",
"tags": [
"identity-roles"
]
},
"delete": {
"description": "系统角色拒绝;有绑定用户(40903)只能停用",
"operationId": "RolesController_remove",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "删除角色",
"tags": [
"identity-roles"
]
}
},
"/api/v1/permissions": {
"get": {
"description": "模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予",
"operationId": "PermissionsController_getTree",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "权限点目录(10 模块树 + 特殊权限 + SoD 对)",
"tags": [
"identity-permissions"
]
} }
}, },
"/health": { "/health": {
+1
View File
@@ -6,5 +6,6 @@ export * from './permissions.js';
export * from './schemas/api.js'; export * from './schemas/api.js';
export * from './schemas/common.js'; export * from './schemas/common.js';
export * from './schemas/identity.js'; export * from './schemas/identity.js';
export * from './schemas/roles.js';
export * from './time.js'; export * from './time.js';
export * from './ulid.js'; export * from './ulid.js';
+188 -5
View File
@@ -1,14 +1,30 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
findSodConflicts,
isPermissionCode, isPermissionCode,
isSpecialPermission,
parsePermission, parsePermission,
Permission, Permission,
PERMISSION_CATALOG,
PERMISSION_CODES, PERMISSION_CODES,
PERMISSION_MODULES,
permissionMetaOf,
permissionsByModule,
ROLE_SOFT_LIMIT,
SOD_PAIRS,
SPECIAL_PERMISSIONS, SPECIAL_PERMISSIONS,
splitPermissions,
SUPER_ADMIN_SOFT_LIMIT,
} from './permissions.js'; } from './permissions.js';
describe('权限点常量', () => { describe('权限点目录', () => {
it('H1 口径:共 30 个权限点,编码全局唯一', () => {
expect(PERMISSION_CODES).toHaveLength(30);
expect(PERMISSION_CATALOG).toHaveLength(30);
expect(new Set(PERMISSION_CODES).size).toBe(30);
});
it('全部符合 模块:动作 命名且模块为小写', () => { it('全部符合 模块:动作 命名且模块为小写', () => {
for (const code of PERMISSION_CODES) { for (const code of PERMISSION_CODES) {
expect(code).toMatch(/^[a-z][a-z_]*:[a-z][a-z_]*$/); expect(code).toMatch(/^[a-z][a-z_]*:[a-z][a-z_]*$/);
@@ -16,15 +32,175 @@ describe('权限点常量', () => {
} }
}); });
it('特殊权限必须是已登记的权限点', () => { it('保留既有 read / write 编码,并补齐 H1 新增的 5 个权限点', () => {
for (const code of [
'script:read',
'script:write',
'material:read',
'material:write',
'asset:read',
'asset:write',
'delivery:read',
'customer:read',
]) {
expect(PERMISSION_CODES).toContain(code);
}
expect(Permission.ROLE_VIEW).toBe('role:view');
expect(Permission.CUSTOMER_FIELD_APPLY).toBe('customer_field:apply');
expect(Permission.CUSTOMER_TRANSFER).toBe('customer:transfer');
expect(Permission.CUSTOMER_GROUP_MANAGE).toBe('customer_group:manage');
expect(Permission.ASSET_DELETE).toBe('asset:delete');
});
it('权限树为 10 个模块,顺序与名称对齐 design/a2-ui-v1/brief.md', () => {
const groups = permissionsByModule();
expect(groups).toHaveLength(10);
expect(PERMISSION_MODULES).toHaveLength(10);
expect(groups.map((group) => group.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(groups.map((group) => group.moduleLabel)).toEqual([
'脚本',
'素材',
'资产',
'导出交付',
'客户',
'客户组',
'组织与角色',
'行业包',
'平台配置',
'审计',
]);
expect(groups.map((group) => group.sortOrder)).toEqual([
10, 20, 30, 40, 50, 60, 70, 80, 90, 100,
]);
expect(groups.flatMap((group) => group.permissions)).toHaveLength(30);
});
it('权限点显示名与说明与 UI 原型一致(抽样关键项)', () => {
const nameOf = (code: string): { name: string; description: string } => {
const meta = permissionMetaOf(code);
if (meta === undefined) {
throw new Error(`权限点未登记:${code}`);
}
return { name: meta.name, description: meta.description };
};
expect(nameOf('script:read')).toEqual({ name: '查看脚本', description: '脚本库、模板与版本' });
expect(nameOf('delivery:export')).toEqual({
name: '发起导出',
description: '数据离开平台的唯一出口(特殊)',
});
expect(nameOf('user:manage')).toEqual({ name: '管理用户', description: '新建、停用、转交' });
expect(nameOf('customer_group:manage')).toEqual({
name: '管理客户组',
description: '客户组增改与成员',
});
expect(nameOf('audit:export')).toEqual({
name: '导出审计',
description: '审计导出并留痕(特殊)',
});
});
it('目录元数据完整(编码 / 模块 / 名称 / 说明 / 特殊标记 / 排序)', () => {
for (const item of PERMISSION_CATALOG) {
expect(permissionMetaOf(item.code)).toEqual(item);
expect(item.name.length).toBeGreaterThan(0);
expect(item.description.length).toBeGreaterThan(0);
expect(item.moduleLabel.length).toBeGreaterThan(0);
expect(item.isSpecial).toBe(isSpecialPermission(item.code));
}
const sortOrders = PERMISSION_CATALOG.map((item) => item.sortOrder);
expect(sortOrders).toEqual([...sortOrders].sort((a, b) => a - b));
});
it('特殊权限为 6 个编码(5 类),均已在目录登记', () => {
expect(SPECIAL_PERMISSIONS).toHaveLength(6);
expect([...SPECIAL_PERMISSIONS].sort()).toEqual(
[
'delivery:export',
'customer_field:approve',
'industry:publish',
'ai:configure',
'audit:read',
'audit:export',
].sort(),
);
for (const code of SPECIAL_PERMISSIONS) { for (const code of SPECIAL_PERMISSIONS) {
expect(PERMISSION_CODES).toContain(code); expect(PERMISSION_CODES).toContain(code);
expect(isSpecialPermission(code)).toBe(true);
}
expect(isSpecialPermission('script:write')).toBe(false);
});
it('软上限取自 PRD 建议值', () => {
expect(ROLE_SOFT_LIMIT).toBe(20);
expect(SUPER_ADMIN_SOFT_LIMIT).toBe(2);
});
});
describe('SoD 三对', () => {
it('互斥对为三对,且两侧都是已登记权限点', () => {
expect(SOD_PAIRS).toHaveLength(3);
expect(SOD_PAIRS.map((pair) => pair.id)).toEqual([
'script-write-review',
'industry-manage-publish',
'customer-field-apply-approve',
]);
for (const pair of SOD_PAIRS) {
for (const code of pair.permissions) {
expect(PERMISSION_CODES).toContain(code);
}
} }
}); });
it('isPermissionCode 只认可登记值', () => { it('同时拥有互斥两侧即命中;只拥有单侧不命中', () => {
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true); expect(findSodConflicts(['script:write', 'script:review'])).toEqual([
expect(isPermissionCode('script:delete_all')).toBe(false); {
pairId: 'script-write-review',
label: '脚本生成 × 脚本审核',
permissions: ['script:write', 'script:review'],
},
]);
expect(findSodConflicts(['industry:manage', 'industry:publish']).map((c) => c.pairId)).toEqual([
'industry-manage-publish',
]);
expect(
findSodConflicts(['customer_field:apply', 'customer_field:approve']).map((c) => c.pairId),
).toEqual(['customer-field-apply-approve']);
expect(findSodConflicts(['script:write', 'script:read'])).toEqual([]);
expect(findSodConflicts([])).toEqual([]);
});
});
describe('splitPermissions', () => {
it('拆分普通权限与特殊权限并去重排序', () => {
expect(
splitPermissions([
'script:write',
'delivery:export',
'script:read',
'delivery:export',
'audit:read',
]),
).toEqual({
permissions: ['script:read', 'script:write'],
specialPermissions: ['audit:read', 'delivery:export'],
});
});
it('未登记编码抛错(调用方映射 40001)', () => {
expect(() => splitPermissions(['script:delete_all'])).toThrow(
'INVALID_PERMISSION_CODE:script:delete_all',
);
}); });
}); });
@@ -40,3 +216,10 @@ describe('parsePermission', () => {
expect(parsePermission('a:b:c')).toBeNull(); expect(parsePermission('a:b:c')).toBeNull();
}); });
}); });
describe('isPermissionCode', () => {
it('只认可登记值', () => {
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true);
expect(isPermissionCode('script:delete_all')).toBe(false);
});
});
+399 -12
View File
@@ -1,9 +1,41 @@
/** /**
* 权限点常量骨架(A0-SH-01) * 权限点目录(A0-SH-01 骨架 → A2 定稿,口径见 H1 权限门)
* 命名规范:<模块>:<动作>,模块取后端八模块语义(identity / client / catalog / industry / *
* script / media / delivery / platform),全小写;动作使用动词或名词短语。 * 命名规范:<模块>:<动作>,全小写;权限树一级模块(script / material / asset / delivery /
* A1/A2 接入真实角色-权限表后,这里只增不改;删除/改名需评审(AGENTS §10)。 * customer / customer_group / identity / industry / platform / audit)与 UI 原型一致,
* 动作使用动词或名词短语。
*
* 约束(AGENTS §10 / 开发计划 §14 附录 B):
* - 权限点总共 30 个:保留 A0/A1 已有的 read / write 编码,只补齐 H1 确认的 5 个新编码;
* - 新增 / 删除 / 改名权限点必须评审,`code` 一经发布不得变更(角色数据按 code 关联);
* - 特殊权限 6 个编码(5 类):默认关闭、单独授予、不随角色复制带入;
* - 本文件是唯一事实源:后端启动时同步 `permissions` 表,前端权限树由 `/permissions` 生成;
* 本文件不依赖任何其他模块,保证 `@lingxi/shared` 零业务依赖。
*/ */
// ---------------------------------------------------------------------------
// 模块(权限树一级分组,共 10 个;与 design/a2-ui-v1/brief.md 对齐,顺序固定)
// ---------------------------------------------------------------------------
export const PERMISSION_MODULES = [
{ module: 'script', moduleLabel: '脚本', sortOrder: 10 },
{ module: 'material', moduleLabel: '素材', sortOrder: 20 },
{ module: 'asset', moduleLabel: '资产', sortOrder: 30 },
{ module: 'delivery', moduleLabel: '导出交付', sortOrder: 40 },
{ module: 'customer', moduleLabel: '客户', sortOrder: 50 },
{ module: 'customer_group', moduleLabel: '客户组', sortOrder: 60 },
{ module: 'identity', moduleLabel: '组织与角色', sortOrder: 70 },
{ module: 'industry', moduleLabel: '行业包', sortOrder: 80 },
{ module: 'platform', moduleLabel: '平台配置', sortOrder: 90 },
{ module: 'audit', moduleLabel: '审计', sortOrder: 100 },
] as const;
export type PermissionModule = (typeof PERMISSION_MODULES)[number]['module'];
// ---------------------------------------------------------------------------
// 权限点编码(30 个)
// ---------------------------------------------------------------------------
export const Permission = { export const Permission = {
// 脚本 // 脚本
SCRIPT_READ: 'script:read', SCRIPT_READ: 'script:read',
@@ -17,20 +49,27 @@ export const Permission = {
// 资产 // 资产
ASSET_READ: 'asset:read', ASSET_READ: 'asset:read',
ASSET_WRITE: 'asset:write', ASSET_WRITE: 'asset:write',
// 导出交付 ASSET_DELETE: 'asset:delete',
// 交付导出
DELIVERY_READ: 'delivery:read', DELIVERY_READ: 'delivery:read',
DELIVERY_EXPORT: 'delivery:export', DELIVERY_EXPORT: 'delivery:export',
// 客户 // 客户
CUSTOMER_READ: 'customer:read', CUSTOMER_READ: 'customer:read',
CUSTOMER_MANAGE: 'customer:manage', CUSTOMER_MANAGE: 'customer:manage',
CUSTOMER_TRANSFER: 'customer:transfer',
CUSTOMER_FIELD_APPLY: 'customer_field:apply',
CUSTOMER_FIELD_APPROVE: 'customer_field:approve', CUSTOMER_FIELD_APPROVE: 'customer_field:approve',
// 客户组
CUSTOMER_GROUP_MANAGE: 'customer_group:manage',
// 组织与角色 // 组织与角色
USER_VIEW: 'user:view', USER_VIEW: 'user:view',
USER_MANAGE: 'user:manage', USER_MANAGE: 'user:manage',
ROLE_VIEW: 'role:view',
ROLE_MANAGE: 'role:manage', ROLE_MANAGE: 'role:manage',
// 平台配置 // 行业包
INDUSTRY_MANAGE: 'industry:manage', INDUSTRY_MANAGE: 'industry:manage',
INDUSTRY_PUBLISH: 'industry:publish', INDUSTRY_PUBLISH: 'industry:publish',
// 平台配置
DICT_MANAGE: 'dict:manage', DICT_MANAGE: 'dict:manage',
FORM_DESIGN: 'form:design', FORM_DESIGN: 'form:design',
AI_CONFIGURE: 'ai:configure', AI_CONFIGURE: 'ai:configure',
@@ -42,23 +81,342 @@ export const Permission = {
export type PermissionCode = (typeof Permission)[keyof typeof Permission]; export type PermissionCode = (typeof Permission)[keyof typeof Permission];
export const PERMISSION_CODES: readonly PermissionCode[] = Object.values(Permission); // ---------------------------------------------------------------------------
// 特殊权限(6 编码 / 5 类,默认关闭、单独授予、不随角色复制带入)
// ---------------------------------------------------------------------------
/** 需要额外单独校验的特殊权限(技术架构 §6.2「关键特殊权限」) */ /** 需要 SpecialPermissionGuard 单独校验的特殊权限(架构 §6.2 / PRD §5.4.3 五) */
export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [ export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [
Permission.DELIVERY_EXPORT, Permission.DELIVERY_EXPORT,
Permission.AI_CONFIGURE,
Permission.AUDIT_EXPORT,
Permission.INDUSTRY_PUBLISH,
Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_FIELD_APPROVE,
Permission.INDUSTRY_PUBLISH,
Permission.AI_CONFIGURE,
Permission.AUDIT_READ,
Permission.AUDIT_EXPORT,
]; ];
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set(PERMISSION_CODES); const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
export function isSpecialPermission(code: string): boolean {
return SPECIAL_PERMISSION_SET.has(code);
}
// ---------------------------------------------------------------------------
// 职责互斥三对(SoD;超管豁免,命中返回 40304)
// ---------------------------------------------------------------------------
export interface SodPair {
id: string;
label: string;
description: string;
permissions: readonly [PermissionCode, PermissionCode];
}
export interface SodConflict {
pairId: string;
label: string;
permissions: readonly [PermissionCode, PermissionCode];
}
export const SOD_PAIRS: readonly SodPair[] = [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '自己写自己审会让审核形同虚设,同一角色不得同时拥有「脚本生成」与「脚本审核」。',
permissions: [Permission.SCRIPT_WRITE, Permission.SCRIPT_REVIEW],
},
{
id: 'industry-manage-publish',
label: '行业包编辑 × 行业包发布',
description: '行业包改动影响所有客户,编辑与发布必须由两个角色分别承担。',
permissions: [Permission.INDUSTRY_MANAGE, Permission.INDUSTRY_PUBLISH],
},
{
id: 'customer-field-apply-approve',
label: '客户字段申请 × 客户字段审批',
description: '字段申请与审批不得由同一角色完成,避免绕过审批直接进入生成。',
permissions: [Permission.CUSTOMER_FIELD_APPLY, Permission.CUSTOMER_FIELD_APPROVE],
},
];
/** 命中任意一对 SoD 即返回该对明细(role 保存前校验,超管豁免) */
export function findSodConflicts(codes: readonly string[]): SodConflict[] {
const codeSet = new Set(codes);
return SOD_PAIRS.filter(
(pair) => codeSet.has(pair.permissions[0]) && codeSet.has(pair.permissions[1]),
).map((pair) => ({
pairId: pair.id,
label: pair.label,
permissions: pair.permissions,
}));
}
// ---------------------------------------------------------------------------
// 软上限(建议值,仅提示不阻断;A4 迁 system_configs)
// ---------------------------------------------------------------------------
/** 角色数量建议上限(PRD §5.4.3 数量建议:角色 ≤ 20) */
export const ROLE_SOFT_LIMIT = 20;
/** 超级管理员人数建议上限(PRD §5.4.1:建议 ≤ 2 人) */
export const SUPER_ADMIN_SOFT_LIMIT = 2;
// ---------------------------------------------------------------------------
// 权限点目录元数据
// ---------------------------------------------------------------------------
export interface PermissionMeta {
code: PermissionCode;
module: PermissionModule;
moduleLabel: string;
name: string;
description: string;
isSpecial: boolean;
sortOrder: number;
}
interface PermissionDefinition {
code: PermissionCode;
module: PermissionModule;
name: string;
description: string;
}
/** 目录定义(按模块 + 操作顺序),`PERMISSION_CATALOG` 由此生成 */
const PERMISSION_DEFINITIONS: readonly PermissionDefinition[] = [
// 脚本
{
code: Permission.SCRIPT_READ,
module: 'script',
name: '查看脚本',
description: '脚本库、模板与版本',
},
{
code: Permission.SCRIPT_WRITE,
module: 'script',
name: '编辑与生成脚本',
description: '编辑脚本、发起生成',
},
{
code: Permission.SCRIPT_REVIEW,
module: 'script',
name: '审核脚本',
description: '脚本入库审核',
},
// 素材
{
code: Permission.MATERIAL_READ,
module: 'material',
name: '查看素材任务',
description: '素材与任务详情',
},
{
code: Permission.MATERIAL_WRITE,
module: 'material',
name: '编辑素材任务',
description: '参数与素材信息',
},
{
code: Permission.MATERIAL_RENDER,
module: 'material',
name: '发起生成与渲染',
description: '图片 / 视频生成',
},
{
code: Permission.MATERIAL_REVIEW,
module: 'material',
name: '审核素材',
description: '素材入库审核',
},
// 资产
{ code: Permission.ASSET_READ, module: 'asset', name: '查看资产', description: '资产库与版本' },
{
code: Permission.ASSET_WRITE,
module: 'asset',
name: '上传与编辑资产',
description: '上传、编辑、启停',
},
{
code: Permission.ASSET_DELETE,
module: 'asset',
name: '删除资产',
description: '受引用保护,仅未被引用可删',
},
// 导出交付
{
code: Permission.DELIVERY_READ,
module: 'delivery',
name: '查看交付记录',
description: '交付清单与导出记录',
},
{
code: Permission.DELIVERY_EXPORT,
module: 'delivery',
name: '发起导出',
description: '数据离开平台的唯一出口(特殊)',
},
// 客户
{
code: Permission.CUSTOMER_READ,
module: 'customer',
name: '查看客户',
description: '客户资料与归属',
},
{
code: Permission.CUSTOMER_MANAGE,
module: 'customer',
name: '管理客户资料',
description: '新建、编辑、停用',
},
{
code: Permission.CUSTOMER_TRANSFER,
module: 'customer',
name: '转交客户',
description: '变更客户负责人',
},
{
code: Permission.CUSTOMER_FIELD_APPLY,
module: 'customer',
name: '申请客户字段',
description: '提交扩展字段申请',
},
{
code: Permission.CUSTOMER_FIELD_APPROVE,
module: 'customer',
name: '审批客户字段',
description: '审批扩展字段(特殊)',
},
// 客户组
{
code: Permission.CUSTOMER_GROUP_MANAGE,
module: 'customer_group',
name: '管理客户组',
description: '客户组增改与成员',
},
// 组织与角色
{ code: Permission.USER_VIEW, module: 'identity', name: '查看用户', description: '用户与部门' },
{
code: Permission.USER_MANAGE,
module: 'identity',
name: '管理用户',
description: '新建、停用、转交',
},
{
code: Permission.ROLE_VIEW,
module: 'identity',
name: '查看角色',
description: '角色、权限点与 SoD',
},
{
code: Permission.ROLE_MANAGE,
module: 'identity',
name: '管理角色与授权',
description: '新建、编辑、复制、停用',
},
// 行业包
{
code: Permission.INDUSTRY_MANAGE,
module: 'industry',
name: '编辑行业包',
description: '草稿、schema、规则集',
},
{
code: Permission.INDUSTRY_PUBLISH,
module: 'industry',
name: '发布行业包',
description: '发布后影响全部客户(特殊)',
},
// 平台配置
{
code: Permission.DICT_MANAGE,
module: 'platform',
name: '管理字典',
description: '字典与系统配置项',
},
{ code: Permission.FORM_DESIGN, module: 'platform', name: '设计表单', description: '表单设计器' },
{
code: Permission.AI_CONFIGURE,
module: 'platform',
name: 'AI 配置',
description: '模型、路由、额度(特殊)',
},
{
code: Permission.SYSTEM_CONFIGURE,
module: 'platform',
name: '系统配置',
description: '平台参数',
},
// 审计
{
code: Permission.AUDIT_READ,
module: 'audit',
name: '查看审计',
description: '审计日志检索(特殊)',
},
{
code: Permission.AUDIT_EXPORT,
module: 'audit',
name: '导出审计',
description: '审计导出并留痕(特殊)',
},
];
export const PERMISSION_CATALOG: readonly PermissionMeta[] = PERMISSION_DEFINITIONS.map((item) => {
const moduleMeta = PERMISSION_MODULES.find((module) => module.module === item.module);
if (moduleMeta === undefined) {
throw new Error(`权限点 ${item.code} 引用了未登记的模块:${item.module}`);
}
const indexInModule = PERMISSION_DEFINITIONS.filter(
(candidate) => candidate.module === item.module,
).findIndex((candidate) => candidate.code === item.code);
return {
code: item.code,
module: item.module,
moduleLabel: moduleMeta.moduleLabel,
name: item.name,
description: item.description,
isSpecial: isSpecialPermission(item.code),
sortOrder: moduleMeta.sortOrder + indexInModule,
};
});
export const PERMISSION_CODES: readonly PermissionCode[] = PERMISSION_CATALOG.map(
(item) => item.code,
);
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set<string>(PERMISSION_CODES);
export function isPermissionCode(value: string): value is PermissionCode { export function isPermissionCode(value: string): value is PermissionCode {
return PERMISSION_CODE_SET.has(value); return PERMISSION_CODE_SET.has(value);
} }
const PERMISSION_META_BY_CODE: ReadonlyMap<string, PermissionMeta> = new Map(
PERMISSION_CATALOG.map((item) => [item.code, item]),
);
export function permissionMetaOf(code: string): PermissionMeta | undefined {
return PERMISSION_META_BY_CODE.get(code);
}
/** 按模块分组(保持 PERMISSION_MODULES 顺序,空模块不返回) */
export function permissionsByModule(): readonly {
module: PermissionModule;
moduleLabel: string;
sortOrder: number;
permissions: readonly PermissionMeta[];
}[] {
return PERMISSION_MODULES.map((moduleMeta) => ({
module: moduleMeta.module,
moduleLabel: moduleMeta.moduleLabel,
sortOrder: moduleMeta.sortOrder,
permissions: PERMISSION_CATALOG.filter((item) => item.module === moduleMeta.module),
})).filter((group) => group.permissions.length > 0);
}
// ---------------------------------------------------------------------------
// 解析与校验
// ---------------------------------------------------------------------------
export interface ParsedPermission { export interface ParsedPermission {
module: string; module: string;
action: string; action: string;
@@ -77,3 +435,32 @@ export function parsePermission(code: string): ParsedPermission | null {
} }
return { module, action }; return { module, action };
} }
/**
* 校验并拆分角色保存入参的权限点:返回普通权限点与特殊权限点。
* 未登记编码抛 `INVALID_PERMISSION_CODE:<code>`(由调用方映射为 40001)。
*/
export function splitPermissions(codes: readonly string[]): {
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
} {
const unique = [...new Set(codes)];
for (const code of unique) {
if (!isPermissionCode(code)) {
throw new Error(`INVALID_PERMISSION_CODE:${code}`);
}
}
const permissions: PermissionCode[] = [];
const specialPermissions: PermissionCode[] = [];
for (const code of unique) {
if (isSpecialPermission(code)) {
specialPermissions.push(code as PermissionCode);
} else {
permissions.push(code as PermissionCode);
}
}
return {
permissions: permissions.sort(),
specialPermissions: specialPermissions.sort(),
};
}
+22 -11
View File
@@ -71,7 +71,7 @@ function makeMe(): Record<string, unknown> {
user: { ...makeSummary() }, user: { ...makeSummary() },
roles: [makeRole()], roles: [makeRole()],
permissions: ['user:view', 'user:manage'], permissions: ['user:view', 'user:manage'],
dataScope: { type: 'department_and_children' }, dataScopes: [{ type: 'department_and_children' }],
mustChangePassword: false, mustChangePassword: false,
}; };
} }
@@ -530,45 +530,56 @@ describe('认证 schema', () => {
}); });
describe('dataScopeSchema / meResponseSchema', () => { describe('dataScopeSchema / meResponseSchema', () => {
it('dataScopeSchema 接受六类范围并允许可选 customerIds', () => { it('dataScopeSchema 接受六类范围,并允许可选 targetIds / customerIds', () => {
for (const type of DATA_SCOPE_TYPES) { for (const type of DATA_SCOPE_TYPES) {
expect(dataScopeSchema.parse({ type }).type).toBe(type); expect(dataScopeSchema.parse({ type }).type).toBe(type);
} }
const withCustomers = dataScopeSchema.parse({ const withCustomers = dataScopeSchema.parse({
type: 'customer_groups', type: 'customer_groups',
targetIds: [ulid()],
customerIds: [ulid()], customerIds: [ulid()],
}); });
expect(withCustomers.targetIds).toHaveLength(1);
expect(withCustomers.customerIds).toHaveLength(1); expect(withCustomers.customerIds).toHaveLength(1);
}); });
it('dataScopeSchema 拒绝未知范围类型与非法客户 ID', () => { it('dataScopeSchema 拒绝未知范围类型与非法客户 ID', () => {
expect(() => dataScopeSchema.parse({ type: 'own' })).toThrow('数据范围类型不合法'); expect(() => dataScopeSchema.parse({ type: 'own' })).toThrow('数据范围类型不合法');
expect(() => dataScopeSchema.parse({ type: 'all', customerIds: ['bad'] })).toThrow(); expect(() => dataScopeSchema.parse({ type: 'all', customerIds: ['bad'] })).toThrow();
expect(() => dataScopeSchema.parse({ type: 'customer_groups', targetIds: ['bad'] })).toThrow();
}); });
it('meResponseSchema 接受完整上下文(user 含手机号全号)', () => { it('meResponseSchema 接受完整上下文(user 含手机号全号、dataScopes 多范围)', () => {
const me = makeMe(); const me = makeMe();
me.user = { ...makeSummary(), phone: '13800138000' }; me.user = { ...makeSummary(), phone: '13800138000' };
me.dataScopes = [
{ type: 'owned_customers', customerIds: [] },
{ type: 'department', customerIds: [] },
];
const parsed = meResponseSchema.parse(me); const parsed = meResponseSchema.parse(me);
expect(parsed.user.phone).toBe('13800138000'); expect(parsed.user.phone).toBe('13800138000');
expect(parsed.permissions).toEqual(['user:view', 'user:manage']); expect(parsed.permissions).toEqual(['user:view', 'user:manage']);
expect(parsed.dataScope.type).toBe('department_and_children'); expect(parsed.dataScopes).toHaveLength(2);
expect(parsed.dataScopes[0]?.type).toBe('owned_customers');
expect(parsed.dataScopes[0]?.customerIds).toEqual([]);
expect(parsed.mustChangePassword).toBe(false); expect(parsed.mustChangePassword).toBe(false);
}); });
it('meResponseSchema 允许 user 不含手机号、dataScope 不含 customerIds', () => { it('meResponseSchema 超管口径:dataScopes 为 [{ type: all }]', () => {
const parsed = meResponseSchema.parse(makeMe()); const parsed = meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'all' }] });
expect(parsed.user.phone).toBeUndefined(); expect(parsed.dataScopes).toEqual([{ type: 'all' }]);
expect(parsed.dataScope.customerIds).toBeUndefined();
}); });
it('meResponseSchema 拒绝缺失 mustChangePassword / 非法权限点 / 非法数据范围', () => { it('meResponseSchema 拒绝缺失 dataScopes / 空数组 / 非法权限点 / 非法数据范围', () => {
const { mustChangePassword: _must, ...withoutFlag } = makeMe(); const { mustChangePassword: _must, ...withoutFlag } = makeMe();
expect(() => meResponseSchema.parse(withoutFlag)).toThrow(); expect(() => meResponseSchema.parse(withoutFlag)).toThrow();
const { dataScopes: _scopes, ...withoutScopes } = makeMe();
expect(() => meResponseSchema.parse(withoutScopes)).toThrow();
expect(() => meResponseSchema.parse({ ...makeMe(), dataScopes: [] })).toThrow();
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [''] })).toThrow(); expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [''] })).toThrow();
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [1] })).toThrow(); expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [1] })).toThrow();
expect(() => expect(() =>
meResponseSchema.parse({ ...makeMe(), dataScope: { type: 'everything' } }), meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'everything' }] }),
).toThrow(); ).toThrow();
}); });
}); });
@@ -589,7 +600,7 @@ describe('loginResponseSchema', () => {
expect(() => expect(() =>
loginResponseSchema.parse({ loginResponseSchema.parse({
...makeLoginResponse(), ...makeLoginResponse(),
me: { ...makeMe(), dataScope: { type: 'unknown' } }, me: { ...makeMe(), dataScopes: [{ type: 'unknown' }] },
}), }),
).toThrow(); ).toThrow();
expect(() => loginResponseSchema.parse({ ...makeLoginResponse(), expiresIn: 0 })).toThrow(); expect(() => loginResponseSchema.parse({ ...makeLoginResponse(), expiresIn: 0 })).toThrow();
+14 -3
View File
@@ -307,22 +307,33 @@ export const dataScopeTypeSchema = z.enum(DATA_SCOPE_TYPES, {
export type DataScopeType = z.infer<typeof dataScopeTypeSchema>; export type DataScopeType = z.infer<typeof dataScopeTypeSchema>;
/** 数据范围解析结果:customerIds 仅「指定客户组 / 负责客户」等范围需要返回 */ /**
* 数据范围(A2-BE-04):
* - `type`:六类范围之一;
* - `targetIds`:角色配置时指定的授权对象(当前仅「指定客户组」用客户组 id,A3 前可为空);
* - `customerIds`:解析后的可见客户集合(由 CustomerDirectory 端口解析,A3 前为空数组)。
*
* 角色详情返回角色声明的范围(带 targetIds);`/me` 返回多角色并集解析结果(带 customerIds)。
*/
export const dataScopeSchema = z.object({ export const dataScopeSchema = z.object({
type: dataScopeTypeSchema, type: dataScopeTypeSchema,
targetIds: z.array(ulidSchema).optional(),
customerIds: z.array(ulidSchema).optional(), customerIds: z.array(ulidSchema).optional(),
}); });
export type DataScope = z.infer<typeof dataScopeSchema>; export type DataScope = z.infer<typeof dataScopeSchema>;
/** GET /me 响应:当前用户、角色、权限点集合、数据范围、是否需强制改密 */ /**
* GET /me 响应:当前用户、角色、权限点集合、数据范围(多角色并集)、是否需强制改密。
* 超管的 dataScopes 固定为 `[{ type: 'all' }]`;其余角色按六类范围并集解析。
*/
export const meResponseSchema = z.object({ export const meResponseSchema = z.object({
user: userSummarySchema.extend({ user: userSummarySchema.extend({
phone: phoneSchema.optional(), phone: phoneSchema.optional(),
}), }),
roles: z.array(roleSummarySchema), roles: z.array(roleSummarySchema),
permissions: z.array(z.string().min(1, '权限点不能为空')), permissions: z.array(z.string().min(1, '权限点不能为空')),
dataScope: dataScopeSchema, dataScopes: z.array(dataScopeSchema).min(1, '至少返回一个数据范围'),
mustChangePassword: z.boolean(), mustChangePassword: z.boolean(),
}); });
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest';
import { ulid } from '../ulid.js';
import {
createRoleInputSchema,
permissionCodeSchema,
permissionTreeSchema,
roleDataScopeInputSchema,
roleDetailSchema,
roleListItemSchema,
roleListQuerySchema,
roleSaveResultSchema,
updateRoleInputSchema,
} from './roles.js';
const ISO = '2026-11-03T08:00:00.000Z';
function makeListItem(): Record<string, unknown> {
return {
id: ulid(),
code: 'material_operator',
name: '素材运营',
description: '素材生产、快筛、导出与归档',
isSystem: false,
status: 'active',
permissionCount: 9,
specialPermissionCount: 0,
userCount: 2,
dataScope: { type: 'owned_customers' },
createdAt: ISO,
updatedAt: ISO,
};
}
describe('roleListItemSchema / roleDetailSchema', () => {
it('列表项包含目录统计与数据范围', () => {
const parsed = roleListItemSchema.parse(makeListItem());
expect(parsed.permissionCount).toBe(9);
expect(parsed.userCount).toBe(2);
expect(parsed.dataScope.type).toBe('owned_customers');
});
it('详情在列表项基础上含普通权限、特殊权限与绑定用户', () => {
const detail = roleDetailSchema.parse({
...makeListItem(),
permissions: ['script:read', 'script:write'],
specialPermissions: ['delivery:export'],
boundUsers: [
{
id: ulid(),
name: '唐嘉',
account: 'tang.jia',
phoneMasked: '135****6641',
departmentName: '素材一组',
status: 'disabled',
},
],
});
expect(detail.permissions).toHaveLength(2);
expect(detail.specialPermissions).toEqual(['delivery:export']);
expect(detail.boundUsers[0]?.phoneMasked).toBe('135****6641');
});
it('拒绝负统计值 / 未登记权限点 / 非法状态', () => {
expect(() => roleListItemSchema.parse({ ...makeListItem(), userCount: -1 })).toThrow();
expect(() =>
roleDetailSchema.parse({
...makeListItem(),
permissions: ['script:delete_all'],
specialPermissions: [],
boundUsers: [],
}),
).toThrow('未登记的权限点编码');
expect(() => roleListItemSchema.parse({ ...makeListItem(), status: 'enabled' })).toThrow();
});
});
describe('角色输入 schema', () => {
it('新建角色:权限与特殊权限默认空,但数据范围必填(六类单选)', () => {
expect(
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'created_by_me' } }),
).toEqual({ name: '运营', dataScope: { type: 'created_by_me' } });
expect(() => createRoleInputSchema.parse({ name: '运营' })).toThrow();
});
it('权限点必须已登记,且特殊权限单独字段提交', () => {
expect(() =>
createRoleInputSchema.parse({
name: '运营',
dataScope: { type: 'all' },
permissions: ['script:delete_all'],
}),
).toThrow('未登记的权限点编码');
expect(() =>
createRoleInputSchema.parse({
name: '运营',
dataScope: { type: 'all' },
permissions: ['delivery:export'],
}),
).toThrow('必须放入 specialPermissions');
expect(() =>
createRoleInputSchema.parse({
name: '运营',
dataScope: { type: 'all' },
specialPermissions: ['script:read'],
}),
).toThrow('不能放入 specialPermissions');
const parsed = createRoleInputSchema.parse({
name: '运营',
dataScope: { type: 'customer_groups', targetIds: [] },
permissions: ['script:read'],
specialPermissions: ['delivery:export'],
});
expect(parsed.permissions).toEqual(['script:read']);
expect(parsed.specialPermissions).toEqual(['delivery:export']);
});
it('未知字段被拒绝(strict)', () => {
expect(() =>
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'all' }, unknown: 1 }),
).toThrow();
expect(() => updateRoleInputSchema.parse({ copyFromRoleId: ulid() })).toThrow();
});
it('更新支持 status enabled / disabled 与停用原因', () => {
expect(updateRoleInputSchema.parse({ status: 'disabled', reason: '岗位合并' })).toEqual({
status: 'disabled',
reason: '岗位合并',
});
expect(() => updateRoleInputSchema.parse({ status: 'active' })).toThrow();
});
it('权限点编码 schema 与目录同源', () => {
expect(permissionCodeSchema.parse('role:view')).toBe('role:view');
expect(() => permissionCodeSchema.parse('role:delete')).toThrow();
});
});
describe('roleDataScopeInputSchema', () => {
it('六类范围可用;指定客户组可带 targetIds', () => {
expect(roleDataScopeInputSchema.parse({ type: 'all' })).toEqual({ type: 'all' });
const groups = roleDataScopeInputSchema.parse({
type: 'customer_groups',
targetIds: [ulid()],
});
expect(groups.targetIds).toHaveLength(1);
expect(roleDataScopeInputSchema.parse({ type: 'customer_groups', targetIds: [] })).toEqual({
type: 'customer_groups',
targetIds: [],
});
});
it('非「指定客户组」范围不允许 targetIds', () => {
expect(() => roleDataScopeInputSchema.parse({ type: 'all', targetIds: [ulid()] })).toThrow(
'只有「指定客户组」范围允许填写 targetIds',
);
});
});
describe('roleListQuerySchema', () => {
it('默认 status=active(兼容 A1 角色多选)、sort=created_at、order=desc、limit=20', () => {
expect(roleListQuerySchema.parse({})).toEqual({
status: 'active',
sort: 'created_at',
order: 'desc',
limit: 20,
});
});
it('白名单校验排序与状态', () => {
expect(roleListQuerySchema.parse({ sort: 'name', order: 'asc', status: 'disabled' }).sort).toBe(
'name',
);
expect(() => roleListQuerySchema.parse({ sort: 'permission_count' })).toThrow();
expect(() => roleListQuerySchema.parse({ status: 'enabled' })).toThrow();
});
});
describe('permissionTreeSchema / roleSaveResultSchema', () => {
it('权限树含模块、特殊权限与 SoD 对', () => {
const parsed = permissionTreeSchema.parse({
modules: [
{
module: 'script',
moduleLabel: '脚本',
sortOrder: 50,
permissions: [
{ code: 'script:read', name: '脚本查看', description: '', isSpecial: false },
],
},
],
specialPermissions: [
{ code: 'delivery:export', name: '交付导出', description: '', isSpecial: true },
],
sodPairs: [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '',
permissions: ['script:write', 'script:review'],
},
],
totalPermissions: 30,
});
expect(parsed.modules).toHaveLength(1);
expect(parsed.totalPermissions).toBe(30);
});
it('保存结果含角色详情、影响用户数与软提示', () => {
const result = roleSaveResultSchema.parse({
role: {
...makeListItem(),
permissions: [],
specialPermissions: [],
boundUsers: [],
},
affectedUserCount: 3,
warnings: ['角色数量已达建议上限 20'],
});
expect(result.affectedUserCount).toBe(3);
expect(result.warnings).toHaveLength(1);
});
});
+304
View File
@@ -0,0 +1,304 @@
/**
* 角色管理共享契约(A2:角色 CRUD、权限点目录、特殊权限、六类数据范围、SoD)
* 依据:PRD §5.4.3、技术架构 §6.3.1 / §8.7 D-07、开发计划 §6.3 / §7.2、H1 权限门口径。
*
* 约定:
* - query 用 snake_case(cursor/limit/keyword/status/sort/order);
* - 写 body 用 camelCase 且 `.strict()`;特殊权限与普通权限分两个字段提交;
* - 权限点编码只能在 `permissions.ts` 目录内取值,且 `code` 自动生成、不可改;
* - **数据范围:一个角色只选一种类型**(六类单选),仅 `customer_groups` 可带多个 `targetIds`;
* 多角色之间才在 `/me` 取并集;
* - DELETE 角色返回 204;系统角色不可删除 / 不可停用 / 不可改权限,仅显示名可改。
*/
import { z } from 'zod';
import { isPermissionCode, isSpecialPermission } from '../permissions.js';
import { cursorPaginationQuerySchema } from './api.js';
import { isoDateTimeSchema, ulidSchema } from './common.js';
import { dataScopeSchema, dataScopeTypeSchema, userStatusSchema } from './identity.js';
// ---------------------------------------------------------------------------
// 基础字段
// ---------------------------------------------------------------------------
/** 已登记的权限点编码(未登记 → 40001,不允许角色里出现「幽灵权限」) */
export const permissionCodeSchema = z.string().refine(isPermissionCode, '未登记的权限点编码');
/** 角色显示名:1–64 字符(超管也允许改显示名) */
export const roleNameSchema = z
.string()
.trim()
.min(1, '请输入角色名称')
.max(64, '角色名称最多 64 个字符');
/** 角色说明:≤ 255 字符(与 roles.description VarChar(255) 对齐) */
export const roleDescriptionSchema = z.string().trim().max(255, '角色说明最多 255 个字符');
/** 角色状态(输出口径):active 启用 / disabled 停用 */
export const ROLE_STATUSES = ['active', 'disabled'] as const;
export const roleStatusSchema = z.enum(ROLE_STATUSES, {
errorMap: () => ({ message: '角色状态只能是 active 或 disabled' }),
});
export type RoleStatus = z.infer<typeof roleStatusSchema>;
/** 角色状态变更入参口径:enabled 启用 / disabled 停用(PATCH status) */
export const ROLE_STATUS_ACTIONS = ['enabled', 'disabled'] as const;
export const roleStatusActionSchema = z.enum(ROLE_STATUS_ACTIONS, {
errorMap: () => ({ message: '状态变更只能是 enabled 或 disabled' }),
});
export type RoleStatusAction = z.infer<typeof roleStatusActionSchema>;
/** 角色列表筛选状态:默认 all(停用角色在列表中可查) */
export const ROLE_LIST_STATUSES = ['all', 'active', 'disabled'] as const;
export const roleListStatusSchema = z.enum(ROLE_LIST_STATUSES, {
errorMap: () => ({ message: '筛选状态只能是 all / active / disabled' }),
});
export type RoleListStatus = z.infer<typeof roleListStatusSchema>;
/** 角色列表排序字段 */
export const ROLE_LIST_SORTS = ['created_at', 'name'] as const;
export const roleListSortSchema = z.enum(ROLE_LIST_SORTS, {
errorMap: () => ({ message: '排序字段只能是 created_at 或 name' }),
});
export type RoleListSort = z.infer<typeof roleListSortSchema>;
export const listOrderSchema = z.enum(['asc', 'desc'], {
errorMap: () => ({ message: '排序方向只能是 asc 或 desc' }),
});
export type ListOrder = z.infer<typeof listOrderSchema>;
// ---------------------------------------------------------------------------
// 数据范围(角色声明 / 输出)
// ---------------------------------------------------------------------------
/**
* 角色数据范围入参:六类之一 + 可选授权对象(单选模型)。
* - 一个角色只能有一种范围类型;
* - 只有 `customer_groups` 允许 `targetIds`(多个客户组),其他类型必须为空 / 缺省;
* - A3 前暂无客户组数据,`targetIds` 允许为空数组。
*/
export const roleDataScopeInputSchema = z
.object({
type: dataScopeTypeSchema,
targetIds: z.array(ulidSchema).max(100, '单个范围最多 100 个授权对象').optional(),
})
.strict()
.refine(
(scope) => scope.type === 'customer_groups' || (scope.targetIds ?? []).length === 0,
'只有「指定客户组」范围允许填写 targetIds',
);
export type RoleDataScopeInput = z.infer<typeof roleDataScopeInputSchema>;
// ---------------------------------------------------------------------------
// 角色输出
// ---------------------------------------------------------------------------
/** 角色绑定用户摘要(角色详情「绑定用户」区) */
export const roleBoundUserSchema = z.object({
id: ulidSchema,
name: z.string().min(1, '用户姓名不能为空'),
account: z.string().min(1, '账号不能为空'),
phoneMasked: z.string().min(1, '手机号掩码不能为空'),
departmentName: z.string().min(1, '部门名称不能为空'),
status: userStatusSchema,
});
export type RoleBoundUser = z.infer<typeof roleBoundUserSchema>;
/** 角色列表项:统计字段 + 数据范围(不含权限点明细,避免列表过大) */
export const roleListItemSchema = z.object({
id: ulidSchema,
code: z.string().min(1, '角色编码不能为空'),
name: z.string().min(1, '角色名称不能为空'),
description: z.string(),
isSystem: z.boolean(),
status: roleStatusSchema,
/** 普通权限点数量(不含特殊权限) */
permissionCount: z.number().int('权限点数量必须是整数').nonnegative('权限点数量不能为负数'),
/** 特殊权限点数量 */
specialPermissionCount: z
.number()
.int('特殊权限数量必须是整数')
.nonnegative('特殊权限数量不能为负数'),
/** 绑定用户数(含停用用户,用于删除前校验与影响面提示) */
userCount: z.number().int('用户数必须是整数').nonnegative('用户数不能为负数'),
/** 角色声明的数据范围(单选;指定客户组时带 targetIds,不含解析后的 customerIds) */
dataScope: dataScopeSchema,
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type RoleListItem = z.infer<typeof roleListItemSchema>;
/** 角色详情 = 列表项 + 权限点明细 + 绑定用户 */
export const roleDetailSchema = roleListItemSchema.extend({
permissions: z.array(permissionCodeSchema),
specialPermissions: z.array(permissionCodeSchema),
boundUsers: z.array(roleBoundUserSchema),
});
export type RoleDetail = z.infer<typeof roleDetailSchema>;
export const roleListResponseSchema = z.object({
items: z.array(roleListItemSchema),
next_cursor: z.string().nullable(),
});
export type RoleListResponse = z.infer<typeof roleListResponseSchema>;
/** 角色保存结果(POST / PATCH 共用):详情 + 影响用户数 + 软提示 */
export const roleSaveResultSchema = z.object({
role: roleDetailSchema,
/** 本次变更影响的绑定用户数(PATCH 返回;POST 新建为 0) */
affectedUserCount: z.number().int('影响用户数必须是整数').nonnegative('影响用户数不能为负数'),
/** 软提示(角色数量 / 超管人数超过建议值等),不阻断保存 */
warnings: z.array(z.string()),
});
export type RoleSaveResult = z.infer<typeof roleSaveResultSchema>;
// ---------------------------------------------------------------------------
// 角色输入
// ---------------------------------------------------------------------------
const rolePermissionListSchema = z
.array(permissionCodeSchema)
.max(100, '权限点数量超出上限')
.optional();
function assertPermissionBuckets(
input: { permissions?: string[]; specialPermissions?: string[] },
context: z.RefinementCtx,
): void {
for (const code of input.permissions ?? []) {
if (isSpecialPermission(code)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['permissions'],
message: `特殊权限 ${code} 必须放入 specialPermissions 单独授予`,
});
}
}
for (const code of input.specialPermissions ?? []) {
if (!isSpecialPermission(code)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['specialPermissions'],
message: `普通权限 ${code} 不能放入 specialPermissions`,
});
}
}
}
/**
* 新建角色:权限点与特殊权限默认全空,`code` 由后端生成。
* `dataScope` 必填(六类单选);未显式选择时前端应默认 `created_by_me` 最小权限。
*/
export const createRoleInputSchema = z
.object({
name: roleNameSchema,
description: roleDescriptionSchema.optional(),
/** 普通权限点(不得包含特殊权限) */
permissions: rolePermissionListSchema,
/** 特殊权限点(必须全部是特殊权限,单独授予) */
specialPermissions: rolePermissionListSchema,
/** 数据范围(必填,单选) */
dataScope: roleDataScopeInputSchema,
})
.strict()
.superRefine(assertPermissionBuckets);
export type CreateRoleInput = z.infer<typeof createRoleInputSchema>;
/** 更新角色:字段级可选;status 为 enabled / disabled;系统角色仅显示名可改 */
export const updateRoleInputSchema = z
.object({
name: roleNameSchema.optional(),
description: roleDescriptionSchema.optional(),
permissions: rolePermissionListSchema,
specialPermissions: rolePermissionListSchema,
/** 数据范围(省略则保持原值) */
dataScope: roleDataScopeInputSchema.optional(),
status: roleStatusActionSchema.optional(),
/** 停用原因(status=disabled 时记录审计) */
reason: z.string().trim().max(200, '停用原因最多 200 个字符').optional(),
})
.strict()
.superRefine(assertPermissionBuckets);
export type UpdateRoleInput = z.infer<typeof updateRoleInputSchema>;
/** 停用角色入参(PATCH status 的停用分支;系统角色拒绝) */
export const disableRoleInputSchema = z
.object({
status: z.literal('disabled'),
reason: z.string().trim().max(200, '停用原因最多 200 个字符').optional(),
})
.strict();
export type DisableRoleInput = z.infer<typeof disableRoleInputSchema>;
/**
* 角色列表 query(snake_case,默认按创建时间倒序)。
* 默认 `status=active`:兼容 A1 用户管理页的角色多选(只返回启用角色);
* 角色管理页需要查看全部 / 停用时显式传 `status=all` / `status=disabled`。
*/
export const roleListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64, '关键词最多 64 个字符').optional(),
status: roleListStatusSchema.default('active'),
sort: roleListSortSchema.default('created_at'),
order: listOrderSchema.default('desc'),
});
export type RoleListQuery = z.infer<typeof roleListQuerySchema>;
// ---------------------------------------------------------------------------
// 权限点目录(GET /permissions)
// ---------------------------------------------------------------------------
export const permissionNodeSchema = z.object({
code: z.string().min(1, '权限点编码不能为空'),
name: z.string().min(1, '权限点名称不能为空'),
description: z.string(),
isSpecial: z.boolean(),
});
export type PermissionNode = z.infer<typeof permissionNodeSchema>;
export const permissionModuleNodeSchema = z.object({
module: z.string().min(1, '模块编码不能为空'),
moduleLabel: z.string().min(1, '模块名称不能为空'),
sortOrder: z.number().int(),
permissions: z.array(permissionNodeSchema),
});
export type PermissionModuleNode = z.infer<typeof permissionModuleNodeSchema>;
export const sodPairSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string(),
permissions: z.tuple([z.string().min(1), z.string().min(1)]),
});
export type SodPairItem = z.infer<typeof sodPairSchema>;
/** GET /permissions:10 模块树 + 特殊权限汇总 + SoD 互斥对(前端勾选树与冲突高亮同源) */
export const permissionTreeSchema = z.object({
modules: z.array(permissionModuleNodeSchema),
specialPermissions: z.array(permissionNodeSchema),
sodPairs: z.array(sodPairSchema),
totalPermissions: z.number().int().positive('权限点总数必须为正整数'),
});
export type PermissionTree = z.infer<typeof permissionTreeSchema>;
+19 -10
View File
@@ -1,14 +1,18 @@
# 种子数据状态(A1) # 种子数据状态(A2)
A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织、账号与角色种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。 A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角色种子;A2 补齐权限点目录、角色权限映射与六类数据范围种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
> 权限点编码唯一事实源是 `packages/shared/src/permissions.ts`(30 个 / 10 模块 / 6 个特殊权限 / 3 对 SoD),`seed.ts` 直接引用 shared 源码目录,不在本文件复制编码字符串。
## 已 seed(`prisma/seed.ts`) ## 已 seed(`prisma/seed.ts`)
| 数据 | 来源 | 幂等键 | 说明 | | 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ---------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 | | 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` | | 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 角色 8 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 超级管理员(`is_system=true`,全部权限点)、平台管理员、素材运营、设计师、文案策划、审核员、AI 工程师、客户负责人;权限点取值来自 `@lingxi/shared` `PERMISSION_CODES`(本文件按字符串固化) | | 权限点目录 30 个 | `permissions` | `code`(只增不删) | 10 个模块(script / material / asset / delivery / customer / customer_group / identity / industry / platform / audit);启动时由 `PermissionCatalogService` 幂等 upsert,seed 同步一次 |
| 角色 10 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 与 `design/a2-ui-v1/brief.md` §4 对齐:超级管理员(隐式全集,映射 24 普通 + 6 特殊)、平台管理员 12+1(`audit:read`)、素材运营 9、设计师 4、文案策划 2、审核员 4、AI 工程师 3+1(`ai:configure`)、客户负责人 5、行业配置 4、审核主管 5(停用);非超管角色合计 50 条映射,特殊授予共 8 项;新建角色仍默认全空、特殊权限单独授予 |
| 角色数据范围 | `data_scopes` | 角色 `code`;范围先清后建 | 一个角色只允许一种范围类型:超管 / 平台管理员 / AI 工程师 / 行业配置 = `all`;素材运营 / 设计师 = `owned_customers`;文案策划 = `department`;审核员 / 审核主管 = `department_and_children`;客户负责人 = `customer_groups`(A3 前无客户组,保留一行 `target_id = NULL` 以固定范围类型,不降级为最小权限) |
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 | | 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 | | 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
@@ -33,16 +37,21 @@ A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织
## 使用 ## 使用
```bash ```bash
pnpm db:migrate # 应用 a1_identity 迁移(expand) pnpm db:migrate # 应用 a2_role_permissions_data_scopes 迁移(expand:permissions / data_scopes 两张新表)
pnpm db:seed # 幂等写入上述种子(可重复执行) pnpm db:seed # 幂等写入上述种子(可重复执行;空库与存量库均已验证)
# 本地重置:pnpm db:reset(会重新执行全部迁移与 seed) # 本地重置:pnpm db:reset(会重新执行全部迁移与 seed)
``` ```
## 与 A0 遗留 TODO 的关系 ## 与 A1 / A2 遗留的关系
A0 记录的「超管 / 角色 / 示例用户待 A1」已在本次迁移与种子中补齐;字典(A4)、行业包(A3)仍按批次留待后续建表,不在 A1 种子内。原 `Q-A0-01`(种子验收与建表顺序冲突)随 A1 建表落地而解除。 - A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。
- 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。
- 字典(A4)、客户 / 客户组(A3)、行业包(A3)仍留待后续批次建表,不在本种子内。
- `data_scopes` 只存角色声明的范围与授权对象;解析后的可见客户集合由 `CustomerDirectory` 端口在请求期计算,A3 前为空集(种子不写客户 id)。
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
## 已知限制 ## 已知限制
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户。 - 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。
- 审计表属 A5,`@Audit` 当前写结构化日志;A5 落库后在此扩展日志 + 审计表双写。 - 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "permissions" (
"code" VARCHAR(64) NOT NULL,
"module" VARCHAR(32) NOT NULL,
"module_label" VARCHAR(32) NOT NULL,
"name" VARCHAR(64) NOT NULL,
"description" VARCHAR(255) NOT NULL DEFAULT '',
"is_special" BOOLEAN NOT NULL DEFAULT false,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "data_scopes" (
"id" CHAR(26) NOT NULL,
"role_id" CHAR(26) NOT NULL,
"scope_type" VARCHAR(32) NOT NULL,
"target_id" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "data_scopes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "permissions_module_sort_order_idx" ON "permissions"("module", "sort_order");
-- CreateIndex
CREATE INDEX "data_scopes_role_id_idx" ON "data_scopes"("role_id");
-- CreateIndex
CREATE INDEX "data_scopes_scope_type_target_id_idx" ON "data_scopes"("scope_type", "target_id");
-- CreateIndex
CREATE UNIQUE INDEX "data_scopes_role_id_scope_type_target_id_key" ON "data_scopes"("role_id", "scope_type", "target_id");
-- AddForeignKey
ALTER TABLE "data_scopes" ADD CONSTRAINT "data_scopes_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+36
View File
@@ -122,6 +122,7 @@ model Role {
users UserRole[] users UserRole[]
permissions RolePermission[] permissions RolePermission[]
dataScopes DataScope[]
@@map("roles") @@map("roles")
} }
@@ -153,6 +154,41 @@ model RolePermission {
@@map("role_permissions") @@map("role_permissions")
} }
/// 权限点目录(A2-BE-02):唯一事实源在 @lingxi/shared PERMISSION_CATALOG;
/// 启动时幂等 upsert 同步,只增不删;前端权限树由目录生成(不手写迁移)。
model Permission {
code String @id @db.VarChar(64)
module String @db.VarChar(32)
moduleLabel String @map("module_label") @db.VarChar(32)
name String @db.VarChar(64)
description String @default("") @db.VarChar(255)
isSpecial Boolean @default(false) @map("is_special")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([module, sortOrder])
@@map("permissions")
}
/// 角色数据范围(A2-BE-04):六类范围之一 + 可选授权对象(当前只有「指定客户组」用客户组 id)。
/// 一行 = 一个范围类型的一个授权对象;无对象类型(全部 / 本部门 / 负责客户 / 本人创建)只存一行 target_id = NULL。
/// 解析后的可见客户集合由 CustomerDirectory 端口计算,不落库(A3 前为空集)。
model DataScope {
id String @id @db.Char(26)
roleId String @map("role_id") @db.Char(26)
scopeType String @map("scope_type") @db.VarChar(32)
targetId String? @map("target_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([roleId, scopeType, targetId])
@@index([roleId])
@@index([scopeType, targetId])
@@map("data_scopes")
}
/// 刷新令牌:jti 作主键、只存哈希;旋转时旧 token 立刻吊销并记录 replaced_by_id /// 刷新令牌:jti 作主键、只存哈希;旋转时旧 token 立刻吊销并记录 replaced_by_id
model RefreshToken { model RefreshToken {
id String @id @db.Char(26) id String @id @db.Char(26)
+194 -62
View File
@@ -1,15 +1,25 @@
// 灵犀 · 种子数据(A1) // 灵犀 · 种子数据(A2)
// 原则: // 原则:
// 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖; // 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖;
// 2) 角色-权限点、用户-角色为种子可重建映射:先清后建(限定在种子角色 / 种子用户范围内); // 2) 权限点目录、角色-权限点、角色-数据范围、用户-角色为种子可重建映射:先清后建
// 3) 超管、部门、示例用户口径见 prisma/SEED.md。 // (限定在种子角色 / 种子用户范围内;生产禁止直接跑 seed);
// 3) 权限点编码唯一事实源是 packages/shared/src/permissions.ts,本文件不复制编码字符串;
// 4) 超管、部门、示例角色 / 用户口径见 prisma/SEED.md。
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { PrismaClient, type UserStatus } from '@prisma/client'; import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
import { config as loadDotenv } from 'dotenv'; import { config as loadDotenv } from 'dotenv';
// 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移)
import {
findSodConflicts,
Permission,
PERMISSION_CATALOG,
splitPermissions,
} from '../packages/shared/src/permissions.js';
for (const file of ['.env.local', '.env']) { for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file); const path = resolve(process.cwd(), file);
if (existsSync(path)) { if (existsSync(path)) {
@@ -144,42 +154,35 @@ interface SeedRole {
name: string; name: string;
description: string; description: string;
isSystem: boolean; isSystem: boolean;
status?: RoleStatus;
/** 普通权限点(不得包含特殊权限;特殊权限必须放 specialPermissions 显式单独授予) */
permissions: string[]; permissions: string[];
/** 特殊权限点:默认关闭,仅在种子里显式授予(原型口径:超管 6 + 平台管理员 1 + AI 工程师 1) */
specialPermissions?: string[];
/** 数据范围声明(一个角色只允许一种类型;A3 前「指定客户组」targetIds 为空) */
dataScope: SeedDataScope;
} }
interface SeedDataScope {
type: string;
targetIds?: string[];
}
/**
* 种子角色与权限映射:与 design/a2-ui-v1/brief.md §4 的共享角色数据对齐
* (各角色普通权限数 12 / 9 / 4 / 2 / 4 / 3 / 5 / 4 / 5,特殊权限授予 8 项)。
*/
const ROLES: SeedRole[] = [ const ROLES: SeedRole[] = [
{ {
code: 'super_admin', code: 'super_admin',
name: '超级管理员', name: '超级管理员',
description: '内置角色,拥有全部权限点', description: '内置角色,拥有系统全部权限',
isSystem: true, isSystem: true,
permissions: [ permissions: PERMISSION_CATALOG.filter((item) => !item.isSpecial).map((item) => item.code),
'script:read', specialPermissions: PERMISSION_CATALOG.filter((item) => item.isSpecial).map(
'script:write', (item) => item.code,
'script:review', ),
'material:read', dataScope: { type: 'all' },
'material:write',
'material:render',
'material:review',
'asset:read',
'asset:write',
'delivery:read',
'delivery:export',
'customer:read',
'customer:manage',
'customer_field:approve',
'user:view',
'user:manage',
'role:manage',
'industry:manage',
'industry:publish',
'dict:manage',
'form:design',
'ai:configure',
'system:configure',
'audit:read',
'audit:export',
] as string[],
}, },
{ {
code: 'platform_admin', code: 'platform_admin',
@@ -187,16 +190,21 @@ const ROLES: SeedRole[] = [
description: '组织、角色与平台配置管理', description: '组织、角色与平台配置管理',
isSystem: false, isSystem: false,
permissions: [ permissions: [
'user:view', Permission.USER_VIEW,
'user:manage', Permission.USER_MANAGE,
'role:manage', Permission.ROLE_VIEW,
'system:configure', Permission.ROLE_MANAGE,
'audit:read', Permission.SYSTEM_CONFIGURE,
'industry:manage', Permission.INDUSTRY_MANAGE,
'dict:manage', Permission.DICT_MANAGE,
'form:design', Permission.FORM_DESIGN,
'customer:read', Permission.CUSTOMER_READ,
Permission.CUSTOMER_GROUP_MANAGE,
Permission.DELIVERY_READ,
Permission.ASSET_READ,
], ],
specialPermissions: [Permission.AUDIT_READ],
dataScope: { type: 'all' },
}, },
{ {
code: 'material_operator', code: 'material_operator',
@@ -204,50 +212,106 @@ const ROLES: SeedRole[] = [
description: '素材生产、快筛、导出与归档', description: '素材生产、快筛、导出与归档',
isSystem: false, isSystem: false,
permissions: [ permissions: [
'script:read', Permission.SCRIPT_READ,
'script:write', Permission.SCRIPT_WRITE,
'material:read', Permission.MATERIAL_READ,
'material:write', Permission.MATERIAL_WRITE,
'material:render', Permission.MATERIAL_RENDER,
'asset:read', Permission.ASSET_READ,
'asset:write', Permission.ASSET_WRITE,
'delivery:read', Permission.DELIVERY_READ,
Permission.CUSTOMER_READ,
], ],
dataScope: { type: 'owned_customers' },
}, },
{ {
code: 'designer', code: 'designer',
name: '设计师', name: '设计师',
description: '素材设计与资产维护', description: '素材设计与资产维护',
isSystem: false, isSystem: false,
permissions: ['material:read', 'material:write', 'asset:read', 'asset:write'], permissions: [
Permission.MATERIAL_READ,
Permission.MATERIAL_WRITE,
Permission.ASSET_READ,
Permission.ASSET_WRITE,
],
dataScope: { type: 'owned_customers' },
}, },
{ {
code: 'copywriter', code: 'copywriter',
name: '文案策划', name: '文案策划',
description: '脚本撰写与要素卡维护', description: '脚本撰写与要素卡维护',
isSystem: false, isSystem: false,
permissions: ['script:read', 'script:write'], permissions: [Permission.SCRIPT_READ, Permission.SCRIPT_WRITE],
dataScope: { type: 'department' },
}, },
{ {
code: 'reviewer', code: 'reviewer',
name: '审核员', name: '审核员',
description: '脚本与素材审核', description: '脚本与素材审核',
isSystem: false, isSystem: false,
permissions: ['script:read', 'script:review', 'material:read', 'material:review'], permissions: [
Permission.SCRIPT_READ,
Permission.SCRIPT_REVIEW,
Permission.MATERIAL_READ,
Permission.MATERIAL_REVIEW,
],
dataScope: { type: 'department_and_children' },
}, },
{ {
code: 'ai_engineer', code: 'ai_engineer',
name: 'AI 工程师', name: 'AI 工程师',
description: '模型接入与生成链路维护', description: '模型接入与生成链路维护',
isSystem: false, isSystem: false,
permissions: ['material:read', 'material:render', 'system:configure'], permissions: [
Permission.MATERIAL_READ,
Permission.MATERIAL_RENDER,
Permission.SYSTEM_CONFIGURE,
],
specialPermissions: [Permission.AI_CONFIGURE],
dataScope: { type: 'all' },
}, },
{ {
code: 'account_owner', code: 'account_owner',
name: '客户负责人', name: '客户负责人',
description: '客户资料查看、脚本审核与交付验收', description: '客户资料、脚本审核与交付验收',
isSystem: false, isSystem: false,
permissions: ['customer:read', 'customer:manage', 'delivery:read', 'script:review'], permissions: [
Permission.CUSTOMER_READ,
Permission.CUSTOMER_MANAGE,
Permission.CUSTOMER_TRANSFER,
Permission.DELIVERY_READ,
Permission.SCRIPT_REVIEW,
],
dataScope: { type: 'customer_groups', targetIds: [] },
},
{
code: 'industry_config',
name: '行业配置',
description: '行业包、表单与字典维护',
isSystem: false,
permissions: [
Permission.INDUSTRY_MANAGE,
Permission.FORM_DESIGN,
Permission.DICT_MANAGE,
Permission.CUSTOMER_READ,
],
dataScope: { type: 'all' },
},
{
code: 'review_lead',
name: '审核主管',
description: '原审核组主管角色,已并入审核员',
isSystem: false,
status: 'DISABLED',
permissions: [
Permission.SCRIPT_READ,
Permission.SCRIPT_REVIEW,
Permission.MATERIAL_READ,
Permission.MATERIAL_REVIEW,
Permission.CUSTOMER_READ,
],
dataScope: { type: 'department_and_children' },
}, },
]; ];
@@ -401,6 +465,75 @@ async function seedDepartments(): Promise<void> {
console.log(`[seed] departments 就绪:${String(DEPARTMENTS.length)} 个(幂等)`); console.log(`[seed] departments 就绪:${String(DEPARTMENTS.length)} 个(幂等)`);
} }
async function seedPermissions(): Promise<void> {
for (const item of PERMISSION_CATALOG) {
await prisma.permission.upsert({
where: { code: item.code },
update: {
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
create: {
code: item.code,
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
});
}
console.log(`[seed] permissions 就绪:${String(PERMISSION_CATALOG.length)} 个(幂等,只增不删)`);
}
async function seedRoleAccess(roleId: string, item: SeedRole): Promise<void> {
const normal = splitPermissions(item.permissions);
if (normal.specialPermissions.length > 0) {
throw new Error(
`种子角色 ${item.code} 的 permissions 混入特殊权限(必须放 specialPermissions):${normal.specialPermissions.join(',')}`,
);
}
const special = splitPermissions(item.specialPermissions ?? []);
if (special.permissions.length > 0) {
throw new Error(
`种子角色 ${item.code} 的 specialPermissions 混入普通权限:${special.permissions.join(',')}`,
);
}
const conflicts = item.isSystem
? []
: findSodConflicts([...normal.permissions, ...special.specialPermissions]);
if (conflicts.length > 0) {
throw new Error(
`种子角色 ${item.code} 违反职责互斥:${conflicts.map((c) => c.label).join(';')}`,
);
}
await prisma.rolePermission.deleteMany({ where: { roleId } });
await prisma.rolePermission.createMany({
data: [...normal.permissions, ...special.specialPermissions].map((permissionCode) => ({
roleId,
permissionCode,
})),
skipDuplicates: true,
});
await prisma.dataScope.deleteMany({ where: { roleId } });
// 无授权对象(含 A3 前空客户组)也保留一行 target_id = NULL,确保范围类型不丢失(不降级为最小权限)
const targetIds = item.dataScope.targetIds?.length ? item.dataScope.targetIds : [null];
await prisma.dataScope.createMany({
data: targetIds.map((targetId) => ({
id: ulid(),
roleId,
scopeType: item.dataScope.type,
targetId,
})),
skipDuplicates: true,
});
}
async function seedRoles(): Promise<void> { async function seedRoles(): Promise<void> {
for (const item of ROLES) { for (const item of ROLES) {
const role = await prisma.role.upsert({ const role = await prisma.role.upsert({
@@ -412,16 +545,14 @@ async function seedRoles(): Promise<void> {
name: item.name, name: item.name,
description: item.description, description: item.description,
isSystem: item.isSystem, isSystem: item.isSystem,
status: 'ACTIVE', status: item.status ?? 'ACTIVE',
}, },
}); });
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } }); await seedRoleAccess(role.id, item);
await prisma.rolePermission.createMany({
data: item.permissions.map((permissionCode) => ({ roleId: role.id, permissionCode })),
skipDuplicates: true,
});
} }
console.log(`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点映射(幂等)`); console.log(
`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点 / 数据范围映射(幂等,特殊权限按原型显式授予)`,
);
} }
async function seedUsers(): Promise<void> { async function seedUsers(): Promise<void> {
@@ -478,6 +609,7 @@ async function seedDepartmentLeaders(): Promise<void> {
async function main(): Promise<void> { async function main(): Promise<void> {
await seedConfigs(); await seedConfigs();
await seedPermissions();
await seedDepartments(); await seedDepartments();
await seedRoles(); await seedRoles();
await seedUsers(); await seedUsers();