non-generic-crud-standards.md 19 KB

非通用 CRUD 路由规范

版本信息

版本 日期 描述 作者
1.0 2025-10-23 创建非通用 CRUD 路由规范文档 Winston

概述

非通用 CRUD 路由是指那些不遵循标准 CRUD 操作模式的 API 端点,包括统计查询、业务操作、复杂查询等场景。这些路由需要特定的业务逻辑和自定义处理,无法通过通用 CRUD 服务自动生成。

设计原则

核心原则

  • 业务导向: 路由设计应反映业务需求而非技术实现
  • 一致性: 保持与通用 CRUD 相同的错误处理、认证和文档标准
  • 类型安全: 使用 Zod schema 确保输入输出类型安全
  • 文档完整: 自动生成 OpenAPI 文档,包含完整的请求响应示例

架构模式

  • 单一职责: 每个路由文件专注于特定业务功能
  • 服务分离: 业务逻辑封装在服务层,路由层只负责 HTTP 处理
  • 中间件复用: 复用现有的认证、授权中间件

非通用 CRUD 路由类型

1. 统计查询路由 (Stats Routes)

适用场景: 获取聚合数据、统计信息、报表数据

示例文件: packages/server/src/api/admin/orders/stats.ts

规范要求:

  • 使用 GET 方法
  • 路径以 /stats/analytics 结尾
  • 返回聚合数据而非分页列表
  • 包含完整的错误处理

代码模板:

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { authMiddleware } from '../../../middleware/auth.middleware';
import { adminMiddleware } from '../../../middleware/admin.middleware';
import { OrderService } from '../../../modules/orders/order.service';
import { OrderStatsSchema } from '../../../modules/orders/order.schema';
import { ErrorSchema } from '../../../utils/errorHandler';

// 统计路由定义
const statsRoute = createRoute({
  method: 'get',
  path: '/',
  middleware: [authMiddleware, adminMiddleware],
  responses: {
    200: {
      description: '成功获取统计信息',
      content: {
        'application/json': {
          schema: OrderStatsSchema
        }
      }
    },
    401: {
      description: '未授权',
      content: { 'application/json': { schema: ErrorSchema } }
    },
    403: {
      description: '权限不足',
      content: { 'application/json': { schema: ErrorSchema } }
    },
    500: {
      description: '服务器错误',
      content: { 'application/json': { schema: ErrorSchema } }
    }
  }
});

const app = new OpenAPIHono().openapi(statsRoute, async (c) => {
  try {
    const orderService = new OrderService();
    const stats = await orderService.getOrderStats();
    return c.json(stats, 200);
  } catch (error) {
    console.error('获取统计信息失败:', error);
    return c.json({
      code: 500,
      message: error instanceof Error ? error.message : '获取统计信息失败'
    }, 500);
  }
});

export default app;

2. 层级数据查询路由 (Hierarchical Data Routes)

适用场景: 树形结构数据、省市区数据、分类数据

示例文件: packages/server/src/api/areas/index.ts

规范要求:

  • 使用 GET 方法
  • 路径反映数据层级关系(如 /provinces, /cities, /districts
  • 支持分页和父级ID筛选
  • 返回标准化的分页响应格式

代码模板:

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { AreaService } from '../../modules/areas/area.service';

// 查询参数Schema
const getCitiesSchema = z.object({
  provinceId: z.coerce.number<number>().int().positive('省份ID必须为正整数').openapi({
    example: 1,
    description: '省份ID'
  }),
  page: z.coerce.number<number>().int().min(1).default(1).openapi({
    example: 1,
    description: '页码'
  }),
  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
    example: 50,
    description: '每页数量'
  })
});

// 响应Schema
const citiesResponseSchema = z.object({
  success: z.boolean(),
  data: z.object({
    cities: z.array(areaResponseSchema),
    pagination: z.object({
      page: z.number(),
      pageSize: z.number(),
      total: z.number(),
      totalPages: z.number()
    })
  }),
  message: z.string()
});

// 创建城市查询路由
const getCitiesRoute = createRoute({
  method: 'get',
  path: '/cities',
  request: {
    query: getCitiesSchema
  },
  responses: {
    200: {
      description: '获取城市列表成功',
      content: { 'application/json': { schema: citiesResponseSchema } }
    },
    400: {
      description: '参数错误',
      content: { 'application/json': { schema: errorSchema } }
    },
    500: {
      description: '获取城市列表失败',
      content: { 'application/json': { schema: errorSchema } }
    }
  }
});

