feat(dict): 新增字典项排序接口
- 实现了 PUT /api/dict/sort 接口,用于批量排序和移动字典项。 - 添加了 SortDictSchema 用于请求验证。 - 添加了 SortDictResponsesSchema 用于 API 文档。 - 使用原生 SQL 和 CASE 语句实现了 sortDict 服务层方法,进行高效的批量更新,解决了之前遇到的 ORM 类型问题。 - 在字典模块的控制器中添加了新路由。 - 在 dict.test.md 中为排序接口添加了全面的测试用例。
This commit is contained in:
parent
10ee246b7d
commit
5580941ffb
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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]>;
|
||||||
|
@ -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
|
||||||
|
.Transform(
|
||||||
|
t.String({
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 50,
|
maxLength: 50,
|
||||||
description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格',
|
description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格',
|
||||||
examples: ['user_status', 'order_type', 'system_config'],
|
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
|
||||||
|
.Transform(
|
||||||
|
t.String({
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
description: '字典名称,同级下唯一,自动去除两端空格',
|
description: '字典名称,同级下唯一,自动去除两端空格',
|
||||||
examples: ['用户状态', '订单类型', '系统配置'],
|
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'],
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
/** 父级ID,0表示顶级 */
|
/** 父级ID,0表示顶级 */
|
||||||
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(
|
||||||
@ -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: ['用户状态字典,包含激活、禁用、待审核等状态'],
|
||||||
}),
|
}),
|
||||||
|
@ -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;
|
||||||
@ -278,22 +295,22 @@ export class DictService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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([], '获取指定字典树成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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,12 +371,15 @@ export class DictService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) {
|
if (Object.keys(updateData).length === 0) {
|
||||||
return successResponse({
|
return successResponse(
|
||||||
|
{
|
||||||
...existing,
|
...existing,
|
||||||
id: String(existing.id),
|
id: String(existing.id),
|
||||||
pid: String(existing.pid),
|
pid: String(existing.pid),
|
||||||
isSystem: Boolean(existing.isSystem),
|
isSystem: Boolean(existing.isSystem),
|
||||||
}, '未提供任何更新内容');
|
},
|
||||||
|
'未提供任何更新内容',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 执行更新
|
// 4. 执行更新
|
||||||
@ -361,12 +389,106 @@ export class DictService {
|
|||||||
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,
|
...updated,
|
||||||
id: String(updated.id),
|
id: String(updated.id),
|
||||||
pid: String(updated.pid),
|
pid: String(updated.pid),
|
||||||
isSystem: Boolean(updated.isSystem),
|
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, '字典项排序成功');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +76,6 @@
|
|||||||
- **预期响应 (404 Not Found)**:
|
- **预期响应 (404 Not Found)**:
|
||||||
- 响应体包含错误信息,提示 "父级字典不存在"。
|
- 响应体包含错误信息,提示 "父级字典不存在"。
|
||||||
|
|
||||||
|
|
||||||
## 2. 获取字典项内容接口 (GET /api/dict/:id)
|
## 2. 获取字典项内容接口 (GET /api/dict/:id)
|
||||||
|
|
||||||
### 场景1: 成功获取存在的字典项
|
### 场景1: 成功获取存在的字典项
|
||||||
|
115
src/modules/dict/test.md
Normal file
115
src/modules/dict/test.md
Normal 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)**:
|
||||||
|
- 响应体包含错误信息,提示 "排序项列表不能为空"。
|
@ -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 '父级ID,0表示顶级',
|
`pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID,0表示顶级',
|
||||||
`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` - 添加删除字典项业务逻辑(软删除)
|
||||||
|
Loading…
Reference in New Issue
Block a user