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

+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' });
}