feat(dict): 新增软删除字典项接口

- 实现了 DELETE /api/dict/:id 接口,用于软删除字典项。
- 支持 `cascade=true` 查询参数,用于级联软删除所有子孙节点。
- 添加了 DeleteDictSchema 用于请求验证。
- 添加了 DeleteDictResponsesSchema 用于 API 文档。
- 服务层实现包含对系统字典的保护、以及对有子节点的非级联删除的防护。
- 所有数据库查询和更新均使用原生 SQL 以规避 ORM 类型问题。
- 在控制器中添加了新路由。
- 在 dict.test.md 中为删除接口添加了全面的测试用例。
This commit is contained in:
expressgy 2025-07-07 21:59:52 +08:00
parent 5580941ffb
commit cd49a78678
5 changed files with 241 additions and 9 deletions

View File

@ -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,
},
);

View File

@ -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]>;

View File

@ -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);
}
}
}
// 导出单例实例

View File

@ -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)**:
- 响应体包含错误信息,提示 "字典项不存在"。

View File

@ -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缓存机制和优化