feat(dict): 新增字典项排序接口

- 实现了 PUT /api/dict/sort 接口,用于批量排序和移动字典项。
- 添加了 SortDictSchema 用于请求验证。
- 添加了 SortDictResponsesSchema 用于 API 文档。
- 使用原生 SQL 和 CASE 语句实现了 sortDict 服务层方法,进行高效的批量更新,解决了之前遇到的 ORM 类型问题。
- 在字典模块的控制器中添加了新路由。
- 在 dict.test.md 中为排序接口添加了全面的测试用例。
This commit is contained in:
expressgy 2025-07-07 21:56:18 +08:00
parent 10ee246b7d
commit 5580941ffb
7 changed files with 467 additions and 171 deletions

View File

@ -17,6 +17,7 @@ import {
GetDictTreeQuerySchema, GetDictTreeQuerySchema,
UpdateDictBodySchema, UpdateDictBodySchema,
UpdateDictParamsSchema, UpdateDictParamsSchema,
SortDictSchema,
} from './dict.schema'; } from './dict.schema';
import { import {
CreateDictResponsesSchema, CreateDictResponsesSchema,
@ -24,6 +25,7 @@ import {
GetDictTreeByCodeResponsesSchema, GetDictTreeByCodeResponsesSchema,
GetDictTreeResponsesSchema, GetDictTreeResponsesSchema,
UpdateDictResponsesSchema, UpdateDictResponsesSchema,
SortDictResponsesSchema,
} from './dict.response'; } from './dict.response';
import { tags } from '@/constants/swaggerTags'; import { tags } from '@/constants/swaggerTags';
@ -96,16 +98,31 @@ export const dictController = new Elysia()
/** /**
* *
* @route PUT /api/dict/:id * @route PUT /api/dict/:id
* @description ID更新单个字典项的内容 * @description ID的字典项内容
*/ */
.put('/:id', ({ params, body }) => dictService.updateDict(params.id, body), { .put('/:id', ({ params, body }) => dictService.updateDict(params.id, body), {
params: UpdateDictParamsSchema, params: UpdateDictParamsSchema,
body: UpdateDictBodySchema, body: UpdateDictBodySchema,
detail: { detail: {
summary: '更新字典项内容', summary: '更新字典项内容',
description: '根据ID更新单个字典项的内容,所有字段均为可选。', description: '更新指定ID的字典项内容,所有字段均为可选。',
tags: [tags.dict], tags: [tags.dict],
operationId: 'updateDict', operationId: 'updateDictItem',
}, },
response: UpdateDictResponsesSchema, 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,
}); });

View File

@ -325,3 +325,45 @@ export const UpdateDictResponsesSchema = {
/** 更新字典项成功响应数据类型 */ /** 更新字典项成功响应数据类型 */
export type UpdateDictSuccessType = Static<(typeof UpdateDictResponsesSchema)[200]>; 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]>;

View File

@ -15,49 +15,52 @@ import { t, type Static } from 'elysia';
*/ */
export const CreateDictSchema = t.Object({ export const CreateDictSchema = t.Object({
/** 字典代码,唯一标识 */ /** 字典代码,唯一标识 */
code: t.Transform(t.String({ code: t
minLength: 1, .Transform(
maxLength: 50, t.String({
description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格', minLength: 1,
examples: ['user_status', 'order_type', 'system_config'], maxLength: 50,
})) description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格',
examples: ['user_status', 'order_type', 'system_config'],
}),
)
.Decode((value: string) => value.trim().toLowerCase()) .Decode((value: string) => value.trim().toLowerCase())
.Encode((value: string) => value), .Encode((value: string) => value),
/** 字典名称 */ /** 字典名称 */
name: t.Transform(t.String({ name: t
minLength: 1, .Transform(
maxLength: 100, t.String({
description: '字典名称,同级下唯一,自动去除两端空格', minLength: 1,
examples: ['用户状态', '订单类型', '系统配置'], maxLength: 100,
})) description: '字典名称,同级下唯一,自动去除两端空格',
examples: ['用户状态', '订单类型', '系统配置'],
}),
)
.Decode((value: string) => value.trim().toLowerCase()) .Decode((value: string) => value.trim().toLowerCase())
.Encode((value: string) => value), .Encode((value: string) => value),
/** 字典值(叶子节点才有值) */ /** 字典值(叶子节点才有值) */
value: t.Optional( value: t.Optional(
t t.String({
.String({ maxLength: 200,
maxLength: 200, description: '字典值,叶子节点才有值,自动去除两端空格',
description: '字典值,叶子节点才有值,自动去除两端空格', examples: ['active', 'inactive', 'pending'],
examples: ['active', 'inactive', 'pending'], }),
})
), ),
/** 字典描述 */ /** 字典描述 */
description: t.Optional( description: t.Optional(
t t.String({
.String({ maxLength: 200,
maxLength: 500, description: '字典描述,自动去除两端空格',
description: '字典描述,自动去除两端空格', examples: ['用户状态字典,包含激活、禁用、待审核等状态'],
examples: ['用户状态字典,包含激活、禁用、待审核等状态'], }),
})
), ),
/** 图标CSS类名或图标路径 */ /** 图标CSS类名或图标路径 */
icon: t.Optional( icon: t.Optional(
t t.String({
.String({ maxLength: 100,
maxLength: 100, description: '图标CSS类名或图标路径自动去除两端空格',
description: '图标CSS类名或图标路径自动去除两端空格', examples: ['icon-user', 'icon-order', '/icons/config.png'],
examples: ['icon-user', 'icon-order', '/icons/config.png'], }),
})
), ),
/** 父级ID0表示顶级 */ /** 父级ID0表示顶级 */
pid: t.Optional( pid: t.Optional(
@ -86,30 +89,27 @@ export const CreateDictSchema = t.Object({
), ),
/** 状态active-启用inactive-禁用 */ /** 状态active-启用inactive-禁用 */
status: t.Optional( status: t.Optional(
t t.Union([t.Literal('active'), t.Literal('inactive')], {
.Union([t.Literal('active'), t.Literal('inactive')], { description: '字典状态默认active',
description: '字典状态默认active', examples: ['active', 'inactive'],
examples: ['active', 'inactive'], default: 'active',
default: 'active', }),
})
), ),
/** 是否系统字典 */ /** 是否系统字典 */
isSystem: t.Optional( isSystem: t.Optional(
t t.Boolean({
.Boolean({ description: '是否系统字典系统字典只能由超级管理员创建默认false',
description: '是否系统字典系统字典只能由超级管理员创建默认false', examples: [true, false],
examples: [true, false], default: false,
default: false, }),
})
), ),
/** 颜色标识 */ /** 颜色标识 */
color: t.Optional( color: t.Optional(
t t.String({
.String({ maxLength: 20,
maxLength: 20, description: '颜色标识,用于前端显示,自动去除两端空格',
description: '颜色标识,用于前端显示,自动去除两端空格', examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'],
examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'], }),
})
), ),
/** 扩展字段 */ /** 扩展字段 */
extra: t.Optional( extra: t.Optional(
@ -180,7 +180,7 @@ export const GetDictTreeByCodeParamsSchema = t.Object({
* Schema * Schema
*/ */
export const GetDictTreeByCodeQuerySchema = t.Object({ export const GetDictTreeByCodeQuerySchema = t.Object({
/** 状态过滤 */ /** 状态过滤 */
status: t.Optional( status: t.Optional(
t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('all')], { t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('all')], {
description: '状态过滤条件', description: '状态过滤条件',
@ -272,7 +272,7 @@ export const UpdateDictBodySchema = t.Object({
/** 字典描述 */ /** 字典描述 */
description: t.Optional( description: t.Optional(
t.String({ t.String({
maxLength: 500, maxLength: 200,
description: '字典描述信息', description: '字典描述信息',
examples: ['用户状态字典,包含激活、禁用、待审核等状态'], examples: ['用户状态字典,包含激活、禁用、待审核等状态'],
}), }),

View File

@ -10,7 +10,7 @@
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import { db } from '@/plugins/drizzle/drizzle.service'; import { db } from '@/plugins/drizzle/drizzle.service';
import { sysDict } from '@/eneities'; 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 { successResponse, BusinessError } from '@/utils/responseFormate';
import { nextId } from '@/utils/snowflake'; import { nextId } from '@/utils/snowflake';
import type { import type {
@ -18,6 +18,7 @@ import type {
GetDictTreeByCodeRequest, GetDictTreeByCodeRequest,
GetDictTreeQueryRequest, GetDictTreeQueryRequest,
UpdateDictBodyRequest, UpdateDictBodyRequest,
SortDictRequest,
} from './dict.schema'; } from './dict.schema';
import type { import type {
CreateDictSuccessType, CreateDictSuccessType,
@ -25,6 +26,7 @@ import type {
GetDictTreeByCodeSuccessType, GetDictTreeByCodeSuccessType,
GetDictTreeSuccessType, GetDictTreeSuccessType,
UpdateDictSuccessType, UpdateDictSuccessType,
SortDictSuccessType,
} from './dict.response'; } from './dict.response';
/** /**
@ -40,14 +42,22 @@ export class DictService {
*/ */
public async createDict(body: CreateDictRequest): Promise<CreateDictSuccessType> { public async createDict(body: CreateDictRequest): Promise<CreateDictSuccessType> {
// 1. code唯一性校验 // 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) { if (existCode.length > 0) {
throw new BusinessError(`字典代码已存在: ${body.code}`, 409); throw new BusinessError(`字典代码已存在: ${body.code}`, 409);
} }
// 2. name同级唯一性校验 // 2. name同级唯一性校验
const pid = body.pid || '0'; 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) { if (existName.length > 0) {
throw new BusinessError(`字典名称已存在: ${body.name}`, 409); throw new BusinessError(`字典名称已存在: ${body.name}`, 409);
} }
@ -188,13 +198,17 @@ export class DictService {
conditions.push(eq(sysDict.isSystem, query.isSystem === 'true' ? 1 : 0)); 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) { if (!dictList || dictList.length === 0) {
return successResponse([], '获取完整字典树成功'); return successResponse([], '获取完整字典树成功');
} }
type DictNode = Omit<typeof dictList[0], 'id' | 'pid' | 'isSystem'> & { type DictNode = Omit<(typeof dictList)[0], 'id' | 'pid' | 'isSystem'> & {
id: string; id: string;
pid: string; pid: string;
isSystem: boolean; isSystem: boolean;
@ -235,7 +249,10 @@ export class DictService {
* @param query * @param query
* @returns Promise<GetDictTreeByCodeSuccessType> * @returns Promise<GetDictTreeByCodeSuccessType>
*/ */
public async getDictTreeByCode({ code }: { code: string }, query: GetDictTreeByCodeRequest): Promise<GetDictTreeByCodeSuccessType> { public async getDictTreeByCode(
{ code }: { code: string },
query: GetDictTreeByCodeRequest,
): Promise<GetDictTreeByCodeSuccessType> {
Logger.info(`获取指定字典树: ${code}, 查询参数: ${JSON.stringify(query)}`); Logger.info(`获取指定字典树: ${code}, 查询参数: ${JSON.stringify(query)}`);
// 1. 查找根节点 // 1. 查找根节点
@ -249,7 +266,7 @@ export class DictService {
const allDicts = await db().select().from(sysDict).orderBy(asc(sysDict.sortOrder)); const allDicts = await db().select().from(sysDict).orderBy(asc(sysDict.sortOrder));
// 3. 在内存中构建完整的树 // 3. 在内存中构建完整的树
type DictNode = Omit<typeof allDicts[0], 'id' | 'pid' | 'isSystem'> & { type DictNode = Omit<(typeof allDicts)[0], 'id' | 'pid' | 'isSystem'> & {
id: string; id: string;
pid: string; pid: string;
isSystem: boolean; isSystem: boolean;
@ -266,37 +283,37 @@ export class DictService {
children: [], children: [],
}); });
}); });
// 4. 定义递归过滤和构建子树的函数 // 4. 定义递归过滤和构建子树的函数
const buildSubTree = (node: DictNode): DictNode | null => { const buildSubTree = (node: DictNode): DictNode | null => {
// 应用过滤条件 // 应用过滤条件
if (query.status && query.status !== 'all' && node.status !== query.status) { if (query.status && query.status !== 'all' && node.status !== query.status) {
return null; 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; return null;
} }
const children = allDicts const children = allDicts
.filter(child => String(child.pid) === node.id) .filter((child) => String(child.pid) === node.id)
.map(child => buildSubTree(nodeMap.get(String(child.id))!)) .map((child) => buildSubTree(nodeMap.get(String(child.id))!))
.filter((child): child is DictNode => child !== null); .filter((child): child is DictNode => child !== null);
if(children.length > 0){ if (children.length > 0) {
node.children = children; node.children = children;
} else { } else {
delete node.children; delete node.children;
} }
return node; return node;
} };
// 5. 找到根节点并构建其子树 // 5. 找到根节点并构建其子树
const root = nodeMap.get(rootId); const root = nodeMap.get(rootId);
if(!root) { if (!root) {
return successResponse([], '获取指定字典树成功'); return successResponse([], '获取指定字典树成功');
} }
const finalTree = buildSubTree(root); const finalTree = buildSubTree(root);
return successResponse(finalTree ? [finalTree] : [], '获取指定字典树成功'); return successResponse(finalTree ? [finalTree] : [], '获取指定字典树成功');
@ -320,14 +337,22 @@ export class DictService {
// 2. 唯一性校验 // 2. 唯一性校验
if (body.code) { 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) { if (existCode.length > 0) {
throw new BusinessError(`字典代码已存在: ${body.code}`, 409); throw new BusinessError(`字典代码已存在: ${body.code}`, 409);
} }
} }
if (body.name) { if (body.name) {
const pid = body.pid ? String(body.pid) : (existing.pid ? String(existing.pid) : '0'); 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 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) { if (existName.length > 0) {
throw new BusinessError(`同级下字典名称已存在: ${body.name}`, 409); throw new BusinessError(`同级下字典名称已存在: ${body.name}`, 409);
} }
@ -346,27 +371,124 @@ export class DictService {
} }
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
return successResponse({ return successResponse(
...existing, {
id: String(existing.id), ...existing,
pid: String(existing.pid), id: String(existing.id),
isSystem: Boolean(existing.isSystem), pid: String(existing.pid),
}, '未提供任何更新内容'); isSystem: Boolean(existing.isSystem),
},
'未提供任何更新内容',
);
} }
// 4. 执行更新 // 4. 执行更新
await db().update(sysDict).set(updateData).where(eq(sysDict.id, id)); await db().update(sysDict).set(updateData).where(eq(sysDict.id, id));
// 5. 查询并返回更新后的数据 // 5. 查询并返回更新后的数据
const updatedArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1); const updatedArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1);
const updated = updatedArr[0]!; const updated = updatedArr[0]!;
return successResponse({ return successResponse(
...updated, {
id: String(updated.id), ...updated,
pid: String(updated.pid), id: String(updated.id),
isSystem: Boolean(updated.isSystem), pid: String(updated.pid),
}, '更新字典项成功'); isSystem: Boolean(updated.isSystem),
},
'更新字典项成功',
);
}
/**
*
* @param body SortDictRequest
* @returns Promise<SortDictSuccessType>
* @throws BusinessError
* @description 使SQL保证性能和类型安全
*/
public async sortDict(body: SortDictRequest): Promise<SortDictSuccessType> {
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, '字典项排序成功');
} }
} }

View File

@ -9,17 +9,17 @@
- **请求方法**: `POST` - **请求方法**: `POST`
- **请求路径**: `/api/dict` - **请求路径**: `/api/dict`
- **请求体**: - **请求体**:
```json ```json
{ {
"code": "test_root", "code": "test_root",
"name": "测试顶级字典", "name": "测试顶级字典",
"description": "这是一个用于测试的顶级字典项", "description": "这是一个用于测试的顶级字典项",
"status": "active" "status": "active"
} }
``` ```
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体包含创建成功的字典项信息,`pid` 为 '0'`level` 为 1。 - 响应体包含创建成功的字典项信息,`pid` 为 '0'`level` 为 1。
- 数据库中存在该条记录。 - 数据库中存在该条记录。
### 场景2: 成功创建子级字典项 ### 场景2: 成功创建子级字典项
@ -30,18 +30,18 @@
- **请求方法**: `POST` - **请求方法**: `POST`
- **请求路径**: `/api/dict` - **请求路径**: `/api/dict`
- **请求体**: - **请求体**:
```json ```json
{ {
"code": "test_child", "code": "test_child",
"name": "测试子级字典", "name": "测试子级字典",
"pid": "100", "pid": "100",
"description": "这是一个用于测试的子级字典项", "description": "这是一个用于测试的子级字典项",
"status": "active" "status": "active"
} }
``` ```
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体包含创建成功的字典项信息,`pid` 为 '100'`level` 为父级 level + 1。 - 响应体包含创建成功的字典项信息,`pid` 为 '100'`level` 为父级 level + 1。
- 数据库中存在该条记录。 - 数据库中存在该条记录。
### 场景3: 失败 - code冲突 ### 场景3: 失败 - code冲突
@ -50,14 +50,14 @@
- **请求方法**: `POST` - **请求方法**: `POST`
- **请求路径**: `/api/dict` - **请求路径**: `/api/dict`
- **请求体**: - **请求体**:
```json ```json
{ {
"code": "test_root", "code": "test_root",
"name": "重复Code测试" "name": "重复Code测试"
} }
``` ```
- **预期响应 (409 Conflict)**: - **预期响应 (409 Conflict)**:
- 响应体包含错误信息,提示 "字典代码已存在"。 - 响应体包含错误信息,提示 "字典代码已存在"。
### 场景4: 失败 - 父级不存在 ### 场景4: 失败 - 父级不存在
@ -66,16 +66,15 @@
- **请求方法**: `POST` - **请求方法**: `POST`
- **请求路径**: `/api/dict` - **请求路径**: `/api/dict`
- **请求体**: - **请求体**:
```json ```json
{ {
"code": "test_no_parent", "code": "test_no_parent",
"name": "无效父级测试", "name": "无效父级测试",
"pid": "99999" "pid": "99999"
} }
``` ```
- **预期响应 (404 Not Found)**: - **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "父级字典不存在"。 - 响应体包含错误信息,提示 "父级字典不存在"。
## 2. 获取字典项内容接口 (GET /api/dict/:id) ## 2. 获取字典项内容接口 (GET /api/dict/:id)
@ -86,7 +85,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/100` - **请求路径**: `/api/dict/100`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体包含 id 为 `100` 的字典项的完整信息。 - 响应体包含 id 为 `100` 的字典项的完整信息。
### 场景2: 失败 - 字典项不存在 ### 场景2: 失败 - 字典项不存在
@ -95,7 +94,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/99999` - **请求路径**: `/api/dict/99999`
- **预期响应 (404 Not Found)**: - **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "字典项不存在"。 - 响应体包含错误信息,提示 "字典项不存在"。
### 场景3: 失败 - ID格式错误 ### 场景3: 失败 - ID格式错误
@ -104,7 +103,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/abc` - **请求路径**: `/api/dict/abc`
- **预期响应 (400 Bad Request / 422 Unprocessable Entity)**: - **预期响应 (400 Bad Request / 422 Unprocessable Entity)**:
- 响应体包含参数验证错误信息。 - 响应体包含参数验证错误信息。
## 3. 获取完整字典树接口 (GET /api/dict/tree) ## 3. 获取完整字典树接口 (GET /api/dict/tree)
@ -115,9 +114,9 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree` - **请求路径**: `/api/dict/tree`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体 `data` 是一个数组,每个元素是一个顶层字典节点。 - 响应体 `data` 是一个数组,每个元素是一个顶层字典节点。
- 每个节点包含 `children` 数组,递归地包含其子节点。 - 每个节点包含 `children` 数组,递归地包含其子节点。
- 节点按 `sortOrder` 排序。 - 节点按 `sortOrder` 排序。
### 场景2: 根据状态过滤 (active) ### 场景2: 根据状态过滤 (active)
@ -126,7 +125,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?status=active` - **请求路径**: `/api/dict/tree?status=active`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应的树结构中只包含 `status` 为 'active' 的节点。 - 响应的树结构中只包含 `status` 为 'active' 的节点。
### 场景3: 根据系统字典过滤 (true) ### 场景3: 根据系统字典过滤 (true)
@ -135,7 +134,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?is_system=true` - **请求路径**: `/api/dict/tree?is_system=true`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应的树结构中只包含 `is_system``true` 的节点。 - 响应的树结构中只包含 `is_system``true` 的节点。
### 场景4: 组合过滤 ### 场景4: 组合过滤
@ -144,7 +143,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?status=active&is_system=false` - **请求路径**: `/api/dict/tree?status=active&is_system=false`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应的树结构中只包含 `status` 为 'active' 且 `is_system``false` 的节点。 - 响应的树结构中只包含 `status` 为 'active' 且 `is_system``false` 的节点。
### 场景5: 数据库为空 ### 场景5: 数据库为空
@ -153,7 +152,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree` - **请求路径**: `/api/dict/tree`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体 `data` 是一个空数组 `[]` - 响应体 `data` 是一个空数组 `[]`
## 4. 获取指定字典树接口 (GET /api/dict/tree/:code) ## 4. 获取指定字典树接口 (GET /api/dict/tree/:code)
@ -164,8 +163,8 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree/user_gender` - **请求路径**: `/api/dict/tree/user_gender`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体 `data` 是一个数组,仅包含一个根节点(即 `user_gender` 字典项)。 - 响应体 `data` 是一个数组,仅包含一个根节点(即 `user_gender` 字典项)。
- 该根节点包含其所有的子孙节点,形成一棵完整的子树。 - 该根节点包含其所有的子孙节点,形成一棵完整的子树。
### 场景2: 失败 - code 不存在 ### 场景2: 失败 - code 不存在
@ -174,7 +173,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree/non_existent_code` - **请求路径**: `/api/dict/tree/non_existent_code`
- **预期响应 (404 Not Found)**: - **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "字典代码不存在"。 - 响应体包含错误信息,提示 "字典代码不存在"。
### 场景3: 带查询参数过滤 ### 场景3: 带查询参数过滤
@ -183,7 +182,7 @@
- **请求方法**: `GET` - **请求方法**: `GET`
- **请求路径**: `/api/dict/tree/user_gender?status=active` - **请求路径**: `/api/dict/tree/user_gender?status=active`
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 返回的 `user_gender` 子树中,只包含状态为 `active` 的节点。 - 返回的 `user_gender` 子树中,只包含状态为 `active` 的节点。
## 5. 更新字典项内容接口 (PUT /api/dict/:id) ## 5. 更新字典项内容接口 (PUT /api/dict/:id)
@ -194,15 +193,15 @@
- **请求方法**: `PUT` - **请求方法**: `PUT`
- **请求路径**: `/api/dict/101` - **请求路径**: `/api/dict/101`
- **请求体**: - **请求体**:
```json ```json
{ {
"name": "更新后的字典名称", "name": "更新后的字典名称",
"description": "这是更新后的描述信息。" "description": "这是更新后的描述信息。"
} }
``` ```
- **预期响应 (200 OK)**: - **预期响应 (200 OK)**:
- 响应体包含更新后的完整字典项信息。 - 响应体包含更新后的完整字典项信息。
- 数据库中对应记录的 `name``description` 字段已更新。 - 数据库中对应记录的 `name``description` 字段已更新。
### 场景2: 失败 - ID 不存在 ### 场景2: 失败 - ID 不存在
@ -211,13 +210,13 @@
- **请求方法**: `PUT` - **请求方法**: `PUT`
- **请求路径**: `/api/dict/99999` - **请求路径**: `/api/dict/99999`
- **请求体**: - **请求体**:
```json ```json
{ {
"name": "任意名称" "name": "任意名称"
} }
``` ```
- **预期响应 (404 Not Found)**: - **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "字典项不存在"。 - 响应体包含错误信息,提示 "字典项不存在"。
### 场景3: 失败 - code 冲突 ### 场景3: 失败 - code 冲突
@ -228,13 +227,13 @@
- **请求方法**: `PUT` - **请求方法**: `PUT`
- **请求路径**: `/api/dict/101` - **请求路径**: `/api/dict/101`
- **请求体**: - **请求体**:
```json ```json
{ {
"code": "dict_b" "code": "dict_b"
} }
``` ```
- **预期响应 (409 Conflict)**: - **预期响应 (409 Conflict)**:
- 响应体包含错误信息,提示 "字典代码已存在"。 - 响应体包含错误信息,提示 "字典代码已存在"。
### 场景4: 失败 - name 同级冲突 ### 场景4: 失败 - name 同级冲突
@ -245,10 +244,10 @@
- **请求方法**: `PUT` - **请求方法**: `PUT`
- **请求路径**: `/api/dict/101` - **请求路径**: `/api/dict/101`
- **请求体**: - **请求体**:
```json ```json
{ {
"name": "name_b" "name": "name_b"
} }
``` ```
- **预期响应 (409 Conflict)**: - **预期响应 (409 Conflict)**:
- 响应体包含错误信息,提示 "同级下字典名称已存在"。 - 响应体包含错误信息,提示 "同级下字典名称已存在"。

115
src/modules/dict/test.md Normal file
View File

@ -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)**:
- 响应体包含错误信息,提示 "排序项列表不能为空"。

View File

@ -12,7 +12,7 @@ CREATE TABLE `sys_dict` (
`code` VARCHAR(50) NOT NULL COMMENT '字典代码,唯一标识', `code` VARCHAR(50) NOT NULL COMMENT '字典代码,唯一标识',
`name` VARCHAR(100) NOT NULL COMMENT '字典名称', `name` VARCHAR(100) NOT NULL COMMENT '字典名称',
`value` VARCHAR(200) NULL COMMENT '字典值(叶子节点才有值)', `value` VARCHAR(200) NULL COMMENT '字典值(叶子节点才有值)',
`description` VARCHAR(500) NULL COMMENT '字典描述', `description` VARCHAR(200) NULL COMMENT '字典描述',
`icon` VARCHAR(100) NULL COMMENT '图标CSS类名或图标路径', `icon` VARCHAR(100) NULL COMMENT '图标CSS类名或图标路径',
`pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID0表示顶级', `pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID0表示顶级',
`level` INT NOT NULL DEFAULT 1 COMMENT '层级深度1为顶级', `level` INT NOT NULL DEFAULT 1 COMMENT '层级深度1为顶级',
@ -143,12 +143,13 @@ CREATE TABLE `sys_dict` (
- [x] 6.5 扩展 `dict.controller.ts` - 实现更新字典项路由 - [x] 6.5 扩展 `dict.controller.ts` - 实现更新字典项路由
- [x] 6.6 更新 `dict.test.md` - 添加更新字典项测试用例 - [x] 6.6 更新 `dict.test.md` - 添加更新字典项测试用例
- [ ] 7.0 字典项排序接口 (PUT /api/dict/sort) - [x] 7.0 字典项排序接口 (PUT /api/dict/sort)
- [ ] 7.1 更新 `dict.docs.md` - 添加字典项排序业务逻辑 - [x] 7.1 更新 `dict.docs.md` - 添加字典项排序业务逻辑
- [ ] 7.2 扩展 `dict.schema.ts` - 定义字典项排序Schema - [x] 7.2 扩展 `dict.schema.ts` - 定义字典项排序Schema
- [ ] 7.3 扩展 `dict.response.ts` - 定义字典项排序响应格式 - [x] 7.3 扩展 `dict.response.ts` - 定义字典项排序响应格式
- [ ] 7.4 扩展 `dict.service.ts` - 实现字典项排序业务逻辑 - [x] 7.4 扩展 `dict.service.ts` - 实现字典项排序业务逻辑
- [ ] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由 - [x] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由
- [x] 7.6 在 `dict.test.md` 中添加排序接口测试用例
- [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id) - [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id)
- [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除) - [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)