feat(dict): 添加获取完整字典树接口

- **新增接口**
  - 添加 `GET /api/dict/tree` 接口,用于获取完整的字典树结构。
  - 支持通过 `status` 和 `is_system` 查询参数进行过滤。

- **Service层**
  - 在 `DictService` 中实现 `getDictTree` 方法,包含从数据库查询数据并将列表转换为树形结构的逻辑。
  - 优化了日志记录,修复了linter错误。

- **Schema和Response**
  - 在 `dict.schema.ts` 中定义了 `GetDictTreeQuerySchema` 用于验证查询参数。
  - 在 `dict.response.ts` 中使用 `t.Recursive` 定义了递归的 `DictTreeNodeSchema` 来描述树形响应结构。

- **文档**
  - 更新了 `dict.docs.md`,添加了获取完整字典树的业务逻辑描述。
  - 在 `dict.test.md` 中为新接口添加了详细的测试用例。
This commit is contained in:
expressgy 2025-07-07 21:25:27 +08:00
parent f9f75c9d2d
commit b11dfa522b
6 changed files with 454 additions and 27 deletions

View File

@ -9,8 +9,8 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { dictService } from './dict.service'; import { dictService } from './dict.service';
import { CreateDictSchema } from './dict.schema'; import { CreateDictSchema, GetDictByIdSchema, GetDictTreeQuerySchema } from './dict.schema';
import { CreateDictResponsesSchema } from './dict.response'; import { CreateDictResponsesSchema, GetDictByIdResponsesSchema, GetDictTreeResponsesSchema } from './dict.response';
import { tags } from '@/constants/swaggerTags'; import { tags } from '@/constants/swaggerTags';
/** /**
@ -32,4 +32,34 @@ export const dictController = new Elysia()
operationId: 'createDict', operationId: 'createDict',
}, },
response: CreateDictResponsesSchema, response: CreateDictResponsesSchema,
})
/**
*
* @route GET /api/dict/:id
* @description ID获取单个字典项的详细内容
*/
.get('/:id', ({ params }) => dictService.getDictById(params.id), {
params: GetDictByIdSchema,
detail: {
summary: '获取字典项内容',
description: '根据ID获取单个字典项的详细内容',
tags: [tags.dict],
operationId: 'getDictById',
},
response: GetDictByIdResponsesSchema,
})
/**
*
* @route GET /api/dict/tree
* @description
*/
.get('/tree', ({ query }) => dictService.getDictTree(query), {
query: GetDictTreeQuerySchema,
detail: {
summary: '获取完整字典树',
description: '获取完整的字典树结构,可根据状态和是否系统字典进行过滤',
tags: [tags.dict],
operationId: 'getDictTree',
},
response: GetDictTreeResponsesSchema,
}); });

View File

@ -128,3 +128,140 @@ export const CreateDictResponsesSchema = {
/** 创建字典项成功响应数据类型 */ /** 创建字典项成功响应数据类型 */
export type CreateDictSuccessType = Static<(typeof CreateDictResponsesSchema)[200]>; export type CreateDictSuccessType = Static<(typeof CreateDictResponsesSchema)[200]>;
/**
*
* @description
*/
export const GetDictByIdSuccessSchema = CreateDictSuccessSchema;
/**
*
* @description Controller中定义所有可能的响应格式
*/
export const GetDictByIdResponsesSchema = {
200: responseWrapperSchema(GetDictByIdSuccessSchema),
404: responseWrapperSchema(
t.Object({
error: t.String({
description: '资源不存在',
examples: ['字典项不存在'],
}),
}),
),
500: responseWrapperSchema(
t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误'],
}),
}),
),
};
/** 获取字典项成功响应数据类型 */
export type GetDictByIdSuccessType = Static<(typeof GetDictByIdResponsesSchema)[200]>;
/**
*
* @description children数组用于表示子节点
*/
export const DictTreeNodeSchema = t.Recursive(
(self) =>
t.Object({
id: t.String({
description: '字典项IDbigint类型以字符串返回防止精度丢失',
examples: ['1', '2', '100'],
}),
code: t.String({
description: '字典代码',
examples: ['user_status'],
}),
name: t.String({
description: '字典名称',
examples: ['用户状态'],
}),
value: t.Optional(
t.String({
description: '字典值',
examples: ['active'],
}),
),
description: t.Optional(
t.String({
description: '字典描述',
examples: ['用户状态字典'],
}),
),
icon: t.Optional(
t.String({
description: '图标',
examples: ['icon-user'],
}),
),
pid: t.String({
description: '父级IDbigint类型以字符串返回',
examples: ['0', '1'],
}),
level: t.Number({
description: '层级深度',
examples: [1, 2],
}),
sortOrder: t.Number({
description: '排序号',
examples: [0, 1, 10],
}),
status: t.String({
description: '状态',
examples: ['active', 'inactive'],
}),
isSystem: t.Boolean({
description: '是否系统字典',
examples: [true, false],
}),
color: t.Optional(
t.String({
description: '颜色标识',
examples: ['#1890ff'],
}),
),
extra: t.Optional(
t.Record(t.String(), t.Any(), {
description: '扩展字段',
examples: [{ key1: 'value1' }],
}),
),
createdAt: t.String({
description: '创建时间',
examples: ['2024-12-19T10:30:00Z'],
}),
updatedAt: t.String({
description: '更新时间',
examples: ['2024-12-19T10:30:00Z'],
}),
children: t.Optional(t.Array(self)),
}),
{
$id: 'DictTreeNode',
description: '字典树节点',
},
);
/**
*
* @description Controller中定义所有可能的响应格式
*/
export const GetDictTreeResponsesSchema = {
200: responseWrapperSchema(t.Array(DictTreeNodeSchema)),
500: responseWrapperSchema(
t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误'],
}),
}),
),
};
/** 获取完整字典树成功响应数据类型 */
export type GetDictTreeSuccessType = Static<(typeof GetDictTreeResponsesSchema)[200]>;

