feat(dict): 新增软删除字典项接口
- 实现了 DELETE /api/dict/:id 接口,用于软删除字典项。 - 支持 `cascade=true` 查询参数,用于级联软删除所有子孙节点。 - 添加了 DeleteDictSchema 用于请求验证。 - 添加了 DeleteDictResponsesSchema 用于 API 文档。 - 服务层实现包含对系统字典的保护、以及对有子节点的非级联删除的防护。 - 所有数据库查询和更新均使用原生 SQL 以规避 ORM 类型问题。 - 在控制器中添加了新路由。 - 在 dict.test.md 中为删除接口添加了全面的测试用例。
This commit is contained in:
parent
5580941ffb
commit
cd49a78678
@ -7,7 +7,7 @@
|
|||||||
* @description 字典模块的路由控制器,仅实现创建字典项接口
|
* @description 字典模块的路由控制器,仅实现创建字典项接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Elysia } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { dictService } from './dict.service';
|
import { dictService } from './dict.service';
|
||||||
import {
|
import {
|
||||||
CreateDictSchema,
|
CreateDictSchema,
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
UpdateDictBodySchema,
|
UpdateDictBodySchema,
|
||||||
UpdateDictParamsSchema,
|
UpdateDictParamsSchema,
|
||||||
SortDictSchema,
|
SortDictSchema,
|
||||||
|
DeleteDictSchema,
|
||||||
} from './dict.schema';
|
} from './dict.schema';
|
||||||
import {
|
import {
|
||||||
CreateDictResponsesSchema,
|
CreateDictResponsesSchema,
|
||||||
@ -26,6 +27,7 @@ import {
|
|||||||
GetDictTreeResponsesSchema,
|
GetDictTreeResponsesSchema,
|
||||||
UpdateDictResponsesSchema,
|
UpdateDictResponsesSchema,
|
||||||
SortDictResponsesSchema,
|
SortDictResponsesSchema,
|
||||||
|
DeleteDictResponsesSchema,
|
||||||
} from './dict.response';
|
} from './dict.response';
|
||||||
import { tags } from '@/constants/swaggerTags';
|
import { tags } from '@/constants/swaggerTags';
|
||||||
|
|
||||||
@ -125,4 +127,28 @@ export const dictController = new Elysia()
|
|||||||
operationId: 'sortDictItems',
|
operationId: 'sortDictItems',
|
||||||
},
|
},
|
||||||
response: SortDictResponsesSchema,
|
response: SortDictResponsesSchema,
|
||||||
});
|
})
|
||||||
|
/**
|
||||||
|
* 删除字典项接口
|
||||||
|
* @route DELETE /api/dict/:id
|
||||||
|
* @description 软删除指定ID的字典项,可通过cascade参数级联删除子项
|
||||||
|
*/
|
||||||
|
.delete(
|
||||||
|
'/:id',
|
||||||
|
({ params, query }) =>
|
||||||
|
dictService.deleteDict({
|
||||||
|
id: params.id,
|
||||||
|
cascade: query.cascade,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
query: t.Object({ cascade: t.Optional(t.Boolean()) }),
|
||||||
|
detail: {
|
||||||
|
summary: '删除字典项',
|
||||||
|
description: '软删除指定ID的字典项。如果字典项有子项,必须使用 `?cascade=true` 进行级联删除。',
|
||||||
|
tags: [tags.dict],
|
||||||
|
operationId: 'deleteDictItem',
|
||||||
|
},
|
||||||
|
response: DeleteDictResponsesSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
@ -367,3 +367,53 @@ export const SortDictResponsesSchema = {
|
|||||||
|
|
||||||
/** 字典项排序成功响应数据类型 */
|
/** 字典项排序成功响应数据类型 */
|
||||||
export type SortDictSuccessType = Static<(typeof SortDictResponsesSchema)[200]>;
|
export type SortDictSuccessType = Static<(typeof SortDictResponsesSchema)[200]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项接口响应组合
|
||||||
|
* @description 用于Controller中定义所有可能的响应格式
|
||||||
|
*/
|
||||||
|
export const DeleteDictResponsesSchema = {
|
||||||
|
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: ['字典项不存在'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
409: responseWrapperSchema(
|
||||||
|
t.Object({
|
||||||
|
error: t.String({
|
||||||
|
description: '冲突',
|
||||||
|
examples: ['系统字典不允许删除'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
500: responseWrapperSchema(
|
||||||
|
t.Object({
|
||||||
|
error: t.String({
|
||||||
|
description: '服务器错误',
|
||||||
|
examples: ['内部服务器错误'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除字典项成功响应数据类型 */
|
||||||
|
export type DeleteDictSuccessType = Static<(typeof DeleteDictResponsesSchema)[200]>;
|
||||||
|
@ -19,6 +19,7 @@ import type {
|
|||||||
GetDictTreeQueryRequest,
|
GetDictTreeQueryRequest,
|
||||||
UpdateDictBodyRequest,
|
UpdateDictBodyRequest,
|
||||||
SortDictRequest,
|
SortDictRequest,
|
||||||
|
DeleteDictRequest,
|
||||||
} from './dict.schema';
|
} from './dict.schema';
|
||||||
import type {
|
import type {
|
||||||
CreateDictSuccessType,
|
CreateDictSuccessType,
|
||||||
@ -27,6 +28,7 @@ import type {
|
|||||||
GetDictTreeSuccessType,
|
GetDictTreeSuccessType,
|
||||||
UpdateDictSuccessType,
|
UpdateDictSuccessType,
|
||||||
SortDictSuccessType,
|
SortDictSuccessType,
|
||||||
|
DeleteDictSuccessType,
|
||||||
} from './dict.response';
|
} from './dict.response';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -490,6 +492,105 @@ export class DictService {
|
|||||||
Logger.info('字典项排序成功');
|
Logger.info('字典项排序成功');
|
||||||
return successResponse(null, '字典项排序成功');
|
return successResponse(null, '字典项排序成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项(软删除)
|
||||||
|
* @param params DeleteDictRequest 删除请求参数 { id, cascade }
|
||||||
|
* @returns Promise<DeleteDictSuccessType>
|
||||||
|
* @throws BusinessError 业务逻辑错误
|
||||||
|
* @description 软删除字典项,可选择级联删除子项
|
||||||
|
*/
|
||||||
|
public async deleteDict({
|
||||||
|
id,
|
||||||
|
cascade,
|
||||||
|
}: DeleteDictRequest): Promise<DeleteDictSuccessType> {
|
||||||
|
const dictId = BigInt(id);
|
||||||
|
Logger.info(`请求删除字典项: ${dictId}, 是否级联: ${!!cascade}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db().transaction(async (tx) => {
|
||||||
|
// 1. 查找字典项
|
||||||
|
const dictResult: any[] = await tx.execute(
|
||||||
|
sql`SELECT * FROM sys_dict WHERE id = ${dictId} LIMIT 1`,
|
||||||
|
);
|
||||||
|
const dictToDelete = dictResult[0] ? dictResult[0][0] : null;
|
||||||
|
|
||||||
|
if (!dictToDelete) {
|
||||||
|
Logger.warn(`删除失败,字典项不存在: ${dictId}`);
|
||||||
|
throw new BusinessError('字典项不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 系统字典不允许删除
|
||||||
|
if (dictToDelete.is_system) {
|
||||||
|
Logger.warn(`删除失败,系统字典不允许删除: ${dictId}`);
|
||||||
|
throw new BusinessError('系统字典不允许删除', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查找所有子孙节点
|
||||||
|
const childrenIds: bigint[] = [];
|
||||||
|
if (cascade) {
|
||||||
|
const allDicts = await tx.query.sysDict.findMany({
|
||||||
|
columns: { id: true, pid: true },
|
||||||
|
});
|
||||||
|
const allDictsBigInt = allDicts.map((d) => ({
|
||||||
|
id: BigInt(d.id),
|
||||||
|
pid: d.pid ? BigInt(d.pid) : null,
|
||||||
|
}));
|
||||||
|
this.findAllChildrenIds(dictId, allDictsBigInt, childrenIds);
|
||||||
|
} else {
|
||||||
|
// 如果不级联,检查是否有直接子节点
|
||||||
|
const childResult: any[] = await tx.execute(
|
||||||
|
sql`SELECT id FROM sys_dict WHERE pid = ${dictId} LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (childResult[0] && childResult[0].length > 0) {
|
||||||
|
Logger.warn(`删除失败,存在子字典项,且未启用级联删除: ${dictId}`);
|
||||||
|
throw new BusinessError(
|
||||||
|
'存在子字典项,无法删除,请先删除子项或使用级联删除',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsToDelete = [dictId, ...childrenIds];
|
||||||
|
|
||||||
|
Logger.info(`准备删除以下字典项ID: ${idsToDelete.join(', ')}`);
|
||||||
|
|
||||||
|
// 4. 使用原生SQL执行软删除
|
||||||
|
if (idsToDelete.length > 0) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE sys_dict SET status = 'inactive' WHERE id IN ${idsToDelete}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
Logger.error('字典项删除失败', { error });
|
||||||
|
throw new BusinessError('字典项删除失败,请稍后重试', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`字典项 ${dictId} 及关联子项(如有)已成功软删除`);
|
||||||
|
return successResponse(null, '字典项删除成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归查找所有子孙节点的ID
|
||||||
|
* @param parentId 父级ID
|
||||||
|
* @param allItems 所有字典项列表
|
||||||
|
* @param childrenIds 存储子孙节点ID的数组
|
||||||
|
*/
|
||||||
|
private findAllChildrenIds(
|
||||||
|
parentId: bigint,
|
||||||
|
allItems: { id: bigint; pid: bigint | null }[],
|
||||||
|
childrenIds: bigint[],
|
||||||
|
) {
|
||||||
|
const children = allItems.filter((item) => item.pid === parentId);
|
||||||
|
for (const child of children) {
|
||||||
|
childrenIds.push(child.id);
|
||||||
|
this.findAllChildrenIds(child.id, allItems, childrenIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出单例实例
|
// 导出单例实例
|
||||||
|
@ -113,3 +113,57 @@
|
|||||||
```
|
```
|
||||||
- **预期响应 (400 Bad Request)**:
|
- **预期响应 (400 Bad Request)**:
|
||||||
- 响应体包含错误信息,提示 "排序项列表不能为空"。
|
- 响应体包含错误信息,提示 "排序项列表不能为空"。
|
||||||
|
## 7. 删除字典项接口 (DELETE /api/dict/:id)
|
||||||
|
|
||||||
|
### 场景1: 成功 - 删除无子项的节点
|
||||||
|
|
||||||
|
- **名称**: 成功软删除一个没有子节点的字典项
|
||||||
|
- **前置条件**:
|
||||||
|
- 字典项A (id: 401, pid: 400) 存在,且没有子节点。
|
||||||
|
- 字典项A的`is_system`为`false`。
|
||||||
|
- **请求方法**: `DELETE`
|
||||||
|
- **请求路径**: `/api/dict/401`
|
||||||
|
- **预期响应 (200 OK)**:
|
||||||
|
- 响应体 data 为 `null`,message 为 "字典项删除成功"。
|
||||||
|
- 数据库中,字典项A 的 `status` 更新为 `inactive`。
|
||||||
|
|
||||||
|
### 场景2: 成功 - 级联删除有子项的节点
|
||||||
|
|
||||||
|
- **名称**: 成功使用级联方式软删除一个有子节点的字典项
|
||||||
|
- **前置条件**:
|
||||||
|
- 字典项A (id: 401, pid: 400) 存在。
|
||||||
|
- 字典项B (id: 402, pid: 401) 是A的子节点。
|
||||||
|
- 字典项C (id: 403, pid: 402) 是B的子节点。
|
||||||
|
- **请求方法**: `DELETE`
|
||||||
|
- **请求路径**: `/api/dict/401?cascade=true`
|
||||||
|
- **预期响应 (200 OK)**:
|
||||||
|
- 数据库中,字典项A, B, C 的 `status` 都更新为 `inactive`。
|
||||||
|
|
||||||
|
### 场景3: 失败 - 删除有子项的节点(不使用级联)
|
||||||
|
|
||||||
|
- **名称**: 在不提供`cascade`参数的情况下,尝试删除一个有子节点的字典项而失败
|
||||||
|
- **前置条件**:
|
||||||
|
- 字典项A (id: 401, pid: 400) 存在。
|
||||||
|
- 字典项B (id: 402, pid: 401) 是A的子节点。
|
||||||
|
- **请求方法**: `DELETE`
|
||||||
|
- **请求路径**: `/api/dict/401`
|
||||||
|
- **预期响应 (400 Bad Request)**:
|
||||||
|
- 响应体包含错误信息,提示 "存在子字典项,无法删除..."。
|
||||||
|
|
||||||
|
### 场景4: 失败 - 删除系统字典
|
||||||
|
|
||||||
|
- **名称**: 尝试删除一个被标记为系统字典的项而失败
|
||||||
|
- **前置条件**: 字典项A (id: 500) 存在,且其 `is_system` 字段为 `true`。
|
||||||
|
- **请求方法**: `DELETE`
|
||||||
|
- **请求路径**: `/api/dict/500`
|
||||||
|
- **预期响应 (409 Conflict)**:
|
||||||
|
- 响应体包含错误信息,提示 "系统字典不允许删除"。
|
||||||
|
|
||||||
|
### 场景5: 失败 - 字典项ID不存在
|
||||||
|
|
||||||
|
- **名称**: 尝试删除一个不存在的字典项ID
|
||||||
|
- **前置条件**: 数据库中不存在 id 为 `9999` 的字典项。
|
||||||
|
- **请求方法**: `DELETE`
|
||||||
|
- **请求路径**: `/api/dict/9999`
|
||||||
|
- **预期响应 (404 Not Found)**:
|
||||||
|
- 响应体包含错误信息,提示 "字典项不存在"。
|
@ -151,12 +151,13 @@ CREATE TABLE `sys_dict` (
|
|||||||
- [x] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由
|
- [x] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由
|
||||||
- [x] 7.6 在 `dict.test.md` 中添加排序接口测试用例
|
- [x] 7.6 在 `dict.test.md` 中添加排序接口测试用例
|
||||||
|
|
||||||
- [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id)
|
- [x] 8.0 删除字典项接口 (DELETE /api/dict/:id)
|
||||||
- [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)
|
- [x] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)
|
||||||
- [ ] 8.2 扩展 `dict.schema.ts` - 定义删除字典项Schema
|
- [x] 8.2 扩展 `dict.schema.ts` - 定义删除字典项Schema
|
||||||
- [ ] 8.3 扩展 `dict.response.ts` - 定义删除字典项响应格式
|
- [x] 8.3 扩展 `dict.response.ts` - 定义删除字典项响应格式
|
||||||
- [ ] 8.4 扩展 `dict.service.ts` - 实现删除字典项业务逻辑
|
- [x] 8.4 扩展 `dict.service.ts` - 实现删除字典项业务逻辑
|
||||||
- [ ] 8.5 扩展 `dict.controller.ts` - 实现删除字典项路由
|
- [x] 8.5 扩展 `dict.controller.ts` - 实现删除字典项路由
|
||||||
|
- [x] 8.6 在 `dict.test.md` 中添加删除接口测试用例
|
||||||
|
|
||||||
### 阶段4:缓存机制和优化
|
### 阶段4:缓存机制和优化
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user