generic-crud-扩展路由开发指南.md 16 KB


description: "通用curd扩展路由开发指令"

通用CRUD扩展路由开发指令

本指令基于通用CRUD规范,指导如何为已存在的通用CRUD路由添加自定义扩展路由,采用模块化方式保持代码清晰。

适用场景

当通用CRUD提供的标准路由(GET /, POST /, GET /{id}, PUT /{id}, DELETE /{id})无法满足业务需求时,需要添加自定义业务路由。

开发流程

1. 定位现有通用CRUD路由文件

找到对应的通用CRUD路由文件,通常位于:

  • src/server/api/[实体名]/index.ts

2. 创建扩展路由文件

为每个扩展功能创建单独的路由文件:

src/server/api/your-entity/
├── index.ts            # 聚合路由(已存在)
├── batch/              # 新增 - 批量操作
│   └── delete.ts       # 批量删除
├── [id]/               # 新增 - 单条记录扩展操作
│   ├── status.ts       # 状态更新
│   ├── toggle.ts       # 状态切换
│   └── audit.ts        # 审核操作
├── export.ts           # 新增 - 数据导出
├── import.ts           # 新增 - 数据导入
├── stats.ts            # 新增 - 统计信息
└── upload.ts           # 新增 - 文件上传

3. 创建独立扩展路由文件

3.1 批量删除路由 - batch/delete.ts

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { ErrorSchema } from '@/server/utils/errorHandler';
import { AppDataSource } from '@/server/data-source';
import { YourEntityService } from '@/server/modules/your-module/your-entity.service';
import { AuthContext } from '@/server/types/context';
import { authMiddleware } from '@/server/middleware/auth.middleware';

const routeDef = createRoute({
  method: 'delete',
  path: '/',
  middleware: [authMiddleware],
  request: {
    body: {
      content: {
        'application/json': {
          schema: z.object({
            ids: z.array(z.number().int().positive()).openapi({
              description: '要删除的ID列表',
              example: [1, 2, 3]
            })
          })
        }
      }
    }
  },
  responses: {
    200: {
      description: '批量删除成功',
      content: {
        'application/json': {
          schema: z.object({
            deletedCount: z.number().openapi({ example: 3, description: '删除的记录数' })
          })
        }
      }
    },
    400: { description: '请求参数错误', content: { 'application/json': { schema: ErrorSchema } } },
    500: { description: '服务器错误', content: { 'application/json': { schema: ErrorSchema } } }
  }
});

const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
  try {
    const { ids } = await c.req.json();
    const service = new YourEntityService(AppDataSource);
    
    let deletedCount = 0;
    for (const id of ids) {
      const result = await service.delete(id);
      if (result) deletedCount++;
    }
    
    return c.json({ deletedCount }, 200);
  } catch (error) {
    return c.json({ code: 500, message: '批量删除失败' }, 500);
  }
});

export default app;

3.2 状态更新路由 - [id]/status.ts

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { YourEntitySchema } from '@/server/modules/your-module/your-entity.schema';
import { parseWithAwait } from '@/server/utils/parseWithAwait';
import { ErrorSchema } from '@/server/utils/errorHandler';
import { AppDataSource } from '@/server/data-source';
import { YourEntityService } from '@/server/modules/your-module/your-entity.service';
import { AuthContext } from '@/server/types/context';
import { authMiddleware } from '@/server/middleware/auth.middleware';

const routeDef = createRoute({
  method: 'patch',
  path: '/',
  middleware: [authMiddleware],
  request: {
    params: z.object({
      id: z.string().openapi({
        param: { name: 'id', in: 'path' },
        example: '1',
        description: '记录ID'
      })
    }),
    body: {
      content: {
        'application/json': {
          schema: z.object({
            status: z.number().openapi({ example: 1, description: '新状态值' })
          })
        }
      }
    }
  },
  responses: {
    200: { description: '状态更新成功', content: { 'application/json': { schema: YourEntitySchema } } },
    404: { description: '记录不存在', content: { 'application/json': { schema: ErrorSchema } } },
    500: { description: '服务器错误', content: { 'application/json': { schema: ErrorSchema } } }
  }
});