View File

@ -4,17 +4,17 @@
* @date 2024-12-19 * @date 2024-12-19
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
* @description * @description
*/ */
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 } from 'drizzle-orm'; import { eq, and, max, asc } 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 { CreateDictRequest } from './dict.schema'; import type { CreateDictRequest, GetDictTreeQueryRequest } from './dict.schema';
import type { CreateDictSuccessType } from './dict.response'; import type { CreateDictSuccessType, GetDictByIdSuccessType, GetDictTreeSuccessType } from './dict.response';
/** /**
* *
@ -121,6 +121,102 @@ export class DictService {
'创建字典项成功', '创建字典项成功',
); );
} }
/**
*
* @param id ID
* @returns Promise<GetDictByIdSuccessType>
* @throws BusinessError
*/
public async getDictById(id: string): Promise<GetDictByIdSuccessType> {
Logger.info(`获取字典项内容:${id}`);
const dictArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1);
if (!dictArr || dictArr.length === 0) {
Logger.warn(`字典项不存在:${id}`);
throw new BusinessError(`字典项不存在:${id}`, 404);
}
const dict = dictArr[0]!;
Logger.info(`获取字典项内容成功:${id}`);
return successResponse({
id: String(dict.id),
code: dict.code,
name: dict.name,
value: dict.value,
description: dict.description,
icon: dict.icon,
pid: String(dict.pid),
level: dict.level,
sortOrder: dict.sortOrder,
status: dict.status,
isSystem: Boolean(dict.isSystem),
color: dict.color,
extra: dict.extra,
createdAt: dict.createdAt,
updatedAt: dict.updatedAt,
});
}
/**
*
* @param query
* @returns Promise<GetDictTreeSuccessType>
*/
public async getDictTree(query: GetDictTreeQueryRequest): Promise<GetDictTreeSuccessType> {
Logger.info(`获取完整字典树, 查询参数: ${JSON.stringify(query)}`);
const conditions = [];
if (query.status && query.status !== 'all') {
conditions.push(eq(sysDict.status, query.status));
}
if (query.isSystem && query.isSystem !== 'all') {
conditions.push(eq(sysDict.isSystem, query.isSystem === 'true' ? 1 : 0));
}
const dictList = await db().select().from(sysDict).where(and(...conditions)).orderBy(asc(sysDict.sortOrder));
if (!dictList || dictList.length === 0) {
return successResponse([], '获取完整字典树成功');
}
type DictNode = Omit<typeof dictList[0], 'id' | 'pid' | 'isSystem'> & {
id: string;
pid: string;
isSystem: boolean;
children?: DictNode[];
};
const nodeMap = new Map<string, DictNode>();
const tree: DictNode[] = [];
for (const item of dictList) {
const node: DictNode = {
...item,
id: String(item.id),
pid: String(item.pid),
isSystem: Boolean(item.isSystem),
children: [],
};
nodeMap.set(node.id, node);
}
for (const node of nodeMap.values()) {
if (node.pid && node.pid !== '0' && nodeMap.has(node.pid)) {
const parent = nodeMap.get(node.pid);
if (parent && parent.children) {
parent.children.push(node);
}
} else {
tree.push(node);
}
}
return successResponse(tree, '获取完整字典树成功');
}
} }
// 导出单例实例 // 导出单例实例

View File

