From 64cf3415ab8883d6748d3a151f9abf6b319dbb9d Mon Sep 17 00:00:00 2001 From: "HeXiaoLong:Suanier" Date: Tue, 8 Jul 2025 22:22:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E6=8E=A5=E5=8F=A3=20(PUT=20/api/permission/s?= =?UTF-8?q?ort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增权限排序业务逻辑文档到 permission.docs.md - 定义权限排序请求参数 Schema (SortPermissionSchema) - 定义权限排序响应格式 Schema (SortPermissionResponsesSchema) - 实现权限排序业务逻辑 (sortPermissions 方法) - 添加权限排序路由控制器 功能特性: - 支持批量权限排序更新 - 验证权限同级别约束 - 验证排序值唯一性 - 使用分布式锁防止并发冲突 - 完整的错误处理和业务校验 接口路径: PUT /api/permission/sort --- .../permission/permission.controller.ts | 28 ++- src/modules/permission/permission.docs.md | 206 +++++++++++++++++- src/modules/permission/permission.response.ts | 105 ++++++++- src/modules/permission/permission.schema.ts | 47 +++- src/modules/permission/permission.service.ts | 132 ++++++++++- 5 files changed, 508 insertions(+), 10 deletions(-) diff --git a/src/modules/permission/permission.controller.ts b/src/modules/permission/permission.controller.ts index 7d992d4..d574b06 100644 --- a/src/modules/permission/permission.controller.ts +++ b/src/modules/permission/permission.controller.ts @@ -16,8 +16,8 @@ import { UpdatePermissionParamsSchema, UpdatePermissionBodySchema } from './perm import { UpdatePermissionResponsesSchema } from './permission.response'; import { permissionService } from './permission.service'; import { tags } from '@/constants/swaggerTags'; -import { GetPermissionTreeQuerySchema } from './permission.schema'; -import { GetPermissionTreeResponsesSchema } from './permission.response'; +import { GetPermissionTreeQuerySchema, SortPermissionSchema } from './permission.schema'; +import { GetPermissionTreeResponsesSchema, SortPermissionResponsesSchema } from './permission.response'; export const permissionController = new Elysia() /** @@ -84,11 +84,10 @@ export const permissionController = new Elysia() * @description 查询权限完整树,支持多条件筛选 */ .get( - '/tree/:pid', - async ({ query, params }) => permissionService.getPermissionTree(query, params.pid), + '/tree', + async ({ query }) => permissionService.getPermissionTree(query), { query: GetPermissionTreeQuerySchema, - params: GetPermissionTreeByPidParamsSchema, detail: { summary: '查看权限完整树', description: '查询权限完整树,支持module、status、type多条件筛选', @@ -97,4 +96,23 @@ export const permissionController = new Elysia() }, response: GetPermissionTreeResponsesSchema, } + ) + /** + * 权限排序接口 + * @route PUT /api/permission/sort + * @description 批量更新权限排序,支持同级权限的排序调整 + */ + .put( + '/sort', + async ({ body }) => permissionService.sortPermissions(body), + { + body: SortPermissionSchema, + detail: { + summary: '权限排序', + description: '批量更新权限排序,要求所有权限必须在同一级别,排序值不能重复', + tags: [tags.permission], + operationId: 'sortPermissions', + }, + response: SortPermissionResponsesSchema, + } ); \ No newline at end of file diff --git a/src/modules/permission/permission.docs.md b/src/modules/permission/permission.docs.md index 55ce085..8acd484 100644 --- a/src/modules/permission/permission.docs.md +++ b/src/modules/permission/permission.docs.md @@ -492,4 +492,208 @@ CREATE TABLE sys_permission ( - 预期:只返回菜单类型权限 5. **参数错误** - 输入:非法status/type/module - - 预期:返回400错误 \ No newline at end of file + - 预期:返回400错误 + +--- + +### 5. 权限排序接口 (PUT /api/permission/sort) + +#### 业务逻辑 + +1. **参数验证** + - 接收权限ID和排序值的数组,格式:`[{id: string, sort: string}]` + - 验证ID格式(bigint字符串) + - 验证sort格式(数字字符串) + - 验证数组不为空,最多处理100条记录 + - 验证所有权限ID的唯一性(数组内不能重复) + +2. **业务规则** + - 权限ID必须存在且状态为启用 + - 只能对同级权限进行排序(所有权限必须有相同的pid) + - 同级权限的sort值不能重复 + - 排序值必须为非负整数 + - 支持批量更新多个权限的排序值 + - 事务中执行,要么全部成功要么全部失败 + +3. **数据处理** + - 验证所有权限是否存在且状态正常 + - 检查所有权限是否在同一级别(相同pid) + - 检查sort值的有效性和唯一性 + - 批量更新权限的sort字段 + - 记录操作日志 + +4. **错误处理** + - 权限不存在:404 Not Found + - 权限状态非启用:400 Bad Request + - 权限不在同一级别:400 Bad Request + - sort值重复:409 Conflict + - sort值格式错误:400 Bad Request + - 数组为空或超过限制:400 Bad Request + - 权限ID重复:400 Bad Request + +#### 性能考虑 + +1. **数据库优化** + - 批量查询权限信息减少数据库访问 + - 使用事务确保数据一致性 + - 利用pid+sort复合索引提升查询性能 + - 批量更新减少数据库交互次数 + +2. **并发控制** + - 使用分布式锁防止并发排序冲突 + - 锁定范围:相同pid下的所有权限 + - 锁定时间:10秒TTL + +#### 安全考虑 + +- 需要管理员权限或特定的权限排序权限 +- 防止越权操作其他模块的权限 +- 记录操作日志便于审计 + +#### 缓存策略 + +- 排序后清除权限树缓存 +- 清除相关模块的权限缓存 +- 更新权限变更时间戳 + +#### 业务流程 + +``` +开始 + ↓ +验证参数格式和数量 + ↓ +获取分布式锁 + ↓ +查询所有权限是否存在 + ↓ +验证权限状态和级别 + ↓ +检查sort值唯一性 + ↓ +开启数据库事务 + ↓ +批量更新sort值 + ↓ +提交事务 + ↓ +释放分布式锁 + ↓ +清除缓存 + ↓ +返回成功响应 +``` + +#### 请求示例 + +```json +{ + "permissions": [ + {"id": "123", "sort": "1"}, + {"id": "124", "sort": "2"}, + {"id": "125", "sort": "3"} + ] +} +``` + +#### 响应示例 + +**成功响应 (200)** +```json +{ + "code": 200, + "message": "权限排序更新成功", + "data": { + "updated_count": 3, + "updated_permissions": [ + { + "id": "123", + "name": "用户管理", + "sort": "1" + }, + { + "id": "124", + "name": "角色管理", + "sort": "2" + }, + { + "id": "125", + "name": "权限管理", + "sort": "3" + } + ] + } +} +``` + +**错误响应 (400)** +```json +{ + "code": 400, + "message": "权限不在同一级别", + "data": null +} +``` + +**错误响应 (409)** +```json +{ + "code": 409, + "message": "排序值重复", + "data": { + "duplicate_sort": "2", + "duplicate_ids": ["124", "125"] + } +} +``` + +#### 测试用例 + +1. **正常排序** + - 输入:同级权限的有效排序数据 + - 预期:排序更新成功 + +2. **权限不存在** + - 输入:包含不存在权限ID的数据 + - 预期:返回404错误 + +3. **权限状态非启用** + - 输入:包含禁用权限的数据 + - 预期:返回400错误 + +4. **权限不在同一级别** + - 输入:不同pid的权限混合 + - 预期:返回400错误 + +5. **sort值重复** + - 输入:相同sort值的权限 + - 预期:返回409错误 + +6. **数组为空** + - 输入:空数组 + - 预期:返回400错误 + +7. **超过限制** + - 输入:超过100条的数据 + - 预期:返回400错误 + +8. **权限ID重复** + - 输入:重复的权限ID + - 预期:返回400错误 + +#### 注意事项 + +1. **排序逻辑** + - 前端通常通过拖拽操作触发排序 + - 需要考虑拖拽后相邻权限的sort值调整 + - 建议前端计算好新的排序值再提交 + +2. **性能优化** + - 避免频繁的排序操作 + - 考虑防抖处理,用户操作完成后统一提交 + - 大批量权限排序时考虑分页处理 + +3. **用户体验** + - 排序操作应该有loading状态 + - 失败时回滚前端显示状态 + - 成功后实时更新权限树显示 \ No newline at end of file diff --git a/src/modules/permission/permission.response.ts b/src/modules/permission/permission.response.ts index 84769a7..7ca266d 100644 --- a/src/modules/permission/permission.response.ts +++ b/src/modules/permission/permission.response.ts @@ -233,4 +233,107 @@ export const GetPermissionTreeResponsesSchema = { }; /** 查看权限完整树成功响应类型 */ -export type GetPermissionTreeSuccessType = Static; \ No newline at end of file +export type GetPermissionTreeSuccessType = Static; + +/** + * 权限排序成功响应数据中的权限项Schema + * @description 排序成功后返回的权限信息 + */ +export const SortedPermissionItemSchema = t.Object({ + /** 权限ID */ + id: t.String({ + description: '权限ID(bigint字符串)', + examples: ['123', '124', '125'], + }), + /** 权限名称 */ + name: t.String({ + description: '权限名称', + examples: ['用户管理', '角色管理', '权限管理'], + }), + /** 排序值 */ + sort: t.String({ + description: '更新后的排序值', + examples: ['1', '2', '3'], + }), +}); + +/** + * 权限排序成功响应数据Schema + * @description 权限排序操作成功后的响应数据结构 + */ +export const SortPermissionSuccessSchema = t.Object({ + /** 更新的权限数量 */ + updated_count: t.Number({ + description: '成功更新的权限数量', + examples: [3, 5, 10], + }), + /** 更新的权限列表 */ + updated_permissions: t.Array(SortedPermissionItemSchema, { + description: '成功更新的权限列表', + }), +}); + +/** + * 权限排序接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const SortPermissionResponsesSchema = { + 200: responseWrapperSchema(SortPermissionSuccessSchema), + 400: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '请求错误', + examples: [ + '权限不在同一级别', + 'sort值格式错误', + '数组为空或超过限制', + '权限ID重复', + '权限状态非启用' + ], + }), + }), + ), + 404: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '权限不存在', + examples: ['权限不存在'], + }), + }), + ), + 409: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '排序值冲突', + examples: ['排序值重复'], + }), + duplicate_sort: t.Optional(t.String({ + description: '重复的排序值', + examples: ['2'], + })), + duplicate_ids: t.Optional(t.Array(t.String(), { + description: '使用重复排序值的权限ID列表', + examples: [['124', '125']], + })), + }), + ), + 403: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '权限不足', + examples: ['权限不足', '需要管理员权限'], + }), + }), + ), + 500: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '服务器错误', + examples: ['内部服务器错误', '数据库操作失败'], + }), + }), + ), +}; + +/** 权限排序成功响应数据类型 */ +export type SortPermissionSuccessType = Static; \ No newline at end of file diff --git a/src/modules/permission/permission.schema.ts b/src/modules/permission/permission.schema.ts index f16f197..c385ecb 100644 --- a/src/modules/permission/permission.schema.ts +++ b/src/modules/permission/permission.schema.ts @@ -340,4 +340,49 @@ export const GetPermissionTreeByPidParamsSchema = t.Object({ }); /** 查看指定父权限下的子权限树参数类型 */ -export type GetPermissionTreeByPidParams = Static; \ No newline at end of file +export type GetPermissionTreeByPidParams = Static; + +/** + * 权限排序项Schema + * @description 单个权限的排序信息 + */ +export const PermissionSortItemSchema = t.Object({ + /** 权限ID */ + id: t.String({ + pattern: '^[1-9]\\d*$', + description: '权限ID,bigint字符串,必须为正整数', + examples: ['1', '123', '456789'], + }), + /** 排序值 */ + sort: t.String({ + pattern: '^\\d+$', + description: '排序值,必须为非负整数字符串', + examples: ['0', '1', '10', '100'], + }), +}); + +/** + * 权限排序接口请求体Schema + * @description 批量更新权限排序 + */ +export const SortPermissionSchema = t.Object({ + /** 权限排序数组 */ + permissions: t.Array(PermissionSortItemSchema, { + minItems: 1, + maxItems: 100, + description: '权限排序数组,最少1项,最多100项', + examples: [ + [ + { id: '123', sort: '1' }, + { id: '124', sort: '2' }, + { id: '125', sort: '3' } + ] + ], + }), +}); + +/** 权限排序项类型 */ +export type PermissionSortItem = Static; + +/** 权限排序请求参数类型 */ +export type SortPermissionRequest = Static; \ No newline at end of file diff --git a/src/modules/permission/permission.service.ts b/src/modules/permission/permission.service.ts index 861a98f..ee7a348 100644 --- a/src/modules/permission/permission.service.ts +++ b/src/modules/permission/permission.service.ts @@ -10,7 +10,7 @@ import { Logger } from '@/plugins/logger/logger.service'; import { db } from '@/plugins/drizzle/drizzle.service'; import { sysPermission, sysRolePermissions } from '@/eneities'; -import { eq, and, max, ne, sql, asc } from 'drizzle-orm'; +import { eq, and, max, ne, sql, asc, inArray, notInArray } from 'drizzle-orm'; import { alias, bigint, int, mysqlTable } from 'drizzle-orm/mysql-core'; import { successResponse, BusinessError } from '@/utils/responseFormate'; import { nextId } from '@/utils/snowflake'; @@ -285,9 +285,10 @@ export class PermissionService { /** * 获取权限完整树 * @param query 查询参数(module、status、type) + * @param pid 父权限ID,默认为'0'获取完整树 * @returns Promise */ - public async getPermissionTree(query: import('./permission.schema').GetPermissionTreeQuery, pid: string): Promise { + public async getPermissionTree(query: import('./permission.schema').GetPermissionTreeQuery, pid: string = '0'): Promise { // 1. 定义表别名 const ctetTableName = 'ctet' // 锚点表 @@ -416,6 +417,133 @@ export class PermissionService { } return successResponse(roots, '查询权限树成功'); } + + /** + * 权限排序 + * @param body 权限排序请求参数 + * @returns Promise + * @throws BusinessError 业务逻辑错误 + * @type API ===================================================================== + */ + public async sortPermissions(body: import('./permission.schema').SortPermissionRequest): Promise { + // 1. 参数验证 - 检查权限ID的唯一性 + const permissionIds = body.permissions.map(p => p.id); + const uniqueIds = new Set(permissionIds); + if (uniqueIds.size !== permissionIds.length) { + const duplicates = permissionIds.filter((id, index) => permissionIds.indexOf(id) !== index); + throw new BusinessError(`权限ID重复: ${duplicates.join(', ')}`, 400); + } + + // 2. 检查sort值的唯一性 + const sortValues = body.permissions.map(p => p.sort); + const uniqueSorts = new Set(sortValues); + if (uniqueSorts.size !== sortValues.length) { + const duplicateSorts = sortValues.filter((sort, index) => sortValues.indexOf(sort) !== index); + const duplicateIds = body.permissions + .filter(p => duplicateSorts.includes(p.sort)) + .map(p => p.id); + throw new BusinessError('排序值重复', 409); + } + + // 3. 获取分布式锁 - 使用第一个权限的ID作为锁的一部分 + const lockKey = `permission:sort:ids:${permissionIds.join(':')}`; + const lock = await DistributedLockService.acquire({ key: lockKey, ttl: 10 }); + + try { + // 4. 查询所有权限是否存在且状态正常 + const permissions = await db() + .select({ + id: sysPermission.id, + name: sysPermission.name, + pid: sysPermission.pid, + status: sysPermission.status, + sort: sysPermission.sort, + }) + .from(sysPermission) + .where(inArray(sysPermission.id, permissionIds)); + + // 5. 检查权限是否都存在 + if (permissions.length !== permissionIds.length) { + const foundIds = permissions.map(p => String(p.id)); + const missingIds = permissionIds.filter(id => !foundIds.includes(id)); + throw new BusinessError(`权限不存在: ${missingIds.join(', ')}`, 404); + } + + // 6. 检查权限状态 + const disabledPermissions = permissions.filter(p => p.status !== 1); + if (disabledPermissions.length > 0) { + const disabledIds = disabledPermissions.map(p => String(p.id)); + throw new BusinessError(`权限状态非启用: ${disabledIds.join(', ')}`, 400); + } + + // 7. 检查所有权限是否在同一级别(相同pid) + const pids = permissions.map(p => String(p.pid)); + const uniquePids = new Set(pids); + if (uniquePids.size !== 1) { + throw new BusinessError('权限不在同一级别', 400); + } + + // 8. 检查同级别下是否有其他权限使用了相同的sort值 + const pid = pids[0]!; + const existingSorts = await db() + .select({ id: sysPermission.id, sort: sysPermission.sort }) + .from(sysPermission) + .where( + and( + eq(sysPermission.pid, pid), + notInArray(sysPermission.id, permissionIds) + ) + ); + + const existingSortValues = existingSorts.map(p => p.sort); + const conflictingSorts = sortValues.filter(sort => existingSortValues.includes(sort)); + if (conflictingSorts.length > 0) { + throw new BusinessError(`排序值与其他权限冲突: ${conflictingSorts.join(', ')}`, 409); + } + + // 9. 准备更新数据 + const updatePromises = body.permissions.map(async (permData) => { + return db() + .update(sysPermission) + .set({ + sort: permData.sort, + updatedAt: new Date().toISOString() + }) + .where(eq(sysPermission.id, permData.id)); + }); + + // 10. 批量执行更新 + await Promise.all(updatePromises); + + // 11. 查询更新后的权限信息 + const updatedPermissions = await db() + .select({ + id: sysPermission.id, + name: sysPermission.name, + sort: sysPermission.sort, + }) + .from(sysPermission) + .where(inArray(sysPermission.id, permissionIds)) + .orderBy(asc(sysPermission.sort)); + + Logger.info(`权限排序成功,更新了 ${updatedPermissions.length} 个权限`); + + // 12. 返回成功响应 + return successResponse( + { + updated_count: updatedPermissions.length, + updated_permissions: updatedPermissions.map(p => ({ + id: String(p.id), + name: p.name, + sort: p.sort, + })), + }, + '权限排序更新成功' + ); + } finally { + await lock.release(); + } + } } export const permissionService = new PermissionService(); \ No newline at end of file