const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
  try {
    const { id } = c.req.valid('param');
    const { status } = await c.req.json();
    const service = new YourEntityService(AppDataSource);
    
    const result = await service.update(Number(id), { status });
    if (!result) {
      return c.json({ code: 404, message: '记录不存在' }, 404);
    }
    
    // 使用 parseWithAwait 处理响应数据
    const validatedResult = await parseWithAwait(YourEntitySchema, result);
    return c.json(validatedResult, 200);
  } catch (error) {
    return c.json({ code: 500, message: '状态更新失败' }, 500);
  }
});

export default app;

3.3 数据导出路由 - export.ts

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { ErrorSchema } from '@/server/utils/errorHandler';
import { AppDataSource } from '@/server/data-source';
import { YourEntityService } from '@/server/modules/your-module/your-entity.service';
import { YourEntitySchema } from '@/server/modules/your-module/your-entity.schema';
import { parseWithAwait } from '@/server/utils/parseWithAwait';
import { AuthContext } from '@/server/types/context';
import { authMiddleware } from '@/server/middleware/auth.middleware';

const routeDef = createRoute({
  method: 'get',
  path: '/',
  middleware: [authMiddleware],
  request: {
    query: z.object({
      format: z.enum(['csv', 'xlsx']).default('csv').openapi({
        description: '导出格式',
        example: 'csv'
      }),
      keyword: z.string().optional().openapi({
        description: '搜索关键词',
        example: '测试'
      }),
      filters: z.string().optional().openapi({
        description: '筛选条件(JSON字符串)',
        example: '{"status":1}'
      })
    })
  },
  responses: {
    200: {
      description: '导出文件',
      content: {
        'text/csv': { schema: z.string() },
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { schema: z.any() }
      }
    }
  }
});

const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
  try {
    const { format, keyword, filters } = c.req.valid('query');
    const service = new YourEntityService(AppDataSource);
    
    let filterObj = {};
    if (filters) {
      try {
        filterObj = JSON.parse(filters);
      } catch (e) {
        return c.json({ code: 400, message: '筛选条件格式错误' }, 400);
      }
    }
    
    const [data] = await service.getList(1, 1000, keyword, undefined, filterObj);
    
    // 使用 parseWithAwait 处理数据格式
    const validatedData = await parseWithAwait(z.array(YourEntitySchema), data);
    
    if (format === 'csv') {
      const csv = convertToCSV(validatedData);
      return new Response(csv, {
        headers: {
          'Content-Type': 'text/csv',
          'Content-Disposition': 'attachment; filename="export.csv"'
        }
      });
    }
    
    return c.json({ code: 400, message: '不支持的导出格式' }, 400);
  } catch (error) {
    return c.json({ code: 500, message: '导出失败' }, 500);
  }
});

export default app;

function convertToCSV(data: any[]): string {
  if (!data || data.length === 0) return '';
  
  const headers = Object.keys(data[0]);
  const csvHeaders = headers.join(',');
  const csvRows = data.map(row =>
    headers.map(header => {
      const value = row[header];
      return typeof value === 'string' && value.includes(',') ? `"${value}"` : value;
    }).join(',')
  );
  
  return [csvHeaders, ...csvRows].join('\n');
}

4. 聚合所有路由

index.ts 中聚合基础CRUD路由和所有扩展路由:

// src/server/api/your-entity/index.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
import { YourEntity } from '@/server/modules/your-module/your-entity.entity';
import { YourEntitySchema, CreateYourEntityDto, UpdateYourEntityDto } from '@/server/modules/your-module/your-entity.schema';
import { authMiddleware } from '@/server/middleware/auth.middleware';