@ -0,0 +1,156 @@
# 字典模块测试用例文档
## 1. 创建字典项接口 (POST /api/dict)
### 场景1: 成功创建顶级字典项
- **名称**: 成功创建一个顶级的、无父级的字典项
- **前置条件**: 数据库中不存在 code 为 `test_root` 的字典项
- **请求方法**: `POST`
- **请求路径**: `/api/dict`
- **请求体**:
```json
{
"code": "test_root",
"name": "测试顶级字典",
"description": "这是一个用于测试的顶级字典项",
"status": "active"
}
```
- **预期响应 (200 OK)**:
- 响应体包含创建成功的字典项信息,`pid` 为 '0'`level` 为 1。
- 数据库中存在该条记录。
### 场景2: 成功创建子级字典项
- **名称**: 成功创建一个子级的字典项
- **前置条件**:
1. 数据库中存在一个 code 为 `test_root` 的字典项,其 id 为 `100`
2. 数据库中不存在 code 为 `test_child` 的字典项。
- **请求方法**: `POST`
- **请求路径**: `/api/dict`
- **请求体**:
```json
{
"code": "test_child",
"name": "测试子级字典",
"pid": "100",
"description": "这是一个用于测试的子级字典项",
"status": "active"
}
```
- **预期响应 (200 OK)**:
- 响应体包含创建成功的字典项信息,`pid` 为 '100'`level` 为父级 level + 1。
- 数据库中存在该条记录。
### 场景3: 失败 - code冲突
- **名称**: 因 code 重复导致创建失败
- **前置条件**: 数据库中已存在 code 为 `test_root` 的字典项。
- **请求方法**: `POST`
- **请求路径**: `/api/dict`
- **请求体**:
```json
{
"code": "test_root",
"name": "重复Code测试"
}
```
- **预期响应 (409 Conflict)**:
- 响应体包含错误信息,提示 "字典代码已存在"。
### 场景4: 失败 - 父级不存在
- **名称**: 因父级 ID 不存在导致创建失败
- **前置条件**: -
- **请求方法**: `POST`
- **请求路径**: `/api/dict`
- **请求体**:
```json
{
"code": "test_no_parent",
"name": "无效父级测试",
"pid": "99999"
}
```
- **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "父级字典不存在"。
## 2. 获取字典项内容接口 (GET /api/dict/:id)
### 场景1: 成功获取存在的字典项
- **名称**: 根据存在的 ID 成功获取字典项
- **前置条件**: 数据库中存在一个 id 为 `100` 的字典项。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/100`
- **预期响应 (200 OK)**:
- 响应体包含 id 为 `100` 的字典项的完整信息。
### 场景2: 失败 - 字典项不存在
- **名称**: 因 ID 不存在导致获取失败
- **前置条件**: 数据库中不存在 id 为 `99999` 的字典项。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/99999`
- **预期响应 (404 Not Found)**:
- 响应体包含错误信息,提示 "字典项不存在"。
### 场景3: 失败 - ID格式错误
- **名称**: 因 ID 格式无效(非数字)导致获取失败
- **前置条件**: -
- **请求方法**: `GET`
- **请求路径**: `/api/dict/abc`
- **预期响应 (400 Bad Request / 422 Unprocessable Entity)**:
- 响应体包含参数验证错误信息。
## 3. 获取完整字典树接口 (GET /api/dict/tree)
### 场景1: 成功获取所有字典
- **名称**: 成功获取完整的字典树结构
- **前置条件**: 数据库中存在多个字典项,可以构成树形结构。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/tree`
- **预期响应 (200 OK)**:
- 响应体 `data` 是一个数组,每个元素是一个顶层字典节点。
- 每个节点包含 `children` 数组,递归地包含其子节点。
- 节点按 `sortOrder` 排序。
### 场景2: 根据状态过滤 (active)
- **名称**: 获取所有状态为 'active' 的字典树
- **前置条件**: 数据库中同时存在 'active' 和 'inactive' 状态的字典项。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?status=active`
- **预期响应 (200 OK)**:
- 响应的树结构中只包含 `status` 为 'active' 的节点。
### 场景3: 根据系统字典过滤 (true)
- **名称**: 获取所有系统字典的字典树
- **前置条件**: 数据库中同时存在系统字典 (`is_system`=true) 和非系统字典 (`is_system`=false)。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?is_system=true`
- **预期响应 (200 OK)**:
- 响应的树结构中只包含 `is_system``true` 的节点。
### 场景4: 组合过滤
- **名称**: 获取所有状态为 'active' 且非系统字典的字典树
- **前置条件**: 数据库中存在满足各种组合条件的字典项。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/tree?status=active&is_system=false`
- **预期响应 (200 OK)**:
- 响应的树结构中只包含 `status` 为 'active' 且 `is_system``false` 的节点。
### 场景5: 数据库为空
- **名称**: 在没有字典数据的情况下获取字典树
- **前置条件**: 数据库 `sys_dict` 表为空。
- **请求方法**: `GET`
- **请求路径**: `/api/dict/tree`
- **预期响应 (200 OK)**:
- 响应体 `data` 是一个空数组 `[]`

