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 14637 additions and 239 deletions

No files matched your search

+3
View File
@@ -19,6 +19,9 @@
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
| **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` | 归档 | 仅作沿革参考,**不具约束力** |
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
@@ -11,6 +11,7 @@ import { tap } from 'rxjs';
import { AUDIT_ACTION_KEY } from './decorators';
import {
type AuditDetail,
buildRequestContext,
type RequestContext,
RequestContextService,
@@ -26,7 +27,16 @@ interface AuditPayload {
result: 'success' | 'failure';
actorId: string | null;
account: string | null;
ip: string | null;
userAgent: string | null;
targetType: string | null;
targetId: string | null;
before?: unknown;
after?: unknown;
changes?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
traceId: string;
durationMs: number;
error?: string;
@@ -39,7 +49,10 @@ function actionOf(context: ExecutionContext): string | null {
/**
* 全局请求上下文 + @Audit 结构化日志。
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
*
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
@@ -80,15 +93,27 @@ export class AuditInterceptor implements NestInterceptor {
ctx: RequestContext,
action: string,
result: AuditPayload['result'],
targetId: string | null,
defaultTargetId: string | null,
error?: unknown,
): void {
const detail: AuditDetail = ctx.audit ?? {};
const payload: AuditPayload = {
action,
result,
actorId: ctx.userId,
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,
durationMs: Date.now() - ctx.startedAt,
...(error === undefined
+13 -1
View File
@@ -1,6 +1,8 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import type { FastifyRequest } from 'fastify';
import type { ScopeContext } from './scope/scope-context';
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
export interface JwtAccessPayload {
sub: string;
@@ -22,12 +24,22 @@ export interface AuthContext {
status: UserStatus;
roles: RoleSummary[];
permissions: string[];
dataScope: DataScope;
/**
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
*/
dataScopes: DataScope[];
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
departmentIds: string[];
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
isSuperAdmin: boolean;
mustChangePassword: boolean;
}
export interface AuthenticatedRequest extends FastifyRequest {
auth?: AuthContext;
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
scope?: ScopeContext;
}
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @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 type { AuthContext, AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
export const IS_PUBLIC_KEY = 'auth:public';
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
@@ -23,3 +24,11 @@ export const CurrentUser = createParamDecorator(
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 { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
@@ -123,9 +124,9 @@ export class PermissionGuard implements CanActivate {
return true;
}
const auth = requireAuth(context);
const missing = normal.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
if (!normal.some((code) => auth.permissions.includes(code))) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
}
return true;
}
@@ -147,11 +148,10 @@ export class SpecialPermissionGuard implements CanActivate {
return true;
}
const auth = requireAuth(context);
const missing = special.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
if (!special.some((code) => auth.permissions.includes(code))) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
details: { missing },
details: { required: special },
});
}
return true;
@@ -159,16 +159,32 @@ export class SpecialPermissionGuard implements CanActivate {
}
/**
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
*
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
* 公开接口(登录 / 健康检查)不解析。
*/
@Injectable()
export class ScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const auth = authOf(context);
if (auth !== null && auth.dataScope.type !== 'all') {
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(ScopeResolver) private readonly resolver: ScopeResolver,
@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;
}
}
@@ -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 type { Prisma } from '@prisma/client';
@@ -6,17 +14,19 @@ import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import type { AuthContext } from './auth.types';
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
/** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
export type UserAuthRecord = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true } };
roles: { include: { role: { include: { permissions: true } } } };
department: { include: { parent: true; children: true } };
roles: {
include: { role: { include: { permissions: true; dataScopes: true } } };
};
};
}>;
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 {
id: role.id,
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'] {
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**,不缓存角色/权限点,
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
* 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
*
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
*/
@Injectable()
export class PermissionService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async resolveAuth(userId: string): Promise<AuthContext | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
const user = await this.findAuthRecord(userId);
if (user === null) {
return null;
}
@@ -62,25 +99,35 @@ export class PermissionService {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
},
});
}
toAuthContext(user: UserAuthRecord): AuthContext {
const roles = activeRolesOf(user);
const permissions = [
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
].sort();
const roleEntities = roles.map((item) => item.role);
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();
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
return {
userId: user.id,
account: user.account,
status,
roles: roles.map((item) => toRoleSummary(item.role)),
roles: roleEntities.map((role) => toRoleSummary(role)),
permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
departmentIds: departmentSubtreeIds(user),
isSuperAdmin,
mustChangePassword: user.mustChangePassword,
};
}
@@ -4,13 +4,35 @@ import { ulid } from '@lingxi/shared';
import { Injectable } from '@nestjs/common';
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 {
traceId: string;
userId: string | null;
account: string | null;
permissions: string[] | null;
scope: ScopeContext | null;
audit: AuditDetail | null;
ip: string | null;
userAgent: string | null;
method: string;
@@ -32,6 +54,8 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
userId: auth?.userId ?? null,
account: auth?.account ?? null,
permissions: auth?.permissions ?? null,
scope: request.scope ?? null,
audit: null,
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
method: request.method,
@@ -42,7 +66,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
/**
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
* 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
*/
@Injectable()
export class RequestContextService {
@@ -63,4 +87,13 @@ export class RequestContextService {
}
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 { AuditInterceptor } from './audit.interceptor';
import { PermissionCatalogService } from './catalog/permission-catalog.service';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service';
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';
/**
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
*/
@Global()
@Module({
@@ -18,18 +24,27 @@ import { TokenService } from './token.service';
providers: [
RequestContextService,
PermissionService,
PermissionCatalogService,
PermissionGuard,
ScopeResolver,
TokenService,
AuditInterceptor,
JwtAuthGuard,
PermissionGuard,
SpecialPermissionGuard,
ScopeGuard,
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
{ provide: APP_GUARD, useExisting: ScopeGuard },
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
],
exports: [RequestContextService, PermissionService, TokenService],
exports: [
RequestContextService,
PermissionService,
PermissionCatalogService,
ScopeResolver,
TokenService,
],
})
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 { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
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 { ScopeContext } from './scope/scope-context';
import type { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const jwt = new JwtService({});
@@ -41,7 +43,9 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
status: 'active',
roles: [],
permissions: ['user:view'],
dataScope: { type: 'department_and_children' },
dataScopes: [{ type: 'department_and_children' }],
departmentIds: ['d1'],
isSuperAdmin: false,
mustChangePassword: false,
...overrides,
};
@@ -110,6 +114,7 @@ function authRecord(
createdAt: new Date(),
updatedAt: new Date(),
},
children: [],
},
roles: roleCodes.map((code, index) => ({
userId: 'u1',
@@ -128,6 +133,7 @@ function authRecord(
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
],
dataScopes: [],
},
})),
};
@@ -170,19 +176,31 @@ describe('TokenService', () => {
});
});
describe('PermissionService / resolveDataScope', () => {
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
expect(resolveDataScope(['super_admin']).type).toBe('all');
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
});
describe('PermissionService', () => {
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
const service = new PermissionService({} as never);
const context = service.toAuthContext(authRecord());
expect(context.permissions).toEqual(['user:manage', 'user:view']);
expect(context.status).toBe('active');
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 () => {
@@ -198,7 +216,7 @@ describe('PermissionService / resolveDataScope', () => {
const service = new PermissionService(prisma as never);
const context = await service.resolveAuth('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');
});
});
@@ -299,11 +317,26 @@ describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
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);
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 元数据可读取', () => {
@@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { PermissionService } from '../../common/security/permission.service';
import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
import { TokenService } from '../../common/security/token.service';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { testConfig } from '../../testing/test-app';
@@ -102,16 +103,19 @@ function harness(): Harness {
status: 'active',
roles: [],
permissions: ['user:manage'],
dataScope: { type: 'all' },
dataScopes: [{ type: 'all' }],
departmentIds: ['d1'],
isSuperAdmin: true,
mustChangePassword: false,
}),
};
const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) };
const me = {
getMe: vi.fn().mockResolvedValue({
user: { id: 'u1' },
roles: [],
permissions: [],
dataScope: { type: 'all' },
dataScopes: [{ type: 'all' }],
mustChangePassword: false,
}),
};
@@ -122,6 +126,7 @@ function harness(): Harness {
passwords,
failures as unknown as LoginFailureService,
permissions as unknown as PermissionService,
scopes as unknown as ScopeResolver,
me as unknown as MeService,
);
return { service, prisma, failures, passwords, permissions, me };
@@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { AuthRequestMeta } from '../../common/security/auth.types';
import { PermissionService } from '../../common/security/permission.service';
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
import { TokenService } from '../../common/security/token.service';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
@@ -33,6 +34,7 @@ export class AuthService {
@Inject(PasswordService) private readonly passwords: PasswordService,
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
@Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
@Inject(MeService) private readonly me: MeService,
) {}
@@ -61,8 +63,12 @@ export class AuthService {
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
});
const auth = await this.permissions.resolveAuth(user.id);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
}
const scope = await this.scopes.resolve(auth);
const { pair } = await this.createTokenPair(user.id, null, meta);
const me = await this.me.getMe(user.id, auth?.permissions ?? []);
const me = await this.me.getMe(user.id, auth.permissions, scope);
return { ...pair, me };
}
@@ -19,11 +19,18 @@ interface FixtureUser {
account: string;
}
interface MeBody {
permissions: string[];
dataScopes: { type: string; customerIds?: string[] }[];
user: { id: string; phone?: string };
mustChangePassword: boolean;
}
interface TokenBody {
accessToken: string;
refreshToken: string;
expiresIn: number;
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
me: MeBody;
}
interface ErrorBody {
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
await prisma.userRole.create({ data: { userId, roleId: role.id } });
}
interface JsonRequestResult<T> {
status: number;
body: T;
}
async function callJson<T>(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonRequestResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json<T>() };
}
const ROLE_NAME_PREFIX = 'IT-角色-';
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
const createdRoleIds: string[] = [];
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
async function createTestRole(input: {
name: string;
permissions?: string[];
isSystem?: boolean;
dataScope?: { type: string; targetIds?: string[] };
status?: 'ACTIVE' | 'DISABLED';
}): Promise<{ id: string; name: string }> {
const id = ulid();
const role = await prisma.role.create({
data: {
id,
code: `it_role_${id.toLowerCase()}`,
name: `${ROLE_NAME_PREFIX}${input.name}`,
description: '集成测试角色',
isSystem: input.isSystem ?? false,
status: input.status ?? 'ACTIVE',
permissions: {
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
},
dataScopes: {
create: [
{
id: ulid(),
scopeType: input.dataScope?.type ?? 'created_by_me',
targetId: input.dataScope?.targetIds?.[0] ?? null,
},
],
},
},
});
createdRoleIds.push(role.id);
return { id: role.id, name: role.name };
}
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${ACCOUNT_PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
return { id, account };
}
describe('identity 集成(真实 PG / Redis)', () => {
let created: CreatedApiApp;
let manager: FixtureUser;
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
manager = await createFixtureUser('manager', 'platform_admin');
viewer = await createFixtureUser('viewer', 'designer');
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
if (createdRoleIds.length > 0) {
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
}
await prisma.$disconnect();
await created.app.close();
});
@@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => {
expect(roles.statusCode).toBe(200);
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
});
// -------------------------------------------------------------------------
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
// -------------------------------------------------------------------------
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
modules: { module: string; moduleLabel: string }[];
specialPermissions: { code: string }[];
sodPairs: { id: string }[];
totalPermissions: number;
}>(created.app, 'GET', '/permissions', body.accessToken);
expect(response.status).toBe(200);
expect(response.body.totalPermissions).toBe(30);
expect(response.body.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
expect(response.body.specialPermissions).toHaveLength(6);
expect(response.body.sodPairs).toHaveLength(3);
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
const designerLogin = await login(created.app, designerOnly.account);
const denied = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
designerLogin.body.accessToken,
);
expect(denied.status).toBe(403);
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
});
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
items: {
id: string;
code: string;
name: string;
isSystem: boolean;
permissionCount: number;
specialPermissionCount: number;
userCount: number;
dataScope: { type: string };
}[];
}>(created.app, 'GET', '/roles', body.accessToken);
expect(response.status).toBe(200);
const codes = response.body.items.map((item) => item.code);
expect(codes).not.toContain('review_lead');
expect(codes).toContain('material_operator');
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
expect(superAdmin?.isSystem).toBe(true);
expect(superAdmin?.permissionCount).toBe(24);
expect(superAdmin?.specialPermissionCount).toBe(6);
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
const all = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles?status=all',
body.accessToken,
);
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
const selectorLogin = await login(created.app, selector.account);
const selectorRoles = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles',
selectorLogin.body.accessToken,
);
expect(selectorRoles.status).toBe(200);
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
const selectorPermissions = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
selectorLogin.body.accessToken,
);
expect(selectorPermissions.status).toBe(403);
});
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
const { body } = await login(created.app, manager.account);
const createdRole = await callJson<{
role: {
id: string;
code: string;
status: string;
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
boundUsers: unknown[];
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'POST', '/roles', body.accessToken, {
name: `${ROLE_NAME_PREFIX}内容运营`,
permissions: ['script:read'],
dataScope: { type: 'department' },
});
expect(createdRole.status).toBe(201);
createdRoleIds.push(createdRole.body.role.id);
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
expect(createdRole.body.role.permissions).toEqual(['script:read']);
expect(createdRole.body.role.specialPermissions).toEqual([]);
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
expect(createdRole.body.affectedUserCount).toBe(0);
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}互斥冲突`,
permissions: ['script:write', 'script:review'],
dataScope: { type: 'all' },
},
);
expect(sod.status).toBe(403);
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
const specialInNormal = await callJson<{ code: number }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
permissions: ['delivery:export'],
dataScope: { type: 'all' },
},
);
expect(specialInNormal.status).toBe(400);
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
});
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({
name: '待编辑',
permissions: ['script:read'],
dataScope: { type: 'department' },
});
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
const patched = await callJson<{
role: {
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
status: string;
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
permissions: ['script:read', 'user:view'],
specialPermissions: ['delivery:export'],
dataScope: { type: 'owned_customers' },
});
expect(patched.status).toBe(200);
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
expect(patched.body.affectedUserCount).toBe(1);
const outbox = await prisma.outboxEvent.findFirst({
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
orderBy: { occurredAt: 'desc' },
});
expect(outbox).not.toBeNull();
const payload = outbox?.payloadJson as {
roleId: string;
affectedUserIds: string[];
changes: { permissions: { added: string[]; removed: string[] } };
};
expect(payload.roleId).toBe(role.id);
expect(payload.affectedUserIds).toEqual([boundUser.id]);
expect(payload.changes.permissions.added).toEqual(['user:view']);
// 系统角色保护:改权限 40903,仅显示名可改
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const protectedUpdate = await callJson<{ code: number }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ permissions: ['user:view'] },
);
expect(protectedUpdate.status).toBe(409);
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const renamed = await callJson<{ role: { name: string } }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ name: '超级管理员' },
);
expect(renamed.status).toBe(200);
expect(renamed.body.role.name).toBe('超级管理员');
});
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
const { body } = await login(created.app, manager.account);
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const systemDelete = await callJson<{ code: number }>(
created.app,
'DELETE',
`/roles/${superAdminRole.id}`,
body.accessToken,
);
expect(systemDelete.status).toBe(409);
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const bound = await createTestRole({ name: '有绑定' });
await createFixtureUserWithRoleId('role-bound', bound.id);
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
created.app,
'DELETE',
`/roles/${bound.id}`,
body.accessToken,
);
expect(boundDelete.status).toBe(409);
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
expect(boundDelete.body.details.userCount).toBe(1);
const free = await createTestRole({ name: '无绑定' });
const freeDelete = await created.app.inject({
method: 'DELETE',
url: `${API}/roles/${free.id}`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(freeDelete.statusCode).toBe(204);
const gone = await prisma.role.findUnique({ where: { id: free.id } });
expect(gone).toBeNull();
});
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
const user = await createFixtureUserWithRoleId('role-disable', role.id);
const userLogin = await login(created.app, user.account);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'disabled', reason: '集成测试停用' },
);
expect(disabled.status).toBe(200);
expect(disabled.body.role.status).toBe('disabled');
expect(disabled.body.affectedUserCount).toBe(1);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
const enabled = await callJson<{ role: { status: string } }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'enabled' },
);
expect(enabled.body.role.status).toBe('active');
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
});
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
const adminLogin = await login(created.app, manager.account);
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
const ownedRole = await createTestRole({
name: '负责客户范围',
permissions: ['user:view'],
dataScope: { type: 'owned_customers' },
});
const deptRole = await createTestRole({
name: '部门范围',
permissions: ['customer:read'],
dataScope: { type: 'department' },
});
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
const userLogin = await login(created.app, user.account);
expect(userLogin.status).toBe(200);
const scopes = userLogin.body.me.dataScopes;
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
for (const scope of scopes) {
expect(scope.customerIds).toEqual([]);
}
});
});
@@ -8,6 +8,7 @@ import { LoginFailureService } from './login-failure.service';
import { MeController } from './me.controller';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { PermissionsController } from './permissions.controller';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
@@ -24,6 +25,7 @@ import { UsersService } from './users.service';
UsersController,
DepartmentsController,
RolesController,
PermissionsController,
],
providers: [
AuthService,
@@ -2,17 +2,19 @@ import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service';
import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { RolesService } from './roles.service';
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
import { maskPhone, toUserDetail, toUserSummary } from './user.mapper';
import { UsersService } from './users.service';
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
const base: UserWithRelations = {
function userFixture(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
const base: UserAuthRecord = {
id: 'u1',
account: 'chen.mo',
email: 'chen.mo@lingxi.cn',
@@ -51,6 +53,7 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
createdAt: new Date(),
updatedAt: new Date(),
},
children: [],
},
roles: [
{
@@ -66,6 +69,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [],
dataScopes: [],
},
},
{
@@ -81,6 +86,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
status: 'DISABLED',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [],
dataScopes: [],
},
},
],
@@ -357,43 +364,79 @@ describe('DepartmentsService', () => {
});
describe('MeService / RolesService', () => {
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
const scope = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [{ type: 'department_and_children' as const, customerIds: [] }],
customerIds: [] as string[],
};
it('/me:手机号按 user:manage 返回,dataScopes 来自 ScopeResolver', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = new MeService(prisma as unknown as PrismaService);
const me = await service.getMe('u1', ['user:manage']);
const me = await service.getMe('u1', ['user:manage'], scope);
expect(me.user.phone).toBe('13866202046');
expect(me.permissions).toEqual(['user:manage']);
expect(me.roles).toHaveLength(1);
const meWithout = await service.getMe('u1', []);
expect(me.dataScopes).toEqual([{ type: 'department_and_children', customerIds: [] }]);
const meWithout = await service.getMe('u1', [], scope);
expect(meWithout.user.phone).toBeUndefined();
await expectRejected(
new MeService({
user: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService).getMe('u1', []),
} as unknown as PrismaService).getMe('u1', [], scope),
ErrorCode.UNAUTHENTICATED,
);
await expectRejected(
new MeService(prisma as unknown as PrismaService).getMe('u1', [], null),
ErrorCode.OUT_OF_SCOPE,
);
});
it('角色列表只返回启用角色', async () => {
it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => {
const prisma = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: 'r1',
code: 'super_admin',
name: '超级管理员',
description: '内置',
isSystem: true,
code: 'reviewer',
name: '审核员',
description: '审核',
isSystem: false,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
createdAt: new Date('2026-11-01T00:00:00.000Z'),
updatedAt: new Date('2026-11-02T00:00:00.000Z'),
permissions: [
{ permissionCode: 'script:read' },
{ permissionCode: 'script:review' },
{ permissionCode: 'delivery:export' },
],
dataScopes: [{ scopeType: 'department_and_children', targetId: null }],
_count: { users: 3 },
},
]),
},
};
const roles = await new RolesService(prisma as unknown as PrismaService).list();
expect(roles).toEqual([
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
]);
const service = new RolesService(
prisma as unknown as PrismaService,
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
);
const result = await service.list({
status: 'active',
sort: 'created_at',
order: 'desc',
limit: 20,
});
expect(result.next_cursor).toBeNull();
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
id: 'r1',
code: 'reviewer',
status: 'active',
permissionCount: 2,
specialPermissionCount: 1,
userCount: 3,
dataScope: { type: 'department_and_children' },
});
});
});
@@ -4,10 +4,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import type { AuthContext } from '../../common/security/auth.types';
import { CurrentUser } from '../../common/security/decorators';
import { CurrentScope, CurrentUser } from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { MeService } from './me.service';
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */
@ApiTags('identity-me')
@ApiBearerAuth()
@Controller('me')
@@ -15,11 +16,14 @@ export class MeController {
constructor(@Inject(MeService) private readonly me: MeService) {}
@Get()
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' })
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> {
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' })
async current(
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<MeResponse> {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return this.me.getMe(auth.userId, auth.permissions);
return this.me.getMe(auth.userId, auth.permissions, scope);
}
}
+20 -8
View File
@@ -2,23 +2,35 @@ import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service';
import { resolveDataScope } from '../../common/security/permission.service';
import { activeRolesOf } from '../../common/security/permission.service';
import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { toRoleSummary, toUserSummary } from './user.mapper';
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */
/**
* /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。
* `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。
*/
@Injectable()
export class MeService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async getMe(userId: string, permissions: string[]): Promise<MeResponse> {
async getMe(
userId: string,
permissions: string[],
scope: ScopeContext | null,
): Promise<MeResponse> {
const user = await this.findUser(userId);
if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
}
if (scope === null) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
const roles = activeRolesOf(user);
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
return {
@@ -28,7 +40,7 @@ export class MeService {
},
roles: roles.map((item) => toRoleSummary(item.role)),
permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
dataScopes: scope.dataScopes,
mustChangePassword: user.mustChangePassword,
};
}
@@ -38,8 +50,8 @@ export class MeService {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
},
});
}
@@ -0,0 +1,29 @@
import { Permission, type PermissionTree } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PermissionCatalogService } from '../../common/security/catalog/permission-catalog.service';
import { RequirePermissions } from '../../common/security/decorators';
/**
* 权限点目录(A2-BE-02):前端角色编辑器据此渲染「模块 × 操作」勾选树、
* 特殊权限区域与 SoD 互斥对高亮。目录由 `@lingxi/shared` 代码注册,不读业务 DB。
*/
@ApiTags('identity-permissions')
@ApiBearerAuth()
@Controller('permissions')
export class PermissionsController {
constructor(
@Inject(PermissionCatalogService) private readonly catalog: PermissionCatalogService,
) {}
@Get()
@RequirePermissions(Permission.ROLE_VIEW)
@ApiOperation({
summary: '权限点目录(10 模块树 + 特殊权限 + SoD 对)',
description: '模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予',
})
getTree(): PermissionTree {
return this.catalog.buildTree();
}
}
@@ -1,11 +1,49 @@
import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import {
type CreateRoleInput,
createRoleInputSchema,
ErrorCode,
Permission,
type RoleDetail,
type RoleListQuery,
roleListQuerySchema,
type RoleListResponse,
type RoleSaveResult,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../../common/security/decorators';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
import { RolesService } from './roles.service';
/** 角色列表:供用户管理页做角色多选绑定 */
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 角色管理(A2-BE-01 / 03 / 05)。
*
* 兼容 A1:`GET /roles` 同时接受 `role:view` 与 `user:view`(用户管理页的角色多选),
* 默认只返回启用角色,返回项包含 `RoleSummary` 的 id / code / name / isSystem / description。
*/
@ApiTags('identity-roles')
@ApiBearerAuth()
@Controller('roles')
@@ -13,9 +51,59 @@ export class RolesController {
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
@Get()
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '角色列表(绑定用)' })
async list(): Promise<ListResponse<RoleSummary>> {
return { items: await this.roles.list(), next_cursor: null };
@RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW)
@ApiOperation({
summary: '角色列表(游标分页 + 筛选 + 排序)',
description: '默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围',
})
async list(
@Query(new ZodValidationPipe(roleListQuerySchema)) query: RoleListQuery,
): Promise<RoleListResponse> {
return this.roles.list(query);
}
@Get(':id')
@RequirePermissions(Permission.ROLE_VIEW)
@ApiOperation({ summary: '角色详情(权限点、特殊权限、数据范围、绑定用户)' })
async detail(@Param('id') id: string): Promise<RoleDetail> {
return this.roles.detail(id);
}
@Post()
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.create')
@ApiOperation({
summary: '新建角色',
description: '权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予',
})
async create(
@Body(new ZodValidationPipe(createRoleInputSchema)) input: CreateRoleInput,
@CurrentUser() auth: AuthContext | null,
): Promise<RoleSaveResult> {
return this.roles.create(input, requireAuth(auth).userId);
}
@Patch(':id')
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.update')
@ApiOperation({
summary: '更新角色 / 启停',
description: '系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示',
})
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateRoleInputSchema)) input: UpdateRoleInput,
@CurrentUser() auth: AuthContext | null,
): Promise<RoleSaveResult> {
return this.roles.update(id, input, requireAuth(auth).userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.delete')
@ApiOperation({ summary: '删除角色', description: '系统角色拒绝;有绑定用户(40903)只能停用' })
async remove(@Param('id') id: string, @CurrentUser() auth: AuthContext | null): Promise<void> {
await this.roles.remove(id, requireAuth(auth).userId);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,328 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { RolesService } from './roles.service';
const NOW = new Date('2026-11-03T02:00:00.000Z');
function roleFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'r1',
code: 'copywriter',
name: '文案策划',
description: '脚本撰写',
isSystem: false,
status: 'ACTIVE',
createdAt: NOW,
updatedAt: NOW,
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'script:write' }],
dataScopes: [{ scopeType: 'department', targetId: null }],
users: [
{
createdAt: NOW,
user: {
id: 'u1',
name: '周岸',
account: 'zhou.an',
phone: '13755205520',
status: 'ACTIVE',
department: { name: '素材二组' },
},
},
{
createdAt: NOW,
user: {
id: 'u2',
name: '林清',
account: 'lin.qing',
phone: '13912347712',
status: 'DISABLED',
department: { name: '设计组' },
},
},
],
_count: { users: 2 },
...overrides,
};
}
interface Harness {
service: RolesService;
prisma: {
role: {
findFirst: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
user: { count: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
tx: {
role: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
rolePermission: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
dataScope: { deleteMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn> };
outboxEvent: { create: ReturnType<typeof vi.fn> };
};
};
requestContext: { get: ReturnType<typeof vi.fn>; patchAudit: ReturnType<typeof vi.fn> };
}
function harness(): Harness {
const tx = {
role: { create: vi.fn().mockResolvedValue({}), update: vi.fn(), delete: vi.fn() },
rolePermission: { deleteMany: vi.fn(), createMany: vi.fn() },
dataScope: { deleteMany: vi.fn(), createMany: vi.fn() },
outboxEvent: { create: vi.fn().mockResolvedValue({}) },
};
const prisma = {
role: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
count: vi.fn().mockResolvedValue(9),
},
user: { count: vi.fn().mockResolvedValue(2) },
$transaction: vi.fn(async (callback: unknown) => {
if (typeof callback === 'function') {
return (callback as (client: typeof tx) => Promise<unknown>)(tx);
}
return Promise.all(callback as Promise<unknown>[]);
}),
tx,
};
const requestContext = {
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
patchAudit: vi.fn(),
};
return {
service: new RolesService(
prisma as unknown as PrismaService,
requestContext as unknown as RequestContextService,
),
prisma,
requestContext,
};
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('RolesService.create', () => {
it('默认全空:code 自动生成、权限点为空、写审计与 outbox', async () => {
const { service, prisma, requestContext } = harness();
prisma.role.findUnique.mockImplementation((args: { where: { id: string } }) =>
Promise.resolve(
roleFixture({ id: args.where.id, code: 'custom_x', permissions: [], users: [] }),
),
);
const result = await service.create(
{ name: '新运营组', dataScope: { type: 'created_by_me' } },
'actor-1',
);
const createdArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { data: { code: string } };
expect(createdArgs.data.code).toMatch(/^custom_[0-9a-z]{26}$/);
expect(result.role.permissions).toEqual([]);
expect(result.affectedUserCount).toBe(0);
expect(result.warnings).toEqual([]);
const createArgs = prisma.tx.role.create.mock.calls[0]?.[0] as {
data: { code: string; permissions: { create: unknown[] } };
};
expect(createArgs.data.permissions.create).toEqual([]);
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
data: { eventType: string; payloadJson: { action: string; roleId: string } };
};
expect(outboxArgs.data.eventType).toBe('role.permissions.changed');
expect(outboxArgs.data.payloadJson.action).toBe('created');
expect(requestContext.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({ targetType: 'role', affectedUserCount: 0 }),
);
});
it('角色名称重复返回 40902', async () => {
const { service, prisma } = harness();
prisma.role.findFirst.mockResolvedValueOnce({ id: 'other' });
await expectRejected(
service.create({ name: '文案策划', dataScope: { type: 'all' } }, 'actor-1'),
ErrorCode.DUPLICATE_SUBMIT,
);
});
it('SoD 命中返回 40304 并给出冲突对', async () => {
const { service } = harness();
let thrown: unknown;
try {
await service.create(
{
name: '冲突角色',
permissions: ['script:write', 'script:review'],
dataScope: { type: 'department' },
},
'actor-1',
);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AppException);
expect((thrown as AppException).code).toBe(ErrorCode.SOD_VIOLATION);
expect((thrown as AppException).details).toMatchObject({
conflicts: [{ pairId: 'script-write-review' }],
});
});
it('特殊权限混入 permissions 返回 40001', async () => {
const { service } = harness();
await expectRejected(
service.create(
{
name: '导出角色',
permissions: ['delivery:export'],
dataScope: { type: 'all' },
},
'actor-1',
),
ErrorCode.PARAM_INVALID,
);
});
});
describe('RolesService.update', () => {
it('系统角色仅允许改显示名:改权限 / 状态返回 40903,改名放行', async () => {
const { service, prisma } = harness();
const systemRole = roleFixture({ code: 'super_admin', isSystem: true });
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
await expectRejected(
service.update('r1', { permissions: ['user:view'] }, 'actor-1'),
ErrorCode.STATE_NOT_ALLOWED,
);
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
await expectRejected(
service.update('r1', { status: 'disabled' }, 'actor-1'),
ErrorCode.STATE_NOT_ALLOWED,
);
prisma.role.findUnique
.mockResolvedValueOnce(systemRole)
.mockResolvedValueOnce(
roleFixture({ code: 'super_admin', isSystem: true, name: '新超管名' }),
);
const result = await service.update('r1', { name: '新超管名' }, 'actor-1');
expect(result.role.name).toBe('新超管名');
});
it('更新权限:返回受影响用户数、outbox 携带 diff、审计含前后值', async () => {
const { service, prisma, requestContext } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
roleFixture({
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'user:view' }],
}),
);
const result = await service.update(
'r1',
{ permissions: ['script:read', 'user:view'], status: 'disabled', reason: '岗位合并' },
'actor-1',
);
expect(result.affectedUserCount).toBe(2);
expect(result.role.specialPermissions).toEqual([]);
const updateArgs = prisma.tx.role.update.mock.calls[0]?.[0] as { data: { status: string } };
expect(updateArgs.data.status).toBe('DISABLED');
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
data: {
payloadJson: {
changes: { permissions: { added: string[]; removed: string[] } };
affectedUserIds: string[];
};
};
};
expect(outboxArgs.data.payloadJson.changes.permissions.added).toEqual(['user:view']);
expect(outboxArgs.data.payloadJson.changes.permissions.removed).toEqual(['script:write']);
expect(outboxArgs.data.payloadJson.affectedUserIds).toEqual(['u1', 'u2']);
expect(requestContext.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({
affectedUserCount: 2,
affectedUserIds: ['u1', 'u2'],
reason: '岗位合并',
}),
);
});
it('更新数据范围:替换 data_scopes 行;customer_groups 多个客户组写多行', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
roleFixture({
dataScopes: [
{ scopeType: 'customer_groups', targetId: 'g1' },
{ scopeType: 'customer_groups', targetId: 'g2' },
],
}),
);
const groups = ['01HZZZZZZZZZZZZZZZZZZZZZZ1', '01HZZZZZZZZZZZZZZZZZZZZZZ2'];
const result = await service.update(
'r1',
{ dataScope: { type: 'customer_groups', targetIds: groups } },
'actor-1',
);
expect(result.role.dataScope).toEqual({ type: 'customer_groups', targetIds: ['g1', 'g2'] });
const createManyArgs = prisma.tx.dataScope.createMany.mock.calls[0]?.[0] as {
data: { roleId: string; scopeType: string; targetId: string }[];
};
expect(createManyArgs.data).toHaveLength(2);
expect(createManyArgs.data[0]?.scopeType).toBe('customer_groups');
});
});
describe('RolesService.remove', () => {
it('系统角色不可删除(40903)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ isSystem: true }));
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
});
it('有绑定用户只能停用(40903,附 userCount)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture());
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
});
it('无绑定用户删除成功并写 outbox(先事件后删除)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ users: [], _count: { users: 0 } }));
await service.remove('r1', 'actor-1');
expect(prisma.tx.role.delete).toHaveBeenCalledWith({ where: { id: 'r1' } });
expect(prisma.tx.outboxEvent.create).toHaveBeenCalledTimes(1);
});
});
describe('RolesService.detail', () => {
it('角色不存在返回 40401', async () => {
const { service } = harness();
await expectRejected(service.detail('missing'), ErrorCode.NOT_FOUND);
});
it('超管详情隐式展示全部权限点(30)与 all 范围', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(
roleFixture({ code: 'super_admin', isSystem: true, permissions: [], dataScopes: [] }),
);
const detail = await service.detail('r1');
expect(detail.permissions).toHaveLength(24);
expect(detail.specialPermissions).toHaveLength(6);
expect(detail.dataScope).toEqual({ type: 'all' });
});
});
+738 -9
View File
@@ -1,20 +1,749 @@
import { type RoleSummary } from '@lingxi/shared';
import {
type CreateRoleInput,
DATA_SCOPE_TYPES,
type DataScope,
type DataScopeType,
ErrorCode,
findSodConflicts,
isPermissionCode,
isSpecialPermission,
type ListResponse,
PERMISSION_CODES,
type PermissionCode,
ROLE_SOFT_LIMIT,
type RoleBoundUser,
type RoleDetail,
type RoleListItem,
type RoleListQuery,
type RoleSaveResult,
splitPermissions,
SUPER_ADMIN_SOFT_LIMIT,
ulid,
type UpdateRoleInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { toRoleSummary } from './user.mapper';
import { iso, maskPhone } from './user.mapper';
/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */
const ROLE_LIST_INCLUDE = {
permissions: { select: { permissionCode: true } },
dataScopes: { select: { scopeType: true, targetId: true } },
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
const ROLE_DETAIL_INCLUDE = {
permissions: { select: { permissionCode: true } },
dataScopes: { select: { scopeType: true, targetId: true } },
users: {
select: {
createdAt: true,
user: {
select: {
id: true,
name: true,
account: true,
phone: true,
status: true,
department: { select: { name: true } },
},
},
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
type RoleWithList = Prisma.RoleGetPayload<{ include: typeof ROLE_LIST_INCLUDE }>;
type RoleWithDetail = Prisma.RoleGetPayload<{ include: typeof ROLE_DETAIL_INCLUDE }>;
interface RolePermissionSnapshot {
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
dataScope: DataScope;
status: 'active' | 'disabled';
}
interface PermissionDiff {
added: PermissionCode[];
removed: PermissionCode[];
specialAdded: PermissionCode[];
specialRemoved: PermissionCode[];
}
interface CreatePersistArgs {
id: string;
code: string;
input: CreateRoleInput;
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
diff: PermissionDiff;
actorId: string;
traceId: string | null;
}
interface UpdatePersistArgs {
id: string;
existing: RoleWithDetail;
input: UpdateRoleInput;
after: RolePermissionSnapshot;
diff: PermissionDiff;
affectedUserIds: string[];
nextName: string;
nextDescription: string;
beforeDataScopeType: string;
actorId: string;
traceId: string | null;
}
interface OutboxEventArgs {
action: 'created' | 'updated' | 'deleted';
roleId: string;
roleCode: string;
roleName: string;
actorId: string;
traceId: string | null;
diff: PermissionDiff;
affectedUserIds: string[];
dataScopeBefore: string | null;
dataScopeAfter: string | null;
}
function splitCodes(codes: readonly PermissionCode[]): {
normal: PermissionCode[];
special: PermissionCode[];
} {
const normal: PermissionCode[] = [];
const special: PermissionCode[] = [];
for (const code of codes) {
if (isSpecialPermission(code)) {
special.push(code);
} else {
normal.push(code);
}
}
return { normal: normal.sort(), special: special.sort() };
}
function effectiveCodes(role: {
isSystem: boolean;
permissions: { permissionCode: string }[];
}): PermissionCode[] {
if (role.isSystem) {
return [...PERMISSION_CODES];
}
return role.permissions
.map((item) => item.permissionCode)
.filter(isPermissionCode)
.sort();
}
/** 一个角色只允许一种范围类型(A2 契约):多行(customer_groups 多客户组)聚合为单值 */
function aggregateDataScope(
rows: readonly { scopeType: string; targetId: string | null }[],
isSystem: boolean,
): DataScope {
if (isSystem) {
return { type: 'all' };
}
if (rows.length === 0) {
return { type: 'created_by_me' };
}
const ordered = [...rows].sort(
(left, right) =>
DATA_SCOPE_TYPES.indexOf(left.scopeType as DataScopeType) -
DATA_SCOPE_TYPES.indexOf(right.scopeType as DataScopeType),
);
const type = (ordered[0]?.scopeType ?? 'created_by_me') as DataScopeType;
if (type !== 'customer_groups') {
return { type };
}
const targetIds = [
...new Set(
rows
.filter((row) => row.scopeType === type)
.flatMap((row) => (row.targetId === null ? [] : [row.targetId])),
),
].sort();
return targetIds.length > 0 ? { type, targetIds } : { type };
}
function toBoundUser(row: RoleWithDetail['users'][number]): RoleBoundUser {
return {
id: row.user.id,
name: row.user.name,
account: row.user.account,
phoneMasked: maskPhone(row.user.phone),
departmentName: row.user.department.name,
status: row.user.status === 'ACTIVE' ? 'active' : 'disabled',
};
}
function toListItem(role: RoleWithList): RoleListItem {
const codes = splitCodes(effectiveCodes(role));
return {
id: role.id,
code: role.code,
name: role.name,
description: role.description,
isSystem: role.isSystem,
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
permissionCount: codes.normal.length,
specialPermissionCount: codes.special.length,
userCount: role._count.users,
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
createdAt: iso(role.createdAt),
updatedAt: iso(role.updatedAt),
};
}
function toDetail(role: RoleWithDetail): RoleDetail {
const listItem = toListItem(role);
const codes = splitCodes(effectiveCodes(role));
return {
...listItem,
permissions: codes.normal,
specialPermissions: codes.special,
boundUsers: role.users.map((row) => toBoundUser(row)),
};
}
function diffCodes(before: RolePermissionSnapshot, after: RolePermissionSnapshot): PermissionDiff {
const beforeNormal = new Set(before.permissions);
const afterNormal = new Set(after.permissions);
const beforeSpecial = new Set(before.specialPermissions);
const afterSpecial = new Set(after.specialPermissions);
return {
added: after.permissions.filter((code) => !beforeNormal.has(code)).sort(),
removed: before.permissions.filter((code) => !afterNormal.has(code)).sort(),
specialAdded: after.specialPermissions.filter((code) => !beforeSpecial.has(code)).sort(),
specialRemoved: before.specialPermissions.filter((code) => !afterSpecial.has(code)).sort(),
};
}
/**
* 角色管理(A2-BE-01 / 03 / 05):
* - 列表 / 详情(目录统计 + 数据范围 + 绑定用户);
* - 新建(权限默认全空、code 自动生成、SoD 保存即校验);
* - 更新(含启用 / 停用;系统角色仅显示名可改);
* - 删除(系统角色拒绝;有绑定用户 40903);
* - 变更审计(前后值 diff + 影响用户数)与 outbox `role.permissions.changed` 占位通知。
*/
@Injectable()
export class RolesService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(): Promise<RoleSummary[]> {
const roles = await this.prisma.role.findMany({
where: { status: 'ACTIVE' },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
const where: Prisma.RoleWhereInput = {};
if (query.status !== 'all') {
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
}
if (query.keyword !== undefined && query.keyword.length > 0) {
where.OR = [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ code: { contains: query.keyword, mode: 'insensitive' } },
];
}
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
const orderBy: Prisma.RoleOrderByWithRelationInput[] =
query.sort === 'name'
? [{ name: direction }, { id: 'asc' }]
: [{ createdAt: direction }, { id: 'asc' }];
const rows = await this.prisma.role.findMany({
where,
orderBy,
take: query.limit + 1,
include: ROLE_LIST_INCLUDE,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
return roles.map((role) => toRoleSummary(role));
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return { items: page.map((row) => toListItem(row)), next_cursor: next };
}
async detail(id: string): Promise<RoleDetail> {
const role = await this.prisma.role.findUnique({
where: { id },
include: ROLE_DETAIL_INCLUDE,
});
if (role === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
return toDetail(role);
}
async create(input: CreateRoleInput, actorId: string): Promise<RoleSaveResult> {
await this.ensureNameAvailable(input.name);
const { permissions, specialPermissions } = this.validatePermissions(
input.permissions ?? [],
input.specialPermissions ?? [],
);
this.assertSod({
permissions,
specialPermissions,
dataScope: input.dataScope,
status: 'active',
});
const id = ulid();
const traceId = this.requestContext.get()?.traceId ?? null;
const diff: PermissionDiff = {
added: permissions,
removed: [],
specialAdded: specialPermissions,
specialRemoved: [],
};
const code = `custom_${ulid().toLowerCase()}`;
await this.prisma.$transaction((tx) =>
this.persistCreate(tx, {
id,
code,
input,
permissions,
specialPermissions,
diff,
actorId,
traceId,
}),
);
const role = await this.detail(id);
this.recordCreateAudit(role, diff);
return { role, affectedUserCount: 0, warnings: await this.buildWarnings(role.code) };
}
async update(id: string, input: UpdateRoleInput, actorId: string): Promise<RoleSaveResult> {
const existing = await this.prisma.role.findUnique({
where: { id },
include: ROLE_DETAIL_INCLUDE,
});
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
this.assertSystemRoleMutable(existing, input);
if (input.name !== undefined && input.name !== existing.name) {
await this.ensureNameAvailable(input.name, id);
}
const before = this.snapshotOf(existing);
const after = this.mergeSnapshot(before, input);
// 超管豁免 SoD(系统角色不可改权限,隐式全集必然「违反」互斥对)
if (!existing.isSystem) {
this.assertSod(after);
}
const diff = diffCodes(before, after);
const affectedUserIds = existing.users.map((row) => row.user.id);
await this.prisma.$transaction((tx) =>
this.persistUpdate(tx, {
id,
existing,
input,
after,
diff,
affectedUserIds,
nextName: input.name ?? existing.name,
nextDescription: input.description ?? existing.description,
beforeDataScopeType: before.dataScope.type,
actorId,
traceId: this.requestContext.get()?.traceId ?? null,
}),
);
const role = await this.detail(id);
this.recordUpdateAudit({ existing, before, role, affectedUserIds, diff, input });
return {
role,
affectedUserCount: affectedUserIds.length,
warnings: await this.buildWarnings(role.code),
};
}
async remove(id: string, actorId: string): Promise<void> {
const existing = await this.prisma.role.findUnique({
where: { id },
include: { _count: { select: { users: true } } },
});
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
if (existing.isSystem) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '系统角色不可删除' });
}
if (existing._count.users > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: `角色已绑定 ${String(existing._count.users)} 名用户,只能停用`,
details: { userCount: existing._count.users },
});
}
await this.prisma.$transaction((tx) =>
this.persistRemove(tx, {
id,
existing,
actorId,
traceId: this.requestContext.get()?.traceId ?? null,
}),
);
this.requestContext.patchAudit({
targetType: 'role',
targetId: id,
before: { name: existing.name, code: existing.code, status: existing.status },
after: null,
affectedUserCount: 0,
affectedUserIds: [],
});
}
private async persistCreate(
tx: Prisma.TransactionClient,
args: CreatePersistArgs,
): Promise<void> {
await tx.role.create({
data: {
id: args.id,
code: args.code,
name: args.input.name,
description: args.input.description ?? '',
isSystem: false,
status: 'ACTIVE',
permissions: {
create: [...args.permissions, ...args.specialPermissions].map((permissionCode) => ({
permissionCode,
})),
},
dataScopes: {
create: this.dataScopeRows(
args.input.dataScope.type,
args.input.dataScope.targetIds ?? [],
),
},
},
});
await this.writeOutboxEvent(tx, {
action: 'created',
roleId: args.id,
roleCode: args.code,
roleName: args.input.name,
actorId: args.actorId,
traceId: args.traceId,
diff: args.diff,
affectedUserIds: [],
dataScopeBefore: null,
dataScopeAfter: args.input.dataScope.type,
});
}
private recordCreateAudit(role: RoleDetail, diff: PermissionDiff): void {
this.requestContext.patchAudit({
targetType: 'role',
targetId: role.id,
before: null,
after: this.auditSnapshot(role),
affectedUserCount: 0,
affectedUserIds: [],
changes: diff,
});
}
private async persistUpdate(
tx: Prisma.TransactionClient,
args: UpdatePersistArgs,
): Promise<void> {
await this.applyRoleUpdate(tx, args);
await this.writeOutboxEvent(tx, {
action: 'updated',
roleId: args.id,
roleCode: args.existing.code,
roleName: args.nextName,
actorId: args.actorId,
traceId: args.traceId,
diff: args.diff,
affectedUserIds: args.affectedUserIds,
dataScopeBefore:
args.existing.dataScopes.length > 0
? (args.existing.dataScopes[0]?.scopeType ?? null)
: null,
dataScopeAfter: args.after.dataScope.type,
});
}
private async applyRoleUpdate(
tx: Prisma.TransactionClient,
args: UpdatePersistArgs,
): Promise<void> {
await tx.role.update({
where: { id: args.id },
data: {
name: args.nextName,
description: args.nextDescription,
status: args.after.status === 'active' ? 'ACTIVE' : 'DISABLED',
},
});
if (args.input.permissions !== undefined || args.input.specialPermissions !== undefined) {
await tx.rolePermission.deleteMany({ where: { roleId: args.id } });
await tx.rolePermission.createMany({
data: [...args.after.permissions, ...args.after.specialPermissions].map(
(permissionCode) => ({ roleId: args.id, permissionCode }),
),
});
}
if (args.input.dataScope !== undefined) {
await tx.dataScope.deleteMany({ where: { roleId: args.id } });
await tx.dataScope.createMany({
data: this.dataScopeRows(
args.input.dataScope.type,
args.input.dataScope.targetIds ?? [],
).map((row) => ({ ...row, roleId: args.id })),
});
}
}
private recordUpdateAudit(args: {
existing: RoleWithDetail;
before: RolePermissionSnapshot;
role: RoleDetail;
affectedUserIds: string[];
diff: PermissionDiff;
input: UpdateRoleInput;
}): void {
this.requestContext.patchAudit({
targetType: 'role',
targetId: args.role.id,
before: {
name: args.existing.name,
description: args.existing.description,
permissions: args.before.permissions,
specialPermissions: args.before.specialPermissions,
dataScope: args.before.dataScope,
status: args.before.status,
},
after: this.auditSnapshot(args.role),
affectedUserCount: args.affectedUserIds.length,
affectedUserIds: args.affectedUserIds,
changes: args.diff,
...(args.input.reason === undefined ? {} : { reason: args.input.reason }),
});
}
private async persistRemove(
tx: Prisma.TransactionClient,
args: {
id: string;
existing: { code: string; name: string };
actorId: string;
traceId: string | null;
},
): Promise<void> {
await this.writeOutboxEvent(tx, {
action: 'deleted',
roleId: args.id,
roleCode: args.existing.code,
roleName: args.existing.name,
actorId: args.actorId,
traceId: args.traceId,
diff: { added: [], removed: [], specialAdded: [], specialRemoved: [] },
affectedUserIds: [],
dataScopeBefore: null,
dataScopeAfter: null,
});
await tx.role.delete({ where: { id: args.id } });
}
private async writeOutboxEvent(
tx: Prisma.TransactionClient,
args: OutboxEventArgs,
): Promise<void> {
await tx.outboxEvent.create({
data: {
id: ulid(),
aggregateType: 'role',
aggregateId: args.roleId,
eventType: 'role.permissions.changed',
payloadJson: this.eventPayload(args),
...(args.traceId === null ? {} : { traceId: args.traceId }),
},
});
}
private mergeSnapshot(
before: RolePermissionSnapshot,
input: UpdateRoleInput,
): RolePermissionSnapshot {
return {
permissions:
input.permissions === undefined
? before.permissions
: this.validatePermissions(input.permissions, []).permissions,
specialPermissions:
input.specialPermissions === undefined
? before.specialPermissions
: this.validatePermissions([], input.specialPermissions).specialPermissions,
dataScope: input.dataScope ?? before.dataScope,
status:
input.status === undefined
? before.status
: input.status === 'enabled'
? 'active'
: 'disabled',
};
}
private auditSnapshot(role: RoleDetail): Record<string, unknown> {
return {
name: role.name,
permissions: role.permissions,
specialPermissions: role.specialPermissions,
dataScope: role.dataScope,
status: role.status,
};
}
private snapshotOf(role: RoleWithDetail): RolePermissionSnapshot {
const codes = splitCodes(effectiveCodes(role));
return {
permissions: codes.normal,
specialPermissions: codes.special,
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
};
}
private assertSystemRoleMutable(role: { isSystem: boolean }, input: UpdateRoleInput): void {
if (!role.isSystem) {
return;
}
const touchesProtected =
input.description !== undefined ||
input.permissions !== undefined ||
input.specialPermissions !== undefined ||
input.dataScope !== undefined ||
input.status !== undefined;
if (touchesProtected) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '系统角色不可修改权限 / 数据范围 / 状态,仅显示名可改',
});
}
}
private validatePermissions(
normalInput: readonly string[],
specialInput: readonly string[],
): { permissions: PermissionCode[]; specialPermissions: PermissionCode[] } {
let parsedNormal: PermissionCode[];
let parsedSpecial: PermissionCode[];
try {
const combined = splitPermissions([...normalInput, ...specialInput]);
parsedNormal = combined.permissions;
parsedSpecial = combined.specialPermissions;
} catch {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在未登记的权限点编码' });
}
const normalSet = new Set<string>(parsedNormal);
for (const code of normalInput) {
if (!normalSet.has(code)) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`,
});
}
}
const specialSet = new Set<string>(parsedSpecial);
for (const code of specialInput) {
if (!specialSet.has(code)) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `普通权限 ${code} 不能放入 specialPermissions`,
});
}
}
return { permissions: parsedNormal, specialPermissions: parsedSpecial };
}
private assertSod(snapshot: RolePermissionSnapshot): void {
const conflicts = findSodConflicts([...snapshot.permissions, ...snapshot.specialPermissions]);
if (conflicts.length > 0) {
throw new AppException({
code: ErrorCode.SOD_VIOLATION,
message: `违反职责互斥:${conflicts.map((conflict) => conflict.label).join(';')}`,
details: { conflicts },
});
}
}
private dataScopeRows(
type: DataScopeType,
targetIds: readonly string[],
): { id: string; scopeType: string; targetId: string | null }[] {
if (type !== 'customer_groups' || targetIds.length === 0) {
return [{ id: ulid(), scopeType: type, targetId: null }];
}
return targetIds.map((targetId) => ({ id: ulid(), scopeType: type, targetId }));
}
private async ensureNameAvailable(name: string, excludeId?: string): Promise<void> {
const existing = await this.prisma.role.findFirst({
where: { name, ...(excludeId === undefined ? {} : { NOT: { id: excludeId } }) },
select: { id: true },
});
if (existing !== null) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '角色名称已存在' });
}
}
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
private async buildWarnings(roleCode: string): Promise<string[]> {
const warnings: string[] = [];
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
if (activeRoleCount > ROLE_SOFT_LIMIT) {
warnings.push(
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
);
}
if (roleCode === 'super_admin') {
const superAdminCount = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
});
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
warnings.push(
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
);
}
}
return warnings;
}
private eventPayload(input: {
action: 'created' | 'updated' | 'deleted';
roleId: string;
roleCode: string;
roleName: string;
actorId: string;
traceId: string | null;
diff: PermissionDiff;
affectedUserIds: string[];
dataScopeBefore: string | null;
dataScopeAfter: string | null;
}): Prisma.InputJsonObject {
return {
action: input.action,
roleId: input.roleId,
roleCode: input.roleCode,
roleName: input.roleName,
actorId: input.actorId,
traceId: input.traceId,
changes: {
permissions: { added: input.diff.added, removed: input.diff.removed },
specialPermissions: {
added: input.diff.specialAdded,
removed: input.diff.specialRemoved,
},
dataScope: { before: input.dataScopeBefore, after: input.dataScopeAfter },
},
affectedUserIds: input.affectedUserIds,
};
}
}
+30 -1
View File
@@ -4,13 +4,14 @@ import {
ErrorCode,
type ListResponse,
Permission,
SUPER_ADMIN_SOFT_LIMIT,
ulid,
type UpdateUserInput,
type UserDetail,
type UserListQuery,
type UserSummary,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
@@ -31,6 +32,8 @@ function hasPermission(permissions: string[], code: string): boolean {
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
@Injectable()
export class UsersService {
private readonly logger = new Logger('UsersService');
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(PasswordService) private readonly passwords: PasswordService,
@@ -90,6 +93,7 @@ export class UsersService {
} catch (error) {
throw this.mapUniqueError(error);
}
await this.warnIfSuperAdminExceeded(input.roleIds);
return this.detail(id, permissions);
}
@@ -125,6 +129,9 @@ export class UsersService {
} catch (error) {
throw this.mapUniqueError(error);
}
if (input.roleIds !== undefined) {
await this.warnIfSuperAdminExceeded(input.roleIds);
}
return this.detail(id, permissions);
}
@@ -181,6 +188,28 @@ export class UsersService {
});
}
/**
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
*/
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
const superAdminRole = await this.prisma.role.findUnique({
where: { code: 'super_admin' },
select: { id: true },
});
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
return;
}
const count = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
});
if (count > SUPER_ADMIN_SOFT_LIMIT) {
this.logger.warn(
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
);
}
}
private async findOrThrow(id: string): Promise<UserWithRelations> {
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
if (user === null) {
+2
View File
@@ -20,4 +20,6 @@ export const ENDPOINTS = {
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
departments: '/departments/tree',
roles: '/roles',
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
permissions: '/permissions',
} as const;
+139 -2
View File
@@ -85,7 +85,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** 当前用户、角色、权限点、数据范围 */
/** 当前用户、角色、权限点、数据范围(多角色并集) */
get: operations['MeController_current'];
put?: never;
post?: never;
@@ -258,9 +258,61 @@ export interface paths {
path?: never;
cookie?: never;
};
/** 角色列表(绑定用) */
/**
* 角色列表(游标分页 + 筛选 + 排序)
* @description 默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围
*/
get: operations['RolesController_list'];
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;
delete?: 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: {
parameters: {
query?: never;
+1 -1
View File
@@ -42,7 +42,7 @@ const me = {
user: userSummary,
roles: userSummary.roles,
permissions: ['user:view'],
dataScope: { type: 'all' },
dataScopes: [{ type: 'all' }],
mustChangePassword: false,
};
+4
View File
@@ -1,3 +1,4 @@
import type { RoleListQueryInput } from './roles';
import type { UserListQueryInput } from './users';
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
@@ -9,4 +10,7 @@ export const queryKeys = {
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
departments: ['departments'] 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 { Link } from 'react-router';
import { getUserSectionTrail } from '@/navigation/breadcrumbs';
import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps {
@@ -9,7 +9,10 @@ export interface PageBreadcrumbsProps {
}
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 (
<nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => {
+2
View File
@@ -6,6 +6,8 @@ import '@/styles/global.css';
import '@/styles/layout.css';
import '@/styles/pages.css';
import '@/styles/users.css';
import '@/styles/roles.css';
import '@/styles/role-editor.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
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';
/** 角色模块下的子路由(新建 / 编辑器)不在 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 的菜单项里,
* 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。
@@ -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('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material');
+1 -1
View File
@@ -259,7 +259,7 @@ export const NAV_MODULES: readonly NavModule[] = [
label: '角色权限',
path: '/system/roles',
icon: IconShield,
permission: Permission.ROLE_MANAGE,
permission: Permission.ROLE_VIEW,
description: '角色、权限点与职责互斥',
},
{
@@ -43,7 +43,7 @@ function meWith(mustChangePassword: boolean): MeResponse {
},
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' },
dataScopes: [{ type: 'all' }],
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 };
});
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 {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
}
@@ -82,6 +97,30 @@ export const router = createBrowserRouter([
</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?',
lazy: async () => ({
+21 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes';
import {
loginPath,
roleDetailPath,
roleNewPath,
ROUTES,
sanitizeRedirect,
userDetailPath,
} from './routes';
describe('路由常量与登录回跳', () => {
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 只接受站内路径', () => {
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
+18
View File
@@ -12,6 +12,9 @@ export const ROUTES = {
users: '/system/users',
userNew: '/system/users/new',
userDetail: '/system/users/:id',
roles: '/system/roles',
roleNew: '/system/roles/new',
roleDetail: '/system/roles/:id',
},
unauthorized: '/401',
forbidden: '/403',
@@ -26,6 +29,9 @@ export type RoutePath =
| typeof ROUTES.system.users
| typeof ROUTES.system.userNew
| typeof ROUTES.system.userDetail
| typeof ROUTES.system.roles
| typeof ROUTES.system.roleNew
| typeof ROUTES.system.roleDetail
| typeof ROUTES.unauthorized
| typeof ROUTES.forbidden
| typeof ROUTES.notFound
@@ -36,6 +42,18 @@ export function userDetailPath(userId: string): string {
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,避免开放重定向。
+1 -1
View File
@@ -24,7 +24,7 @@ const me: MeResponse = {
},
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' },
dataScopes: [{ type: 'all' }],
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(初稿,待评审) |
| 日期 | 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)) |
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
@@ -379,7 +379,7 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- | --- |
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
| **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 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
| **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | 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` 为准。
>
> **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 批次依赖图
@@ -561,6 +563,8 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 |
| 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)。
### 6.4 A3 客户与行业目录
+30 -4
View File
@@ -2,10 +2,11 @@
## 现有用例
| 文件 | 说明 | 默认是否执行 |
| --------------------- | ---------------------------------- | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| 文件 | 说明 | 默认是否执行 |
| --------------------- | --------------------------------------------- | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
## 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 行为不变。
- 若在 `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": []
}
],
"summary": "当前用户、角色、权限点、数据范围",
"summary": "当前用户、角色、权限点、数据范围(多角色并集)",
"tags": [
"identity-me"
]
@@ -287,6 +287,7 @@
},
"/api/v1/roles": {
"get": {
"description": "默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围",
"operationId": "RolesController_list",
"parameters": [],
"responses": {
@@ -299,10 +300,108 @@
"bearer": []
}
],
"summary": "角色列表(绑定用)",
"summary": "角色列表(游标分页 + 筛选 + 排序)",
"tags": [
"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": {
+1
View File
@@ -6,5 +6,6 @@ export * from './permissions.js';
export * from './schemas/api.js';
export * from './schemas/common.js';
export * from './schemas/identity.js';
export * from './schemas/roles.js';
export * from './time.js';
export * from './ulid.js';
+188 -5
View File
@@ -1,14 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
findSodConflicts,
isPermissionCode,
isSpecialPermission,
parsePermission,
Permission,
PERMISSION_CATALOG,
PERMISSION_CODES,
PERMISSION_MODULES,
permissionMetaOf,
permissionsByModule,
ROLE_SOFT_LIMIT,
SOD_PAIRS,
SPECIAL_PERMISSIONS,
splitPermissions,
SUPER_ADMIN_SOFT_LIMIT,
} 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('全部符合 模块:动作 命名且模块为小写', () => {
for (const code of PERMISSION_CODES) {
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) {
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 只认可登记值', () => {
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true);
expect(isPermissionCode('script:delete_all')).toBe(false);
it('同时拥有互斥两侧即命中;只拥有单侧不命中', () => {
expect(findSodConflicts(['script:write', 'script:review'])).toEqual([
{
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();
});
});
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)
* 命名规范:<模块>:<动作>,模块取后端八模块语义(identity / client / catalog / industry /
* script / media / delivery / platform),全小写;动作使用动词或名词短语。
* A1/A2 接入真实角色-权限表后,这里只增不改;删除/改名需评审(AGENTS §10)。
* 权限点目录(A0-SH-01 骨架 → A2 定稿,口径见 H1 权限门)
*
* 命名规范:<模块>:<动作>,全小写;权限树一级模块(script / material / asset / delivery /
* 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 = {
// 脚本
SCRIPT_READ: 'script:read',
@@ -17,20 +49,27 @@ export const Permission = {
// 资产
ASSET_READ: 'asset:read',
ASSET_WRITE: 'asset:write',
// 导出交付
ASSET_DELETE: 'asset:delete',
// 交付导出
DELIVERY_READ: 'delivery:read',
DELIVERY_EXPORT: 'delivery:export',
// 客户
CUSTOMER_READ: 'customer:read',
CUSTOMER_MANAGE: 'customer:manage',
CUSTOMER_TRANSFER: 'customer:transfer',
CUSTOMER_FIELD_APPLY: 'customer_field:apply',
CUSTOMER_FIELD_APPROVE: 'customer_field:approve',
// 客户组
CUSTOMER_GROUP_MANAGE: 'customer_group:manage',
// 组织与角色
USER_VIEW: 'user:view',
USER_MANAGE: 'user:manage',
ROLE_VIEW: 'role:view',
ROLE_MANAGE: 'role:manage',
// 平台配置
// 行业包
INDUSTRY_MANAGE: 'industry:manage',
INDUSTRY_PUBLISH: 'industry:publish',
// 平台配置
DICT_MANAGE: 'dict:manage',
FORM_DESIGN: 'form:design',
AI_CONFIGURE: 'ai:configure',
@@ -42,23 +81,342 @@ export const 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[] = [
Permission.DELIVERY_EXPORT,
Permission.AI_CONFIGURE,
Permission.AUDIT_EXPORT,
Permission.INDUSTRY_PUBLISH,
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 {
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 {
module: string;
action: string;
@@ -77,3 +435,32 @@ export function parsePermission(code: string): ParsedPermission | null {
}
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() },
roles: [makeRole()],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'department_and_children' },
dataScopes: [{ type: 'department_and_children' }],
mustChangePassword: false,
};
}
@@ -530,45 +530,56 @@ describe('认证 schema', () => {
});
describe('dataScopeSchema / meResponseSchema', () => {
it('dataScopeSchema 接受六类范围并允许可选 customerIds', () => {
it('dataScopeSchema 接受六类范围,并允许可选 targetIds / customerIds', () => {
for (const type of DATA_SCOPE_TYPES) {
expect(dataScopeSchema.parse({ type }).type).toBe(type);
}
const withCustomers = dataScopeSchema.parse({
type: 'customer_groups',
targetIds: [ulid()],
customerIds: [ulid()],
});
expect(withCustomers.targetIds).toHaveLength(1);
expect(withCustomers.customerIds).toHaveLength(1);
});
it('dataScopeSchema 拒绝未知范围类型与非法客户 ID', () => {
expect(() => dataScopeSchema.parse({ type: 'own' })).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();
me.user = { ...makeSummary(), phone: '13800138000' };
me.dataScopes = [
{ type: 'owned_customers', customerIds: [] },
{ type: 'department', customerIds: [] },
];
const parsed = meResponseSchema.parse(me);
expect(parsed.user.phone).toBe('13800138000');
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);
});
it('meResponseSchema 允许 user 不含手机号、dataScope 不含 customerIds', () => {
const parsed = meResponseSchema.parse(makeMe());
expect(parsed.user.phone).toBeUndefined();
expect(parsed.dataScope.customerIds).toBeUndefined();
it('meResponseSchema 超管口径:dataScopes 为 [{ type: all }]', () => {
const parsed = meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'all' }] });
expect(parsed.dataScopes).toEqual([{ type: 'all' }]);
});
it('meResponseSchema 拒绝缺失 mustChangePassword / 非法权限点 / 非法数据范围', () => {
it('meResponseSchema 拒绝缺失 dataScopes / 空数组 / 非法权限点 / 非法数据范围', () => {
const { mustChangePassword: _must, ...withoutFlag } = makeMe();
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: [1] })).toThrow();
expect(() =>
meResponseSchema.parse({ ...makeMe(), dataScope: { type: 'everything' } }),
meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'everything' }] }),
).toThrow();
});
});
@@ -589,7 +600,7 @@ describe('loginResponseSchema', () => {
expect(() =>
loginResponseSchema.parse({
...makeLoginResponse(),
me: { ...makeMe(), dataScope: { type: 'unknown' } },
me: { ...makeMe(), dataScopes: [{ type: 'unknown' }] },
}),
).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>;
/** 数据范围解析结果:customerIds 仅「指定客户组 / 负责客户」等范围需要返回 */
/**
* 数据范围(A2-BE-04):
* - `type`:六类范围之一;
* - `targetIds`:角色配置时指定的授权对象(当前仅「指定客户组」用客户组 id,A3 前可为空);
* - `customerIds`:解析后的可见客户集合(由 CustomerDirectory 端口解析,A3 前为空数组)。
*
* 角色详情返回角色声明的范围(带 targetIds);`/me` 返回多角色并集解析结果(带 customerIds)。
*/
export const dataScopeSchema = z.object({
type: dataScopeTypeSchema,
targetIds: z.array(ulidSchema).optional(),
customerIds: z.array(ulidSchema).optional(),
});
export type DataScope = z.infer<typeof dataScopeSchema>;
/** GET /me 响应:当前用户、角色、权限点集合、数据范围、是否需强制改密 */
/**
* GET /me 响应:当前用户、角色、权限点集合、数据范围(多角色并集)、是否需强制改密。
* 超管的 dataScopes 固定为 `[{ type: 'all' }]`;其余角色按六类范围并集解析。
*/
export const meResponseSchema = z.object({
user: userSummarySchema.extend({
phone: phoneSchema.optional(),
}),
roles: z.array(roleSummarySchema),
permissions: z.array(z.string().min(1, '权限点不能为空')),
dataScope: dataScopeSchema,
dataScopes: z.array(dataScopeSchema).min(1, '至少返回一个数据范围'),
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>;
+24 -15
View File
@@ -1,16 +1,20 @@
# 种子数据状态(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`)
| 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 角色 8 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 超级管理员(`is_system=true`,全部权限点)、平台管理员、素材运营、设计师、文案策划、审核员、AI 工程师、客户负责人;权限点取值来自 `@lingxi/shared` `PERMISSION_CODES`(本文件按字符串固化) |
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
| 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 权限点目录 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 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
## 登录账号(本地联调)
@@ -33,16 +37,21 @@ A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织
## 使用
```bash
pnpm db:migrate # 应用 a1_identity 迁移(expand)
pnpm db:seed # 幂等写入上述种子(可重复执行)
pnpm db:migrate # 应用 a2_role_permissions_data_scopes 迁移(expand:permissions / data_scopes 两张新表)
pnpm db: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)提供`),种子不含客户。
- 审计表属 A5,`@Audit` 当前写结构化日志;A5 落库后在此扩展日志 + 审计表双写。
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。
- 审计表属 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[]
permissions RolePermission[]
dataScopes DataScope[]
@@map("roles")
}
@@ -153,6 +154,41 @@ model RolePermission {
@@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
model RefreshToken {
id String @id @db.Char(26)
+194 -62
View File
@@ -1,15 +1,25 @@
// 灵犀 · 种子数据(A1)
// 灵犀 · 种子数据(A2)
// 原则:
// 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖;
// 2) 角色-权限点、用户-角色为种子可重建映射:先清后建(限定在种子角色 / 种子用户范围内);
// 3) 超管、部门、示例用户口径见 prisma/SEED.md。
// 2) 权限点目录、角色-权限点、角色-数据范围、用户-角色为种子可重建映射:先清后建
// (限定在种子角色 / 种子用户范围内;生产禁止直接跑 seed);
// 3) 权限点编码唯一事实源是 packages/shared/src/permissions.ts,本文件不复制编码字符串;
// 4) 超管、部门、示例角色 / 用户口径见 prisma/SEED.md。
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
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';
// 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移)
import {
findSodConflicts,
Permission,
PERMISSION_CATALOG,
splitPermissions,
} from '../packages/shared/src/permissions.js';
for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
@@ -144,42 +154,35 @@ interface SeedRole {
name: string;
description: string;
isSystem: boolean;
status?: RoleStatus;
/** 普通权限点(不得包含特殊权限;特殊权限必须放 specialPermissions 显式单独授予) */
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[] = [
{
code: 'super_admin',
name: '超级管理员',
description: '内置角色,拥有全部权限点',
description: '内置角色,拥有系统全部权限',
isSystem: true,
permissions: [
'script:read',
'script:write',
'script:review',
'material:read',
'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[],
permissions: PERMISSION_CATALOG.filter((item) => !item.isSpecial).map((item) => item.code),
specialPermissions: PERMISSION_CATALOG.filter((item) => item.isSpecial).map(
(item) => item.code,
),
dataScope: { type: 'all' },
},
{
code: 'platform_admin',
@@ -187,16 +190,21 @@ const ROLES: SeedRole[] = [
description: '组织、角色与平台配置管理',
isSystem: false,
permissions: [
'user:view',
'user:manage',
'role:manage',
'system:configure',
'audit:read',
'industry:manage',
'dict:manage',
'form:design',
'customer:read',
Permission.USER_VIEW,
Permission.USER_MANAGE,
Permission.ROLE_VIEW,
Permission.ROLE_MANAGE,
Permission.SYSTEM_CONFIGURE,
Permission.INDUSTRY_MANAGE,
Permission.DICT_MANAGE,
Permission.FORM_DESIGN,
Permission.CUSTOMER_READ,
Permission.CUSTOMER_GROUP_MANAGE,
Permission.DELIVERY_READ,
Permission.ASSET_READ,
],
specialPermissions: [Permission.AUDIT_READ],
dataScope: { type: 'all' },
},
{
code: 'material_operator',
@@ -204,50 +212,106 @@ const ROLES: SeedRole[] = [
description: '素材生产、快筛、导出与归档',
isSystem: false,
permissions: [
'script:read',
'script:write',
'material:read',
'material:write',
'material:render',
'asset:read',
'asset:write',
'delivery:read',
Permission.SCRIPT_READ,
Permission.SCRIPT_WRITE,
Permission.MATERIAL_READ,
Permission.MATERIAL_WRITE,
Permission.MATERIAL_RENDER,
Permission.ASSET_READ,
Permission.ASSET_WRITE,
Permission.DELIVERY_READ,
Permission.CUSTOMER_READ,
],
dataScope: { type: 'owned_customers' },
},
{
code: 'designer',
name: '设计师',
description: '素材设计与资产维护',
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',
name: '文案策划',
description: '脚本撰写与要素卡维护',
isSystem: false,
permissions: ['script:read', 'script:write'],
permissions: [Permission.SCRIPT_READ, Permission.SCRIPT_WRITE],
dataScope: { type: 'department' },
},
{
code: 'reviewer',
name: '审核员',
description: '脚本与素材审核',
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',
name: 'AI 工程师',
description: '模型接入与生成链路维护',
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',
name: '客户负责人',
description: '客户资料查看、脚本审核与交付验收',
description: '客户资料、脚本审核与交付验收',
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)} 个(幂等)`);
}
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> {
for (const item of ROLES) {
const role = await prisma.role.upsert({
@@ -412,16 +545,14 @@ async function seedRoles(): Promise<void> {
name: item.name,
description: item.description,
isSystem: item.isSystem,
status: 'ACTIVE',
status: item.status ?? 'ACTIVE',
},
});
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
await prisma.rolePermission.createMany({
data: item.permissions.map((permissionCode) => ({ roleId: role.id, permissionCode })),
skipDuplicates: true,
});
await seedRoleAccess(role.id, item);
}
console.log(`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点映射(幂等)`);
console.log(
`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点 / 数据范围映射(幂等,特殊权限按原型显式授予)`,
);
}
async function seedUsers(): Promise<void> {
@@ -478,6 +609,7 @@ async function seedDepartmentLeaders(): Promise<void> {
async function main(): Promise<void> {
await seedConfigs();
await seedPermissions();
await seedDepartments();
await seedRoles();
await seedUsers();