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 字典模块的路由控制器,仅实现创建字典项接口
|
||||
*/
|
||||
|
||||
import { Elysia } from 'elysia';
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { dictService } from './dict.service';
|
||||
import {
|
||||
CreateDictSchema,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
UpdateDictBodySchema,
|
||||
UpdateDictParamsSchema,
|
||||
SortDictSchema,
|
||||
DeleteDictSchema,
|
||||
} from './dict.schema';
|
||||
import {
|
||||
CreateDictResponsesSchema,
|
||||
@ -26,6 +27,7 @@ import {
|
||||
GetDictTreeResponsesSchema,
|
||||
UpdateDictResponsesSchema,
|
||||
SortDictResponsesSchema,
|
||||
DeleteDictResponsesSchema,
|
||||
} from './dict.response';
|
||||
import { tags } from '@/constants/swaggerTags';
|
||||
|
||||
@ -125,4 +127,28 @@ export const dictController = new Elysia()
|
||||
operationId: 'sortDictItems',
|
||||
},
|
||||
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]>;
|
||||
|
||||
/**
|
||||
* 删除字典项接口响应组合
|
||||
* @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,
|
||||
UpdateDictBodyRequest,
|
||||
SortDictRequest,
|
||||
DeleteDictRequest,
|
||||
} from './dict.schema';
|
||||
import type {
|
||||
CreateDictSuccessType,
|
||||
@ -27,6 +28,7 @@ import type {
|
||||
GetDictTreeSuccessType,
|
||||
UpdateDictSuccessType,
|
||||
SortDictSuccessType,
|
||||
DeleteDictSuccessType,
|
||||
} from './dict.response';
|
||||
|
||||
/**
|
||||
@ -490,6 +492,105 @@ export class DictService {
|
||||
Logger.info('字典项排序成功');
|
||||
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)**:
|
||||
- 响应体包含错误信息,提示 "排序项列表不能为空"。
|
||||
## 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.6 在 `dict.test.md` 中添加排序接口测试用例
|
||||
|
||||
- [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id)
|
||||
- [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)
|
||||
- [ ] 8.2 扩展 `dict.schema.ts` - 定义删除字典项Schema
|
||||
- [ ] 8.3 扩展 `dict.response.ts` - 定义删除字典项响应格式
|
||||
- [ ] 8.4 扩展 `dict.service.ts` - 实现删除字典项业务逻辑
|
||||
- [ ] 8.5 扩展 `dict.controller.ts` - 实现删除字典项路由
|
||||
- [x] 8.0 删除字典项接口 (DELETE /api/dict/:id)
|
||||
- [x] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)
|
||||
- [x] 8.2 扩展 `dict.schema.ts` - 定义删除字典项Schema
|
||||
- [x] 8.3 扩展 `dict.response.ts` - 定义删除字典项响应格式
|
||||
- [x] 8.4 扩展 `dict.service.ts` - 实现删除字典项业务逻辑
|
||||
- [x] 8.5 扩展 `dict.controller.ts` - 实现删除字典项路由
|
||||
- [x] 8.6 在 `dict.test.md` 中添加删除接口测试用例
|
||||
|
||||
### 阶段4:缓存机制和优化
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user