View File

@ -75,6 +75,14 @@ export const errorHandlerPlugin = (app: Elysia) =>
errors: error.message || error.response.message || error.response, errors: error.message || error.response.message || error.response,
}; };
} }
case 404: {
set.status = code;
return {
code: error.code,
message: '资源未找到',
errors: error.message || error.response.message || error.response,
};
}
case 408: { case 408: {
set.status = code; set.status = code;
return { return {

View File

@ -101,29 +101,29 @@ CREATE TABLE `sys_dict` (
### 阶段2字典模块核心接口开发 ### 阶段2字典模块核心接口开发
- [ ] 2.0 创建字典项接口 (POST /api/dict) - [x] 2.0 创建字典项接口 (POST /api/dict)
- [ ] 2.1 生成接口业务逻辑文档,写入 `dict.docs.md` - [x] 2.1 生成接口业务逻辑文档,写入 `dict.docs.md`
- [ ] 2.2 创建 `dict.schema.ts` - 定义创建字典项Schema - [x] 2.2 创建 `dict.schema.ts` - 定义创建字典项Schema
- [ ] 2.3 创建 `dict.response.ts` - 定义创建字典项响应格式 - [x] 2.3 创建 `dict.response.ts` - 定义创建字典项响应格式
- [ ] 2.4 创建 `dict.service.ts` - 实现创建字典项业务逻辑 - [x] 2.4 创建 `dict.service.ts` - 实现创建字典项业务逻辑
- [ ] 2.5 创建 `dict.controller.ts` - 实现创建字典项路由 - [x] 2.5 创建 `dict.controller.ts` - 实现创建字典项路由
- [ ] 2.6 创建 `dict.test.md` - 编写创建字典项测试用例 - [x] 2.6 创建 `dict.test.md` - 编写创建字典项测试用例
- [ ] 3.0 获取字典项内容接口 (GET /api/dict/:id) - [x] 3.0 获取字典项内容接口 (GET /api/dict/:id)
- [ ] 3.1 更新 `dict.docs.md` - 添加获取字典项业务逻辑 - [x] 3.1 更新 `dict.docs.md` - 添加获取字典项业务逻辑
- [ ] 3.2 扩展 `dict.schema.ts` - 定义获取字典项Schema - [x] 3.2 扩展 `dict.schema.ts` - 定义获取字典项Schema
- [ ] 3.3 扩展 `dict.response.ts` - 定义获取字典项响应格式 - [x] 3.3 扩展 `dict.response.ts` - 定义获取字典项响应格式
- [ ] 3.4 扩展 `dict.service.ts` - 实现获取字典项业务逻辑 - [x] 3.4 扩展 `dict.service.ts` - 实现获取字典项业务逻辑
- [ ] 3.5 扩展 `dict.controller.ts` - 实现获取字典项路由 - [x] 3.5 扩展 `dict.controller.ts` - 实现获取字典项路由
- [ ] 3.6 更新 `dict.test.md` - 添加获取字典项测试用例 - [x] 3.6 更新 `dict.test.md` - 添加获取字典项测试用例
- [ ] 4.0 获取完整字典树接口 (GET /api/dict/tree) - [x] 4.0 获取完整字典树接口 (GET /api/dict/tree)
- [ ] 4.1 更新 `dict.docs.md` - 添加获取完整字典树业务逻辑 - [x] 4.1 更新 `dict.docs.md` - 添加获取完整字典树业务逻辑
- [ ] 4.2 扩展 `dict.schema.ts` - 定义获取字典树查询Schema - [x] 4.2 扩展 `dict.schema.ts` - 定义获取字典树查询Schema
- [ ] 4.3 扩展 `dict.response.ts` - 定义字典树响应格式 - [x] 4.3 扩展 `dict.response.ts` - 定义字典树响应格式
- [ ] 4.4 扩展 `dict.service.ts` - 实现获取完整字典树业务逻辑 - [x] 4.4 扩展 `dict.service.ts` - 实现获取完整字典树业务逻辑
- [ ] 4.5 扩展 `dict.controller.ts` - 实现获取完整字典树路由 - [x] 4.5 扩展 `dict.controller.ts` - 实现获取完整字典树路由
- [ ] 4.6 更新 `dict.test.md` - 添加获取完整字典树测试用例 - [x] 4.6 更新 `dict.test.md` - 添加获取完整字典树测试用例
- [ ] 5.0 获取指定字典树接口 (GET /api/dict/tree/:code) - [ ] 5.0 获取指定字典树接口 (GET /api/dict/tree/:code)
- [ ] 5.1 更新 `dict.docs.md` - 添加获取指定字典树业务逻辑 - [ ] 5.1 更新 `dict.docs.md` - 添加获取指定字典树业务逻辑