feat(identity): 完成 A2 角色权限与数据范围全栈落地

背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
This commit is contained in:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

+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>;