const app = new OpenAPIHono()
  .openapi(getCitiesRoute, async (c) => {
    try {
      const { provinceId, page, pageSize } = c.req.valid('query');
      const areaService = new AreaService();

      // 业务逻辑处理
      const allCities = await areaService.getAreaTreeByLevel(AreaLevel.CITY);
      const cities = allCities.filter(city => city.parentId === provinceId);

      // 分页处理
      const startIndex = (page - 1) * pageSize;
      const endIndex = startIndex + pageSize;
      const paginatedCities = cities.slice(startIndex, endIndex);

      return c.json({
        success: true,
        data: {
          cities: paginatedCities,
          pagination: {
            page,
            pageSize,
            total: cities.length,
            totalPages: Math.ceil(cities.length / pageSize)
          }
        },
        message: '获取城市列表成功'
      }, 200);
    } catch (error) {
      console.error('获取城市列表失败:', error);
      return c.json({
        code: 500,
        message: error instanceof Error ? error.message : '获取城市列表失败'
      }, 500);
    }
  });

export default app;

3. 业务操作路由 (Business Action Routes)

适用场景: 设置默认项、状态变更、批量操作等业务动作

示例文件: packages/server/src/api/passengers/index.ts

规范要求:

  • 使用 POST 方法表示动作执行
  • 路径格式为 /{id}/{action-name}
  • 包含完整的权限验证
  • 返回操作结果

代码模板:

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { authMiddleware } from '../../middleware/auth.middleware';
import { PassengerService } from '../../modules/passengers/passenger.service';

// 设置默认乘客路由
const setDefaultRoute = createRoute({
  method: 'post',
  path: '/{id}/set-default',
  middleware: [authMiddleware],
  request: {
    params: z.object({
      id: z.coerce.number<number>().openapi({
        param: { name: 'id', in: 'path' },
        example: 1,
        description: '乘客ID'
      })
    })
  },
  responses: {
    200: {
      description: '设置默认乘客成功',
      content: { 'application/json': { schema: PassengerResponseSchema } }
    },
    401: {
      description: '未授权',
      content: { 'application/json': { schema: ErrorSchema } }
    },
    403: {
      description: '无权设置',
      content: { 'application/json': { schema: ErrorSchema } }
    },
    404: {
      description: '乘客不存在',
      content: { 'application/json': { schema: ErrorSchema } }
    },
    500: {
      description: '服务器错误',
      content: { 'application/json': { schema: ErrorSchema } }
    }
  }
});

const app = new OpenAPIHono<AuthContext>()
  .openapi(setDefaultRoute, async (c) => {
    try {
      const user = c.get('user');
      const { id } = c.req.valid('param');

      // 权限验证
      const existingPassenger = await passengerService.getPassengerById(id);
      if (!existingPassenger) {
        return c.json({ code: 404, message: '乘客不存在' }, 404);
      }

      if (existingPassenger.userId !== user.id) {
        return c.json({ code: 403, message: '无权设置该乘客为默认' }, 403);
      }

      // 执行业务操作
      const result = await passengerService.setDefaultPassenger(user.id, id);
      if (!result) {
        return c.json({ code: 500, message: '设置默认乘客失败' }, 500);
      }
      return c.json(result, 200);
    } catch (error) {
      console.error('设置默认乘客失败:', error);
      return c.json({
        code: 500,
        message: error instanceof Error ? error.message : '设置默认乘客失败'
      }, 500);
    }
  });

export default app;

Schema 设计规范

1. 查询参数 Schema

  • 使用 z.coerce 处理类型转换
  • 为所有参数添加 .openapi() 配置,包含示例和描述
  • 设置合理的默认值和验证规则

    const querySchema = z.object({
    page: z.coerce.number<number>().int().positive().default(1).openapi({
    example: 1,
    description: '页码,从1开始'
    }),
    pageSize: z.coerce.number<number>().int().positive().default(20).openapi({
    example: 20,
    description: '每页数量'
    }),
    keyword: z.string().optional().openapi({
    example: '搜索关键词',
    description: '搜索关键词'
    })
    });
    

2. 路径参数 Schema

  • 使用 z.coerce 处理类型转换
  • 包含 param 配置指定参数位置

    const paramsSchema = z.object({
    id: z.coerce.number<number>().openapi({
    param: { name: 'id', in: 'path' },
    example: 1,
    description: '实体ID'
    })
    });
    

3. 响应 Schema

  • 使用标准化的响应格式
  • 包含 successdatamessage 字段
  • 为分页数据包含 pagination 信息

    const responseSchema = z.object({
    success: z.boolean(),
    data: z.object({
    // 业务数据字段
    items: z.array(itemSchema),
    pagination: z.object({
      page: z.number(),
      pageSize: z.number(),
      total: z.number(),
      totalPages: z.number()
    }).optional()
    }),
    message: z.string()
    });
    

错误处理规范

1. 标准错误响应

所有非通用 CRUD 路由必须使用统一的错误处理:

return c.json({
  code: 500,
  message: error instanceof Error ? error.message : '操作失败'
}, 500);

2. 常见错误状态码

  • 400: 参数错误
  • 401: 未授权
  • 403: 权限不足
  • 404: 资源不存在
  • 500: 服务器错误

3. 错误日志记录

  • 使用 console.error 记录详细错误信息
  • 包含操作上下文信息
  • 避免在生产环境暴露敏感信息

权限控制规范

1. 中间件使用

  • 复用现有的认证中间件 authMiddleware
  • 管理员权限使用 adminMiddleware
  • 自定义权限中间件应遵循相同模式

2. 资源所有权验证

  • 验证用户对资源的访问权限
  • 在服务层或路由层实现权限检查
  • 返回明确的权限错误信息

    // 检查资源所有权
    if (resource.userId !== user.id) {
    return c.json({ code: 403, message: '无权访问该资源' }, 403);
    }
    

最佳实践

1. 文件组织

  • 每个非通用路由功能创建独立文件
  • 文件名反映功能用途(如 stats.ts, actions.ts
  • 在模块的 index.ts 中统一导出

2. 代码结构

  • 路由定义在前,处理函数在后
  • 使用 try-catch 包装所有业务逻辑
  • 保持错误处理的一致性

3. 性能考虑

  • 为统计查询添加适当的缓存策略
  • 复杂查询使用数据库索引优化
  • 避免 N+1 查询问题

4. 测试策略

  • 为每个非通用路由编写集成测试
  • 测试所有可能的错误场景
  • 验证权限控制和业务逻辑

路由聚合链式规范

1. 路由聚合模式

1.1 单一文件聚合模式

适用于功能相对简单的模块,所有路由定义在单个文件中:

示例文件: packages/server/src/api/passengers/index.ts

规范要求:

  • 所有路由定义在同一文件中
  • 使用 .openapi() 方法链式注册路由
  • 保持路由定义的顺序性(CRUD 操作在前,业务操作在后)
  • 统一的服务实例管理

代码模板:

import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { authMiddleware } from '../../middleware/auth.middleware';
import { PassengerService } from '../../modules/passengers/passenger.service';

// 服务实例化(单例模式)
const passengerService = new PassengerService();

// 路由定义
const listRoute = createRoute({ /* ... */ });
const createRouteDef = createRoute({ /* ... */ });
const getRouteDef = createRoute({ /* ... */ });
const updateRouteDef = createRoute({ /* ... */ });
const deleteRouteDef = createRoute({ /* ... */ });
const setDefaultRoute = createRoute({ /* ... */ });

// 链式路由注册
const app = new OpenAPIHono<AuthContext>()
  .openapi(listRoute, async (c) => { /* ... */ })
  .openapi(createRouteDef, async (c) => { /* ... */ })
  .openapi(getRouteDef, async (c) => { /* ... */ })
  .openapi(updateRouteDef, async (c) => { /* ... */ })
  .openapi(deleteRouteDef, async (c) => { /* ... */ })
  .openapi(setDefaultRoute, async (c) => { /* ... */ });

export default app;

1.2 混合路由聚合模式

适用于需要结合通用 CRUD 和自定义业务路由的模块:

示例文件: packages/server/src/api/users/index.ts

规范要求:

  • 使用 .route() 方法聚合多个路由应用
  • 通用 CRUD 路由与自定义业务路由分离
  • 自定义路由优先于通用路由
  • 明确的中间件配置

代码模板:

import { OpenAPIHono } from '@hono/zod-openapi';
import { createCrudRoutes } from '../../utils/generic-crud.routes';
import { UserEntity } from '../../modules/users/user.entity';
import { authMiddleware } from '../../middleware/auth.middleware';
import customRoutes from './custom';

// 创建通用CRUD路由配置
const userCrudRoutes = createCrudRoutes({
  entity: UserEntity,
  createSchema: CreateUserDto,
  updateSchema: UpdateUserDto,
  getSchema: UserSchema,
  listSchema: UserSchema.omit({ password: true }),
  searchFields: ['username', 'nickname', 'phone', 'email'],
  relations: ['roles'],
  middleware: [authMiddleware],
  readOnly: true // 创建/更新/删除使用自定义路由
});

// 创建混合路由应用
const app = new OpenAPIHono()
  .route('/', customRoutes)   // 自定义业务路由(创建/更新/删除)
  .route('/', userCrudRoutes); // 通用CRUD路由(列表查询和获取详情)

export default app;

2. 路由注册顺序规范

2.1 路由优先级规则

  • 自定义路由优先: 自定义业务路由应注册在通用路由之前
  • 具体路径优先: 具体路径的路由应注册在通用路径之前
  • 中间件顺序: 全局中间件在前,路由特定中间件在后

2.2 推荐注册顺序

const app = new OpenAPIHono()
  // 1. 业务操作路由(POST/PUT/DELETE)
  .openapi(createRoute, createHandler)
  .openapi(updateRoute, updateHandler)
  .openapi(deleteRoute, deleteHandler)

  // 2. 自定义业务动作路由
  .openapi(setDefaultRoute, setDefaultHandler)
  .openapi(bulkUpdateRoute, bulkUpdateHandler)

  // 3. 查询路由(GET)
  .openapi(listRoute, listHandler)
  .openapi(getRoute, getHandler)

  // 4. 统计和聚合路由
  .openapi(statsRoute, statsHandler);

3. 服务实例管理规范

3.1 路由级别服务实例化

  • 在路由处理函数中实例化服务类
  • 避免模块级别的单例,便于测试时 mock
  • 保持代码的可测试性和灵活性

    // ✅ 推荐:路由级别实例化(便于测试)
    const app = new OpenAPIHono()
    .openapi(listRoute, async (c) => {
    const passengerService = new PassengerService(); // 路由级别实例化
    const result = await passengerService.getPassengers(params);
    return c.json(result, 200);
    });
    
    // ❌ 不推荐:模块级别单例(测试困难)
    const passengerService = new PassengerService(); // 模块级别单例
    
    const app = new OpenAPIHono()
    .openapi(listRoute, async (c) => {
    const result = await passengerService.getPassengers(params);
    return c.json(result, 200);
    });
    

3.2 测试时的 Mock 方法

在测试时,可以直接使用 vi.mocked() 来 mock 服务类:

// 测试示例
import { PassengerService } from '../../modules/passengers/passenger.service';

// Mock 服务类
vi.mock('../../modules/passengers/passenger.service');

// 在测试中使用
const mockGetPassengers = vi.mocked(PassengerService.prototype.getPassengers);
mockGetPassengers.mockResolvedValue({ /* mock data */ });

4. 错误处理统一规范

4.1 统一错误响应格式

所有路由应使用统一的错误处理模式:

.openapi(route, async (c) => {
  try {
    // 业务逻辑
    const result = await service.method(params);
    return c.json(result, 200);
  } catch (error) {
    console.error('操作失败:', error);
    return c.json({
      code: 500,
      message: error instanceof Error ? error.message : '操作失败'
    }, 500);
  }
})

4.2 权限验证模式

对于需要资源所有权验证的路由:

.openapi(route, async (c) => {
  try {
    const user = c.get('user');
    const { id } = c.req.valid('param');

    // 资源存在性检查
    const resource = await service.getById(id);
    if (!resource) {
      return c.json({ code: 404, message: '资源不存在' }, 404);
    }

    // 所有权验证
    if (resource.userId !== user.id) {
      return c.json({ code: 403, message: '无权访问该资源' }, 403);
    }

    // 执行业务逻辑
    const result = await service.method(id, data);
    return c.json(result, 200);
  } catch (error) {
    // 统一错误处理
  }
})

5. 文件组织规范

5.1 路由文件结构

packages/server/src/api/
├── users/
│   ├── index.ts          # 路由聚合入口
│   ├── custom.ts         # 自定义业务路由
│   └── stats.ts          # 统计路由(可选)
├── passengers/
│   └── index.ts          # 单一文件聚合
└── admin/
    └── orders/
        ├── index.ts      # 路由聚合
        ├── stats.ts      # 统计路由
        └── actions.ts    # 业务操作路由

5.2 导出规范

  • 每个路由文件应导出默认的 OpenAPIHono 实例
  • 使用 export default app 语法
  • 确保类型安全,正确配置泛型类型

扩展和自定义

1. 自定义中间件

对于需要特殊权限验证的路由,可以创建自定义中间件:

const ownerMiddleware = createMiddleware(async (c, next) => {
  const user = c.get('user');
  const { id } = c.req.param();

  const resource = await resourceService.getById(id);
  if (!resource || resource.userId !== user.id) {
    return c.json({ code: 403, message: '无权访问' }, 403);
  }

  c.set('resource', resource);
  await next();
});

2. 批量操作路由

对于批量操作,创建专门的批量处理路由:

const bulkUpdateRoute = createRoute({
  method: 'post',
  path: '/bulk-update',
  middleware: [authMiddleware],
  request: {
    body: {
      content: {
        'application/json': { schema: BulkUpdateSchema }
      }
    }
  },
  responses: {
    200: {
      description: '批量更新成功',
      content: { 'application/json': { schema: BulkUpdateResponseSchema } }
    }
  }
});

相关文档


文档状态: 正式版 下次评审: 2025-11-23