// 导入基础路由和各扩展路由
import batchDeleteRoute from './batch/delete';
import statusUpdateRoute from './[id]/status';
import exportRoute from './export';

// 1. 创建基础CRUD路由
const yourEntityRoutes = createCrudRoutes({
  entity: YourEntity,
  createSchema: CreateYourEntityDto,
  updateSchema: UpdateYourEntityDto,
  getSchema: YourEntitySchema,
  listSchema: YourEntitySchema,
  searchFields: ['name', 'description'],
  middleware: [authMiddleware]
});

// 2. 聚合所有路由(保持链式)
const app = new OpenAPIHono()
  .route('/batch', batchDeleteRoute)        // 批量操作路由
  .route('/:id/status', statusUpdateRoute)  // 状态更新路由
  .route('/export', exportRoute)          // 导出路由
  .route('/', yourEntityRoutes);               // 基础CRUD路由必需放最后,不然自定义路由会覆盖掉基础CRUD路由

// 3. 导出聚合后的路由
export default app;

常见扩展场景

1. 批量操作

  • 批量删除:DELETE /your-entity/batch
  • 批量更新状态:PATCH /your-entity/batch/status
  • 批量导入:POST /your-entity/import

2. 数据统计

  • 获取统计信息:GET /your-entity/stats
  • 获取图表数据:GET /your-entity/chart-data

3. 文件相关

  • 上传文件:POST /your-entity/upload
  • 下载文件:GET /your-entity/download/{id}
  • 导出数据:GET /your-entity/export

4. 状态管理

  • 状态切换:PATCH /your-entity/{id}/toggle-status
  • 审核操作:POST /your-entity/{id}/audit

5. 关联操作

  • 获取关联数据:GET /your-entity/{id}/related-data
  • 更新关联关系:PUT /your-entity/{id}/relations

命名规范

  • 路径命名:使用RESTful风格,动词用HTTP方法表示
  • 批量操作:使用复数名词,如 /batch, /import, /export
  • 状态变更:使用 PATCH 方法,路径中体现操作,如 /status, /toggle
  • 自定义方法:避免在路径中使用动词,用名词+参数表示

扩展路由 Schema 文件规范

Schema 文件位置

所有扩展路由的 Zod Schema 定义必须遵循以下文件位置规范:

src/server/modules/[模块名]/
├── [实体名].entity.ts      # 实体定义
├── [实体名].schema.ts      # 实体Schema定义(已存在)
└── schemas/                # 扩展路由专用Schema目录(新增)
    ├── batch/              # 批量操作Schema
    │   └── delete.schema.ts
    ├── [id]/               # 单条记录操作Schema
    │   ├── status.schema.ts
    │   ├── toggle.schema.ts
    │   └── audit.schema.ts
    ├── export.schema.ts    # 导出操作Schema
    ├── import.schema.ts    # 导入操作Schema
    └── stats.schema.ts     # 统计操作Schema

Schema 文件命名规范

  • 文件名[操作名].schema.ts
  • 导出:必须包含完整的请求/响应Schema定义
  • 引用:在扩展路由文件中直接引用对应的Schema文件

Schema 文件示例

批量删除Schema - schemas/batch/delete.schema.ts

import { z } from '@hono/zod-openapi';

// 请求Schema
export const BatchDeleteRequestSchema = z.object({
  ids: z.array(z.number().int().positive()).openapi({
    description: '要删除的ID列表',
    example: [1, 2, 3]
  })
});

// 响应Schema
export const BatchDeleteResponseSchema = z.object({
  deletedCount: z.number().openapi({
    example: 3,
    description: '删除的记录数'
  })
});

// 类型定义
export type BatchDeleteRequest = z.infer<typeof BatchDeleteRequestSchema>;
export type BatchDeleteResponse = z.infer<typeof BatchDeleteResponseSchema>;

状态更新Schema - schemas/[id]/status.schema.ts

import { z } from '@hono/zod-openapi';
import { YourEntitySchema } from '../../your-entity.schema';

