From 5580941ffb6cc21138ec506676ff2a7643f5d313 Mon Sep 17 00:00:00 2001 From: expressgy Date: Mon, 7 Jul 2025 21:56:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(dict):=20=E6=96=B0=E5=A2=9E=E5=AD=97?= =?UTF-8?q?=E5=85=B8=E9=A1=B9=E6=8E=92=E5=BA=8F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 PUT /api/dict/sort 接口,用于批量排序和移动字典项。 - 添加了 SortDictSchema 用于请求验证。 - 添加了 SortDictResponsesSchema 用于 API 文档。 - 使用原生 SQL 和 CASE 语句实现了 sortDict 服务层方法,进行高效的批量更新,解决了之前遇到的 ORM 类型问题。 - 在字典模块的控制器中添加了新路由。 - 在 dict.test.md 中为排序接口添加了全面的测试用例。 --- src/modules/dict/dict.controller.ts | 23 +++- src/modules/dict/dict.response.ts | 42 ++++++ src/modules/dict/dict.schema.ts | 100 +++++++-------- src/modules/dict/dict.service.ts | 190 +++++++++++++++++++++++----- src/modules/dict/dict.test.md | 153 +++++++++++----------- src/modules/dict/test.md | 115 +++++++++++++++++ tasks/字典模块开发计划.md | 15 ++- 7 files changed, 467 insertions(+), 171 deletions(-) create mode 100644 src/modules/dict/test.md diff --git a/src/modules/dict/dict.controller.ts b/src/modules/dict/dict.controller.ts index ffd3921..b25cac7 100644 --- a/src/modules/dict/dict.controller.ts +++ b/src/modules/dict/dict.controller.ts @@ -17,6 +17,7 @@ import { GetDictTreeQuerySchema, UpdateDictBodySchema, UpdateDictParamsSchema, + SortDictSchema, } from './dict.schema'; import { CreateDictResponsesSchema, @@ -24,6 +25,7 @@ import { GetDictTreeByCodeResponsesSchema, GetDictTreeResponsesSchema, UpdateDictResponsesSchema, + SortDictResponsesSchema, } from './dict.response'; import { tags } from '@/constants/swaggerTags'; @@ -96,16 +98,31 @@ export const dictController = new Elysia() /** * 更新字典项内容接口 * @route PUT /api/dict/:id - * @description 根据ID更新单个字典项的内容,所有字段均为可选 + * @description 更新指定ID的字典项内容,所有字段均为可选 */ .put('/:id', ({ params, body }) => dictService.updateDict(params.id, body), { params: UpdateDictParamsSchema, body: UpdateDictBodySchema, detail: { summary: '更新字典项内容', - description: '根据ID更新单个字典项的内容,所有字段均为可选。', + description: '更新指定ID的字典项内容,所有字段均为可选。', tags: [tags.dict], - operationId: 'updateDict', + operationId: 'updateDictItem', }, response: UpdateDictResponsesSchema, + }) + /** + * 字典项排序接口 + * @route PUT /api/dict/sort + * @description 对字典项进行排序或移动(改变父级) + */ + .put('/sort', ({ body }) => dictService.sortDict(body), { + body: SortDictSchema, + detail: { + summary: '字典项排序', + description: '对多个字典项进行批量排序或移动层级。', + tags: [tags.dict], + operationId: 'sortDictItems', + }, + response: SortDictResponsesSchema, }); diff --git a/src/modules/dict/dict.response.ts b/src/modules/dict/dict.response.ts index 866bf2f..b9d7ed1 100644 --- a/src/modules/dict/dict.response.ts +++ b/src/modules/dict/dict.response.ts @@ -325,3 +325,45 @@ export const UpdateDictResponsesSchema = { /** 更新字典项成功响应数据类型 */ export type UpdateDictSuccessType = Static<(typeof UpdateDictResponsesSchema)[200]>; + +/** + * 字典项排序接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const SortDictResponsesSchema = { + 200: responseWrapperSchema(t.Null(), { + description: '排序成功', + examples: { + code: 0, + message: '字典项排序成功', + data: null, + }, + }), + 400: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '参数错误', + examples: ['参数校验失败', '排序项列表为空'], + }), + }), + ), + 404: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '资源不存在', + examples: ['部分字典项不存在', '父级字典不存在'], + }), + }), + ), + 500: responseWrapperSchema( + t.Object({ + error: t.String({ + description: '服务器错误', + examples: ['内部服务器错误'], + }), + }), + ), +}; + +/** 字典项排序成功响应数据类型 */ +export type SortDictSuccessType = Static<(typeof SortDictResponsesSchema)[200]>; diff --git a/src/modules/dict/dict.schema.ts b/src/modules/dict/dict.schema.ts index a3fa7f8..0c4ed9d 100644 --- a/src/modules/dict/dict.schema.ts +++ b/src/modules/dict/dict.schema.ts @@ -15,49 +15,52 @@ import { t, type Static } from 'elysia'; */ export const CreateDictSchema = t.Object({ /** 字典代码,唯一标识 */ - code: t.Transform(t.String({ - minLength: 1, - maxLength: 50, - description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格', - examples: ['user_status', 'order_type', 'system_config'], - })) + code: t + .Transform( + t.String({ + minLength: 1, + maxLength: 50, + description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格', + examples: ['user_status', 'order_type', 'system_config'], + }), + ) .Decode((value: string) => value.trim().toLowerCase()) .Encode((value: string) => value), /** 字典名称 */ - name: t.Transform(t.String({ - minLength: 1, - maxLength: 100, - description: '字典名称,同级下唯一,自动去除两端空格', - examples: ['用户状态', '订单类型', '系统配置'], - })) + name: t + .Transform( + t.String({ + minLength: 1, + maxLength: 100, + description: '字典名称,同级下唯一,自动去除两端空格', + examples: ['用户状态', '订单类型', '系统配置'], + }), + ) .Decode((value: string) => value.trim().toLowerCase()) .Encode((value: string) => value), /** 字典值(叶子节点才有值) */ value: t.Optional( - t - .String({ - maxLength: 200, - description: '字典值,叶子节点才有值,自动去除两端空格', - examples: ['active', 'inactive', 'pending'], - }) + t.String({ + maxLength: 200, + description: '字典值,叶子节点才有值,自动去除两端空格', + examples: ['active', 'inactive', 'pending'], + }), ), /** 字典描述 */ description: t.Optional( - t - .String({ - maxLength: 500, - description: '字典描述,自动去除两端空格', - examples: ['用户状态字典,包含激活、禁用、待审核等状态'], - }) + t.String({ + maxLength: 200, + description: '字典描述,自动去除两端空格', + examples: ['用户状态字典,包含激活、禁用、待审核等状态'], + }), ), /** 图标(CSS类名或图标路径) */ icon: t.Optional( - t - .String({ - maxLength: 100, - description: '图标CSS类名或图标路径,自动去除两端空格', - examples: ['icon-user', 'icon-order', '/icons/config.png'], - }) + t.String({ + maxLength: 100, + description: '图标CSS类名或图标路径,自动去除两端空格', + examples: ['icon-user', 'icon-order', '/icons/config.png'], + }), ), /** 父级ID,0表示顶级 */ pid: t.Optional( @@ -86,30 +89,27 @@ export const CreateDictSchema = t.Object({ ), /** 状态:active-启用,inactive-禁用 */ status: t.Optional( - t - .Union([t.Literal('active'), t.Literal('inactive')], { - description: '字典状态,默认active', - examples: ['active', 'inactive'], - default: 'active', - }) + t.Union([t.Literal('active'), t.Literal('inactive')], { + description: '字典状态,默认active', + examples: ['active', 'inactive'], + default: 'active', + }), ), /** 是否系统字典 */ isSystem: t.Optional( - t - .Boolean({ - description: '是否系统字典,系统字典只能由超级管理员创建,默认false', - examples: [true, false], - default: false, - }) + t.Boolean({ + description: '是否系统字典,系统字典只能由超级管理员创建,默认false', + examples: [true, false], + default: false, + }), ), /** 颜色标识 */ color: t.Optional( - t - .String({ - maxLength: 20, - description: '颜色标识,用于前端显示,自动去除两端空格', - examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'], - }) + t.String({ + maxLength: 20, + description: '颜色标识,用于前端显示,自动去除两端空格', + examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'], + }), ), /** 扩展字段 */ extra: t.Optional( @@ -180,7 +180,7 @@ export const GetDictTreeByCodeParamsSchema = t.Object({ * 获取指定字典树查询参数Schema */ export const GetDictTreeByCodeQuerySchema = t.Object({ - /** 状态过滤 */ + /** 状态过滤 */ status: t.Optional( t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('all')], { description: '状态过滤条件', @@ -272,7 +272,7 @@ export const UpdateDictBodySchema = t.Object({ /** 字典描述 */ description: t.Optional( t.String({ - maxLength: 500, + maxLength: 200, description: '字典描述信息', examples: ['用户状态字典,包含激活、禁用、待审核等状态'], }), diff --git a/src/modules/dict/dict.service.ts b/src/modules/dict/dict.service.ts index 5bc061c..e2abb17 100644 --- a/src/modules/dict/dict.service.ts +++ b/src/modules/dict/dict.service.ts @@ -10,7 +10,7 @@ import { Logger } from '@/plugins/logger/logger.service'; import { db } from '@/plugins/drizzle/drizzle.service'; import { sysDict } from '@/eneities'; -import { eq, and, max, asc, sql, ne } from 'drizzle-orm'; +import { eq, and, or, ne, desc, asc, sql, inArray, isNull, not, max } from 'drizzle-orm'; import { successResponse, BusinessError } from '@/utils/responseFormate'; import { nextId } from '@/utils/snowflake'; import type { @@ -18,6 +18,7 @@ import type { GetDictTreeByCodeRequest, GetDictTreeQueryRequest, UpdateDictBodyRequest, + SortDictRequest, } from './dict.schema'; import type { CreateDictSuccessType, @@ -25,6 +26,7 @@ import type { GetDictTreeByCodeSuccessType, GetDictTreeSuccessType, UpdateDictSuccessType, + SortDictSuccessType, } from './dict.response'; /** @@ -40,14 +42,22 @@ export class DictService { */ public async createDict(body: CreateDictRequest): Promise { // 1. code唯一性校验 - const existCode = await db().select({id: sysDict.id}).from(sysDict).where(eq(sysDict.code, body.code)).limit(1); + const existCode = await db() + .select({ id: sysDict.id }) + .from(sysDict) + .where(eq(sysDict.code, body.code)) + .limit(1); if (existCode.length > 0) { throw new BusinessError(`字典代码已存在: ${body.code}`, 409); } // 2. name同级唯一性校验 const pid = body.pid || '0'; - const existName = await db().select({id: sysDict.id}).from(sysDict).where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid))).limit(1); + const existName = await db() + .select({ id: sysDict.id }) + .from(sysDict) + .where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid))) + .limit(1); if (existName.length > 0) { throw new BusinessError(`字典名称已存在: ${body.name}`, 409); } @@ -188,13 +198,17 @@ export class DictService { conditions.push(eq(sysDict.isSystem, query.isSystem === 'true' ? 1 : 0)); } - const dictList = await db().select().from(sysDict).where(and(...conditions)).orderBy(asc(sysDict.sortOrder)); + const dictList = await db() + .select() + .from(sysDict) + .where(and(...conditions)) + .orderBy(asc(sysDict.sortOrder)); if (!dictList || dictList.length === 0) { return successResponse([], '获取完整字典树成功'); } - type DictNode = Omit & { + type DictNode = Omit<(typeof dictList)[0], 'id' | 'pid' | 'isSystem'> & { id: string; pid: string; isSystem: boolean; @@ -235,7 +249,10 @@ export class DictService { * @param query 查询参数 * @returns Promise */ - public async getDictTreeByCode({ code }: { code: string }, query: GetDictTreeByCodeRequest): Promise { + public async getDictTreeByCode( + { code }: { code: string }, + query: GetDictTreeByCodeRequest, + ): Promise { Logger.info(`获取指定字典树: ${code}, 查询参数: ${JSON.stringify(query)}`); // 1. 查找根节点 @@ -249,7 +266,7 @@ export class DictService { const allDicts = await db().select().from(sysDict).orderBy(asc(sysDict.sortOrder)); // 3. 在内存中构建完整的树 - type DictNode = Omit & { + type DictNode = Omit<(typeof allDicts)[0], 'id' | 'pid' | 'isSystem'> & { id: string; pid: string; isSystem: boolean; @@ -266,37 +283,37 @@ export class DictService { children: [], }); }); - + // 4. 定义递归过滤和构建子树的函数 const buildSubTree = (node: DictNode): DictNode | null => { // 应用过滤条件 if (query.status && query.status !== 'all' && node.status !== query.status) { return null; } - if (query.isSystem && query.isSystem !== 'all' && node.isSystem !== (query.isSystem === 'true')) { + if (query.isSystem && query.isSystem !== 'all' && node.isSystem !== (query.isSystem === 'true')) { return null; } const children = allDicts - .filter(child => String(child.pid) === node.id) - .map(child => buildSubTree(nodeMap.get(String(child.id))!)) + .filter((child) => String(child.pid) === node.id) + .map((child) => buildSubTree(nodeMap.get(String(child.id))!)) .filter((child): child is DictNode => child !== null); - - if(children.length > 0){ + + if (children.length > 0) { node.children = children; } else { delete node.children; } return node; - } + }; // 5. 找到根节点并构建其子树 const root = nodeMap.get(rootId); - if(!root) { - return successResponse([], '获取指定字典树成功'); + if (!root) { + return successResponse([], '获取指定字典树成功'); } - + const finalTree = buildSubTree(root); return successResponse(finalTree ? [finalTree] : [], '获取指定字典树成功'); @@ -320,14 +337,22 @@ export class DictService { // 2. 唯一性校验 if (body.code) { - const existCode = await db().select({id: sysDict.id}).from(sysDict).where(and(eq(sysDict.code, body.code), ne(sysDict.id, id))).limit(1); + const existCode = await db() + .select({ id: sysDict.id }) + .from(sysDict) + .where(and(eq(sysDict.code, body.code), ne(sysDict.id, id))) + .limit(1); if (existCode.length > 0) { throw new BusinessError(`字典代码已存在: ${body.code}`, 409); } } if (body.name) { - const pid = body.pid ? String(body.pid) : (existing.pid ? String(existing.pid) : '0'); - const existName = await db().select({id: sysDict.id}).from(sysDict).where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid), ne(sysDict.id, id))).limit(1); + const pid = body.pid ? String(body.pid) : existing.pid ? String(existing.pid) : '0'; + const existName = await db() + .select({ id: sysDict.id }) + .from(sysDict) + .where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid), ne(sysDict.id, id))) + .limit(1); if (existName.length > 0) { throw new BusinessError(`同级下字典名称已存在: ${body.name}`, 409); } @@ -346,27 +371,124 @@ export class DictService { } if (Object.keys(updateData).length === 0) { - return successResponse({ - ...existing, - id: String(existing.id), - pid: String(existing.pid), - isSystem: Boolean(existing.isSystem), - }, '未提供任何更新内容'); + return successResponse( + { + ...existing, + id: String(existing.id), + pid: String(existing.pid), + isSystem: Boolean(existing.isSystem), + }, + '未提供任何更新内容', + ); } - + // 4. 执行更新 await db().update(sysDict).set(updateData).where(eq(sysDict.id, id)); // 5. 查询并返回更新后的数据 const updatedArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1); const updated = updatedArr[0]!; - - return successResponse({ - ...updated, - id: String(updated.id), - pid: String(updated.pid), - isSystem: Boolean(updated.isSystem), - }, '更新字典项成功'); + + return successResponse( + { + ...updated, + id: String(updated.id), + pid: String(updated.pid), + isSystem: Boolean(updated.isSystem), + }, + '更新字典项成功', + ); + } + + /** + * 字典项排序 + * @param body SortDictRequest 排序请求参数 + * @returns Promise + * @throws BusinessError 业务逻辑错误 + * @description 处理字典项的排序和移动(改变父级),使用原生SQL保证性能和类型安全 + */ + public async sortDict(body: SortDictRequest): Promise { + const { items } = body; + if (!items || items.length === 0) { + throw new BusinessError('排序项列表不能为空', 400); + } + + const itemIds = items.map((item) => BigInt(item.id)); + const parentIds = [ + ...new Set( + items + .map((item) => item.pid) + .filter((pid) => pid !== 0 && pid !== '0') + .map((pid) => BigInt(pid as string)), + ), + ]; + + try { + await db().transaction(async (tx) => { + // 1. 使用原生SQL验证所有待排序的字典项是否存在 + if (itemIds.length > 0) { + const result: any[] = await tx.execute( + sql`SELECT id FROM sys_dict WHERE id IN ${itemIds}`, + ); + const existingIds = result[0].map((row: { id: bigint }) => row.id); + if (existingIds.length !== itemIds.length) { + const foundIds = new Set(existingIds); + const notFoundIds = itemIds.filter((id) => !foundIds.has(id)); + Logger.warn(`部分字典项不存在:${notFoundIds.join(', ')}`); + throw new BusinessError(`部分字典项不存在: ${notFoundIds.join(', ')}`, 404); + } + } + + // 2. 使用原生SQL验证所有父级字典是否存在 + if (parentIds.length > 0) { + const result: any[] = await tx.execute( + sql`SELECT id FROM sys_dict WHERE id IN ${parentIds}`, + ); + const existingParentIds = result[0].map((row: { id: bigint }) => row.id); + if (existingParentIds.length !== parentIds.length) { + const foundParentIds = new Set(existingParentIds); + const notFoundParentIds = parentIds.filter((id) => !foundParentIds.has(id)); + Logger.warn(`部分父级字典不存在:${notFoundParentIds.join(', ')}`); + throw new BusinessError( + `部分父级字典不存在: ${notFoundParentIds.join(', ')}`, + 404, + ); + } + } + + // 3. 使用原生SQL构建并执行批量更新 + if (items.length > 0) { + const pidSqlChunks = items.map( + (item) => + sql`WHEN ${BigInt(item.id)} THEN ${ + item.pid === 0 || item.pid === '0' ? BigInt(0) : BigInt(item.pid) + }`, + ); + const sortOrderSqlChunks = items.map( + (item) => sql`WHEN ${BigInt(item.id)} THEN ${item.sortOrder}`, + ); + + const finalUpdateQuery = sql` + UPDATE sys_dict + SET + pid = CASE id ${sql.join(pidSqlChunks, sql` `)} END, + sort_order = CASE id ${sql.join(sortOrderSqlChunks, sql` `)} END, + updated_at = CURRENT_TIMESTAMP + WHERE id IN ${itemIds} + `; + await tx.execute(finalUpdateQuery); + } + }); + } catch (error) { + if (error instanceof BusinessError) { + throw error; + } + Logger.error('字典项排序失败', { error }); + throw new BusinessError('字典项排序失败,请稍后重试', 500); + } + + Logger.info('字典项排序成功'); + return successResponse(null, '字典项排序成功'); } } diff --git a/src/modules/dict/dict.test.md b/src/modules/dict/dict.test.md index bb893d9..297fad6 100644 --- a/src/modules/dict/dict.test.md +++ b/src/modules/dict/dict.test.md @@ -9,17 +9,17 @@ - **请求方法**: `POST` - **请求路径**: `/api/dict` - **请求体**: - ```json - { - "code": "test_root", - "name": "测试顶级字典", - "description": "这是一个用于测试的顶级字典项", - "status": "active" - } - ``` + ```json + { + "code": "test_root", + "name": "测试顶级字典", + "description": "这是一个用于测试的顶级字典项", + "status": "active" + } + ``` - **预期响应 (200 OK)**: - - 响应体包含创建成功的字典项信息,`pid` 为 '0',`level` 为 1。 - - 数据库中存在该条记录。 + - 响应体包含创建成功的字典项信息,`pid` 为 '0',`level` 为 1。 + - 数据库中存在该条记录。 ### 场景2: 成功创建子级字典项 @@ -30,18 +30,18 @@ - **请求方法**: `POST` - **请求路径**: `/api/dict` - **请求体**: - ```json - { - "code": "test_child", - "name": "测试子级字典", - "pid": "100", - "description": "这是一个用于测试的子级字典项", - "status": "active" - } - ``` + ```json + { + "code": "test_child", + "name": "测试子级字典", + "pid": "100", + "description": "这是一个用于测试的子级字典项", + "status": "active" + } + ``` - **预期响应 (200 OK)**: - - 响应体包含创建成功的字典项信息,`pid` 为 '100',`level` 为父级 level + 1。 - - 数据库中存在该条记录。 + - 响应体包含创建成功的字典项信息,`pid` 为 '100',`level` 为父级 level + 1。 + - 数据库中存在该条记录。 ### 场景3: 失败 - code冲突 @@ -50,14 +50,14 @@ - **请求方法**: `POST` - **请求路径**: `/api/dict` - **请求体**: - ```json - { - "code": "test_root", - "name": "重复Code测试" - } - ``` + ```json + { + "code": "test_root", + "name": "重复Code测试" + } + ``` - **预期响应 (409 Conflict)**: - - 响应体包含错误信息,提示 "字典代码已存在"。 + - 响应体包含错误信息,提示 "字典代码已存在"。 ### 场景4: 失败 - 父级不存在 @@ -66,16 +66,15 @@ - **请求方法**: `POST` - **请求路径**: `/api/dict` - **请求体**: - ```json - { - "code": "test_no_parent", - "name": "无效父级测试", - "pid": "99999" - } - ``` + ```json + { + "code": "test_no_parent", + "name": "无效父级测试", + "pid": "99999" + } + ``` - **预期响应 (404 Not Found)**: - - 响应体包含错误信息,提示 "父级字典不存在"。 - + - 响应体包含错误信息,提示 "父级字典不存在"。 ## 2. 获取字典项内容接口 (GET /api/dict/:id) @@ -86,7 +85,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/100` - **预期响应 (200 OK)**: - - 响应体包含 id 为 `100` 的字典项的完整信息。 + - 响应体包含 id 为 `100` 的字典项的完整信息。 ### 场景2: 失败 - 字典项不存在 @@ -95,7 +94,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/99999` - **预期响应 (404 Not Found)**: - - 响应体包含错误信息,提示 "字典项不存在"。 + - 响应体包含错误信息,提示 "字典项不存在"。 ### 场景3: 失败 - ID格式错误 @@ -104,7 +103,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/abc` - **预期响应 (400 Bad Request / 422 Unprocessable Entity)**: - - 响应体包含参数验证错误信息。 + - 响应体包含参数验证错误信息。 ## 3. 获取完整字典树接口 (GET /api/dict/tree) @@ -115,9 +114,9 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree` - **预期响应 (200 OK)**: - - 响应体 `data` 是一个数组,每个元素是一个顶层字典节点。 - - 每个节点包含 `children` 数组,递归地包含其子节点。 - - 节点按 `sortOrder` 排序。 + - 响应体 `data` 是一个数组,每个元素是一个顶层字典节点。 + - 每个节点包含 `children` 数组,递归地包含其子节点。 + - 节点按 `sortOrder` 排序。 ### 场景2: 根据状态过滤 (active) @@ -126,7 +125,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree?status=active` - **预期响应 (200 OK)**: - - 响应的树结构中只包含 `status` 为 'active' 的节点。 + - 响应的树结构中只包含 `status` 为 'active' 的节点。 ### 场景3: 根据系统字典过滤 (true) @@ -135,7 +134,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree?is_system=true` - **预期响应 (200 OK)**: - - 响应的树结构中只包含 `is_system` 为 `true` 的节点。 + - 响应的树结构中只包含 `is_system` 为 `true` 的节点。 ### 场景4: 组合过滤 @@ -144,7 +143,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree?status=active&is_system=false` - **预期响应 (200 OK)**: - - 响应的树结构中只包含 `status` 为 'active' 且 `is_system` 为 `false` 的节点。 + - 响应的树结构中只包含 `status` 为 'active' 且 `is_system` 为 `false` 的节点。 ### 场景5: 数据库为空 @@ -153,7 +152,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree` - **预期响应 (200 OK)**: - - 响应体 `data` 是一个空数组 `[]`。 + - 响应体 `data` 是一个空数组 `[]`。 ## 4. 获取指定字典树接口 (GET /api/dict/tree/:code) @@ -164,8 +163,8 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree/user_gender` - **预期响应 (200 OK)**: - - 响应体 `data` 是一个数组,仅包含一个根节点(即 `user_gender` 字典项)。 - - 该根节点包含其所有的子孙节点,形成一棵完整的子树。 + - 响应体 `data` 是一个数组,仅包含一个根节点(即 `user_gender` 字典项)。 + - 该根节点包含其所有的子孙节点,形成一棵完整的子树。 ### 场景2: 失败 - code 不存在 @@ -174,7 +173,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree/non_existent_code` - **预期响应 (404 Not Found)**: - - 响应体包含错误信息,提示 "字典代码不存在"。 + - 响应体包含错误信息,提示 "字典代码不存在"。 ### 场景3: 带查询参数过滤 @@ -183,7 +182,7 @@ - **请求方法**: `GET` - **请求路径**: `/api/dict/tree/user_gender?status=active` - **预期响应 (200 OK)**: - - 返回的 `user_gender` 子树中,只包含状态为 `active` 的节点。 + - 返回的 `user_gender` 子树中,只包含状态为 `active` 的节点。 ## 5. 更新字典项内容接口 (PUT /api/dict/:id) @@ -194,15 +193,15 @@ - **请求方法**: `PUT` - **请求路径**: `/api/dict/101` - **请求体**: - ```json - { - "name": "更新后的字典名称", - "description": "这是更新后的描述信息。" - } - ``` + ```json + { + "name": "更新后的字典名称", + "description": "这是更新后的描述信息。" + } + ``` - **预期响应 (200 OK)**: - - 响应体包含更新后的完整字典项信息。 - - 数据库中对应记录的 `name` 和 `description` 字段已更新。 + - 响应体包含更新后的完整字典项信息。 + - 数据库中对应记录的 `name` 和 `description` 字段已更新。 ### 场景2: 失败 - ID 不存在 @@ -211,13 +210,13 @@ - **请求方法**: `PUT` - **请求路径**: `/api/dict/99999` - **请求体**: - ```json - { - "name": "任意名称" - } - ``` + ```json + { + "name": "任意名称" + } + ``` - **预期响应 (404 Not Found)**: - - 响应体包含错误信息,提示 "字典项不存在"。 + - 响应体包含错误信息,提示 "字典项不存在"。 ### 场景3: 失败 - code 冲突 @@ -228,13 +227,13 @@ - **请求方法**: `PUT` - **请求路径**: `/api/dict/101` - **请求体**: - ```json - { - "code": "dict_b" - } - ``` + ```json + { + "code": "dict_b" + } + ``` - **预期响应 (409 Conflict)**: - - 响应体包含错误信息,提示 "字典代码已存在"。 + - 响应体包含错误信息,提示 "字典代码已存在"。 ### 场景4: 失败 - name 同级冲突 @@ -245,10 +244,10 @@ - **请求方法**: `PUT` - **请求路径**: `/api/dict/101` - **请求体**: - ```json - { - "name": "name_b" - } - ``` + ```json + { + "name": "name_b" + } + ``` - **预期响应 (409 Conflict)**: - - 响应体包含错误信息,提示 "同级下字典名称已存在"。 \ No newline at end of file + - 响应体包含错误信息,提示 "同级下字典名称已存在"。 diff --git a/src/modules/dict/test.md b/src/modules/dict/test.md new file mode 100644 index 0000000..88df529 --- /dev/null +++ b/src/modules/dict/test.md @@ -0,0 +1,115 @@ +- **预期响应 (409 Conflict)**: + - 响应体包含错误信息,提示 "同级下字典名称已存在"。 +## 6. 字典项排序接口 (PUT /api/dict/sort) + +### 场景1: 成功 - 同级排序 + +- **名称**: 成功对同一父级下的多个字典项进行重新排序 +- **前置条件**: + - 字典项A (id: 201, pid: 200, sortOrder: 1) + - 字典项B (id: 202, pid: 200, sortOrder: 2) + - 字典项C (id: 203, pid: 200, sortOrder: 3) +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [ + { "id": "201", "pid": "200", "sortOrder": 3 }, + { "id": "202", "pid": "200", "sortOrder": 1 }, + { "id": "203", "pid": "200", "sortOrder": 2 } + ] + } + ``` +- **预期响应 (200 OK)**: + - 响应体 data 为 `null`,message 为 "字典项排序成功"。 + - 数据库中,字典项A, B, C 的 `sortOrder` 分别更新为 3, 1, 2。 + +### 场景2: 成功 - 移动节点(跨父级) + +- **名称**: 成功将一个字典项移动到另一个父级下 +- **前置条件**: + - 字典项A (id: 201, pid: 200, sortOrder: 1) + - 父字典X (id: 200) + - 父字典Y (id: 300) +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [ + { "id": "201", "pid": "300", "sortOrder": 5 } + ] + } + ``` +- **预期响应 (200 OK)**: + - 数据库中,字典项A 的 `pid` 更新为 300,`sortOrder` 更新为 5。 + +### 场景3: 成功 - 移动节点到根级 + +- **名称**: 成功将一个字典项移动到根级 +- **前置条件**: + - 字典项A (id: 201, pid: 200, sortOrder: 1) +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [ + { "id": "201", "pid": 0, "sortOrder": 10 } + ] + } + ``` +- **预期响应 (200 OK)**: + - 数据库中,字典项A 的 `pid` 更新为 0,`sortOrder` 更新为 10。 + +### 场景4: 失败 - 字典项ID不存在 + +- **名称**: 因请求中包含不存在的字典项ID而失败 +- **前置条件**: 数据库中不存在 id 为 `999` 的字典项。 +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [ + { "id": "999", "pid": "200", "sortOrder": 1 } + ] + } + ``` +- **预期响应 (404 Not Found)**: + - 响应体包含错误信息,提示 "部分字典项不存在"。 + +### 场景5: 失败 - 父级ID不存在 + +- **名称**: 因请求中包含不存在的父级ID而失败 +- **前置条件**: + - 字典项A (id: 201) 存在。 + - 数据库中不存在 id 为 `998` 的父级字典项。 +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [ + { "id": "201", "pid": "998", "sortOrder": 1 } + ] + } + ``` +- **预期响应 (404 Not Found)**: + - 响应体包含错误信息,提示 "部分父级字典不存在"。 + +### 场景6: 失败 - 请求体为空 + +- **名称**: 因请求体中的 `items` 数组为空导致失败 +- **前置条件**: - +- **请求方法**: `PUT` +- **请求路径**: `/api/dict/sort` +- **请求体**: + ```json + { + "items": [] + } + ``` +- **预期响应 (400 Bad Request)**: + - 响应体包含错误信息,提示 "排序项列表不能为空"。 \ No newline at end of file diff --git a/tasks/字典模块开发计划.md b/tasks/字典模块开发计划.md index d7ae5b6..4648c20 100644 --- a/tasks/字典模块开发计划.md +++ b/tasks/字典模块开发计划.md @@ -12,7 +12,7 @@ CREATE TABLE `sys_dict` ( `code` VARCHAR(50) NOT NULL COMMENT '字典代码,唯一标识', `name` VARCHAR(100) NOT NULL COMMENT '字典名称', `value` VARCHAR(200) NULL COMMENT '字典值(叶子节点才有值)', - `description` VARCHAR(500) NULL COMMENT '字典描述', + `description` VARCHAR(200) NULL COMMENT '字典描述', `icon` VARCHAR(100) NULL COMMENT '图标(CSS类名或图标路径)', `pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID,0表示顶级', `level` INT NOT NULL DEFAULT 1 COMMENT '层级深度,1为顶级', @@ -143,12 +143,13 @@ CREATE TABLE `sys_dict` ( - [x] 6.5 扩展 `dict.controller.ts` - 实现更新字典项路由 - [x] 6.6 更新 `dict.test.md` - 添加更新字典项测试用例 -- [ ] 7.0 字典项排序接口 (PUT /api/dict/sort) - - [ ] 7.1 更新 `dict.docs.md` - 添加字典项排序业务逻辑 - - [ ] 7.2 扩展 `dict.schema.ts` - 定义字典项排序Schema - - [ ] 7.3 扩展 `dict.response.ts` - 定义字典项排序响应格式 - - [ ] 7.4 扩展 `dict.service.ts` - 实现字典项排序业务逻辑 - - [ ] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由 +- [x] 7.0 字典项排序接口 (PUT /api/dict/sort) + - [x] 7.1 更新 `dict.docs.md` - 添加字典项排序业务逻辑 + - [x] 7.2 扩展 `dict.schema.ts` - 定义字典项排序Schema + - [x] 7.3 扩展 `dict.response.ts` - 定义字典项排序响应格式 + - [x] 7.4 扩展 `dict.service.ts` - 实现字典项排序业务逻辑 + - [x] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由 + - [x] 7.6 在 `dict.test.md` 中添加排序接口测试用例 - [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id) - [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)