// 路径参数Schema
export const StatusUpdateParamsSchema = z.object({
  id: z.string().openapi({
    param: { name: 'id', in: 'path' },
    example: '1',
    description: '记录ID'
  })
});

// 请求体Schema
export const StatusUpdateBodySchema = z.object({
  status: z.number().openapi({
    example: 1,
    description: '新状态值'
  })
});

// 响应Schema(复用实体Schema)
export const StatusUpdateResponseSchema = YourEntitySchema;

// 类型定义
export type StatusUpdateParams = z.infer<typeof StatusUpdateParamsSchema>;
export type StatusUpdateBody = z.infer<typeof StatusUpdateBodySchema>;
export type StatusUpdateResponse = z.infer<typeof StatusUpdateResponseSchema>;

在扩展路由中的使用方式

引用Schema文件

// src/server/api/your-entity/batch/delete.ts
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { BatchDeleteRequestSchema, BatchDeleteResponseSchema } from '@/server/modules/your-module/schemas/batch/delete.schema';
// ...其他导入

最佳实践

  1. Schema复用:尽量复用实体已有的Schema定义
  2. 类型安全:所有Schema必须包含完整的OpenAPI元数据
  3. 模块化:每个扩展路由对应独立的Schema文件
  4. 命名一致:Schema文件名与路由功能保持一致
  5. 导出规范:同时导出Schema和对应的TypeScript类型

注意事项

  1. 模块化设计:每个扩展功能单独一个文件,保持代码清晰
  2. 路径一致性:扩展路由的路径要与聚合时的路径匹配
  3. 类型安全:为所有自定义路由定义完整的OpenAPI schema
  4. 错误处理:统一使用标准错误响应格式
  5. 权限控制:为敏感操作添加适当的中间件
  6. 性能考虑:批量操作要考虑事务处理和性能优化
  7. Schema管理:所有Schema必须放在指定的schemas目录下
  8. 版本兼容:Schema变更要保持向后兼容性
  9. 数据验证:所有查询类路由必须使用 parseWithAwait 处理响应数据,确保类型安全

parseWithAwait 使用规范

概述

parseWithAwait 是通用CRUD模块提供的数据验证工具,用于确保返回数据的类型安全,支持异步验证和转换。

使用场景

所有涉及数据查询和返回的扩展路由都应使用 parseWithAwait 处理响应数据。

基本用法

import { parseWithAwait } from '@/server/utils/parseWithAwait';

// 验证单个实体
const validatedEntity = await parseWithAwait(YourEntitySchema, entityData);

// 验证实体数组
const validatedEntities = await parseWithAwait(z.array(YourEntitySchema), entitiesData);

集成示例

// 在扩展路由中使用
const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
  try {
    const data = await yourService.getList();
    
    // 使用 parseWithAwait 确保数据格式正确
    const validatedData = await parseWithAwait(z.array(YourEntitySchema), data);
    
    return c.json({
      data: validatedData,
      pagination: { total, current: page, pageSize }
    }, 200);
  } catch (error) {
    return c.json({ code: 500, message: '获取数据失败' }, 500);
  }
});

优势

  • 类型安全:确保返回数据完全符合Zod schema定义
  • 异步支持:支持异步验证和转换操作
  • 错误处理:提供详细的验证错误信息
  • 性能优化:避免运行时类型错误
  • 向后兼容:与现有代码完全兼容

最佳实践

  1. 所有查询路由:GET请求返回数据前必须使用 parseWithAwait
  2. 列表查询:使用 z.array(EntitySchema) 格式验证数组
  3. 单条查询:直接使用实体Schema验证单个对象
  4. 错误处理:捕获并适当处理验证错误

验证步骤

  1. 创建独立的扩展路由文件
  2. 实现各路由的业务逻辑
  3. 在 index.ts 中聚合所有路由
  4. 测试所有API端点
  5. 验证RPC客户端能正确识别所有路由
  6. 更新前端API客户端(如需要)