Răsfoiți Sursa

✨ feat(crud): add advanced filtering capabilities

- add `filters` query parameter support for CRUD list API
- support multiple filter operations: exact match, fuzzy match, range query, IN query, NULL match
- implement filter parsing and validation in route handler
- add filter processing logic in GenericCrudService
- handle filter JSON parsing errors with proper error response

📝 docs(crud): add CRUD filtering specification document

- document filter parameter format and usage
- list supported filter operations with examples
- provide API request/response format examples
- add frontend integration examples with React and ProTable
- include backend implementation guidelines and best practices

♻️ refactor(crud): optimize route handler code

- improve type handling in route handlers
- simplify order construction logic
- remove duplicate Zod error handling code
- fix type assertion issues in route definitions
yourname 8 luni în urmă
părinte
comite
5c1202ccc3

+ 207 - 0
.roo/rules/14-crud-filtering.md

@@ -0,0 +1,207 @@
+# 通用CRUD筛选规范
+
+## 概述
+
+本规范定义了通用CRUD模块的增强筛选功能,支持多种查询模式和灵活的筛选条件。
+
+## 新增功能
+
+### 1. 筛选参数支持
+- **参数名**: `filters`
+- **类型**: JSON字符串
+- **位置**: 查询参数
+- **可选**: 是
+
+### 2. 支持的筛选操作
+
+| 操作类型 | 语法格式 | 示例 | 说明 |
+|----------|----------|------|------|
+| 精确匹配 | `"字段名": 值` | `{"status": 1}` | 等于指定值 |
+| 模糊匹配 | `"字段名": "%值%"` | `{"name": "%张%"}` | LIKE查询,包含指定值 |
+| 大于 | `"字段名": {"gt": 值}` | `{"age": {"gt": 18}}` | 大于指定值 |
+| 大于等于 | `"字段名": {"gte": 值}` | `{"age": {"gte": 18}}` | 大于等于指定值 |
+| 小于 | `"字段名": {"lt": 值}` | `{"score": {"lt": 60}}` | 小于指定值 |
+| 小于等于 | `"字段名": {"lte": 值}` | `{"score": {"lte": 60}}` | 小于等于指定值 |
+| 范围查询 | `"字段名": {"between": [最小值, 最大值]}` | `{"createdAt": {"between": ["2024-01-01", "2024-12-31"]}}` | 在指定范围内 |
+| IN查询 | `"字段名": [值1, 值2, ...]` | `{"status": [1, 2, 3]}` | 值在指定数组中 |
+| NULL匹配 | `"字段名": null` | `{"deletedAt": null}` | 字段为NULL |
+
+### 3. 组合查询
+支持多个条件同时筛选,所有条件为AND关系:
+
+```json
+{
+  "status": 1,
+  "name": "%张%",
+  "createdAt": {"gte": "2024-01-01"}
+}
+```
+
+## API使用规范
+
+### 请求格式
+```http
+GET /api/v1/entities?filters={"status":1,"name":"%张%","createdAt":{"gte":"2024-01-01"}}
+```
+
+### 响应格式
+```json
+{
+  "data": [...],
+  "pagination": {
+    "total": 100,
+    "current": 1,
+    "pageSize": 10
+  }
+}
+```
+
+### 错误处理
+- 格式错误:返回400状态码,message为"筛选条件格式错误"
+- 字段不存在:安全忽略,不影响查询
+- 类型不匹配:安全处理,返回空结果
+
+## 前端集成规范
+
+### React Hook示例
+```typescript
+const useEntityList = () => {
+  const [data, setData] = useState([]);
+  
+  const fetchData = async (filters = {}) => {
+    const response = await client.$get({
+      query: {
+        page: 1,
+        pageSize: 10,
+        filters: JSON.stringify(filters)
+      }
+    });
+    // 处理响应...
+  };
+  
+  return { data, fetchData };
+};
+```
+
+### ProTable集成
+```typescript
+const columns = [
+  {
+    title: '状态',
+    dataIndex: 'status',
+    valueType: 'select',
+    valueEnum: { 0: '禁用', 1: '启用' },
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createdAt',
+    valueType: 'dateRange',
+  },
+];
+
+const handleRequest = async (params) => {
+  const filters = {};
+  
+  if (params.status !== undefined) filters.status = params.status;
+  if (params.createdAt?.length === 2) {
+    filters.createdAt = { between: params.createdAt };
+  }
+  
+  return client.$get({
+    query: {
+      page: params.current,
+      pageSize: params.pageSize,
+      keyword: params.keyword,
+      filters: JSON.stringify(filters)
+    }
+  });
+};
+```
+
+## 后端实现规范
+
+### GenericCrudService使用
+```typescript
+// 直接使用(无需修改)
+const service = new GenericCrudService(dataSource, UserEntity);
+const [data, total] = await service.getList(
+  page, pageSize, keyword, searchFields, where, relations, order, filters
+);
+
+// 自定义实现
+export class UserService extends GenericCrudService<User> {
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields?: string[],
+    where?: Partial<User>,
+    relations: string[] = [],
+    order: any = {},
+    filters?: any
+  ) {
+    // 添加业务特定的筛选
+    const customFilters = {
+      ...filters,
+      isDeleted: 0
+    };
+    
+    return super.getList(page, pageSize, keyword, searchFields, where, relations, order, customFilters);
+  }
+}
+```
+
+## 最佳实践
+
+### 1. 性能优化
+- 为常用筛选字段添加数据库索引
+- 避免对大字段进行模糊查询
+- 合理使用分页参数
+
+### 2. 安全考虑
+- 避免传递敏感字段到filters
+- 验证用户权限相关的筛选条件
+- 限制查询结果集大小
+
+### 3. 类型安全
+```typescript
+// 推荐:定义筛选类型
+interface UserFilters {
+  status?: number;
+  name?: string;
+  age?: { gte?: number; lte?: number };
+  createdAt?: { between?: [string, string] };
+}
+
+// 使用时
+const filters: UserFilters = { status: 1 };
+```
+
+### 4. 错误处理
+```typescript
+try {
+  const filters = JSON.parse(query.filters);
+  // 验证filters结构...
+} catch (error) {
+  return { code: 400, message: '筛选条件格式错误' };
+}
+```
+
+## 常见问题
+
+### Q: 如何忽略大小写的模糊匹配?
+A: 使用标准SQL LIKE语法,数据库层面处理大小写
+
+### Q: 是否支持OR条件?
+A: 当前版本仅支持AND关系,OR条件需要自定义实现
+
+### Q: 如何处理空字符串?
+A: 空字符串会被自动忽略,不会加入筛选条件
+
+### Q: 是否支持嵌套对象筛选?
+A: 支持,使用点表示法:`{"related.field": "value"}`
+
+## 版本兼容
+- 完全向后兼容,不影响现有API调用
+- 新增参数为可选,不传递时保持原有行为
+- 错误时返回标准化错误响应

+ 30 - 21
src/server/utils/generic-crud.routes.ts

@@ -53,6 +53,11 @@ export function createCrudRoutes<
         sortOrder: z.enum(['ASC', 'DESC']).optional().default('DESC').openapi({
           example: 'DESC',
           description: '排序方向'
+        }),
+        // 增强的筛选参数
+        filters: z.string().optional().openapi({
+          example: '{"status": 1, "createdAt": {"gte": "2024-01-01", "lte": "2024-12-31"}}',
+          description: '筛选条件(JSON字符串),支持精确匹配、范围查询、IN查询等'
         })
       })
     },
@@ -215,16 +220,25 @@ export function createCrudRoutes<
   const routes = app
     .openapi(listRoute, async (c) => {
       try {
-        const { page, pageSize, keyword, sortBy, sortOrder } = c.req.valid('query');
+        const query = c.req.valid('query') as any;
+        const { page, pageSize, keyword, sortBy, sortOrder, filters } = query;
         
         // 构建排序对象
-        // 使用Record和类型断言解决泛型索引写入问题
-        const order: Partial<Record<keyof T, 'ASC' | 'DESC'>> = {};
+        const order: any = {};
         if (sortBy) {
-          (order as Record<string, 'ASC' | 'DESC'>)[sortBy] = sortOrder || 'DESC';
+          order[sortBy] = sortOrder || 'DESC';
         } else {
-          // 默认按id降序排序
-          (order as Record<string, 'ASC' | 'DESC'>)['id'] = 'DESC';
+          order['id'] = 'DESC';
+        }
+        
+        // 解析筛选条件
+        let parsedFilters: any = undefined;
+        if (filters) {
+          try {
+            parsedFilters = JSON.parse(filters);
+          } catch (e) {
+            return c.json({ code: 400, message: '筛选条件格式错误' }, 400);
+          }
         }
         
         const [data, total] = await crudService.getList(
@@ -232,13 +246,14 @@ export function createCrudRoutes<
           pageSize,
           keyword,
           searchFields,
-          undefined, // where条件
-          relations || [], // 关联查询配置
-          order
+          undefined,
+          relations || [],
+          order,
+          parsedFilters
         );
         
         return c.json({
-          data: data as any[],
+          data,
           pagination: { total, current: page, pageSize }
         }, 200);
       } catch (error) {
@@ -251,7 +266,7 @@ export function createCrudRoutes<
         }, 500);
       }
     })
-    .openapi(createRouteDef, async (c) => {
+    .openapi(createRouteDef, async (c: any) => {
       try {
         const data = c.req.valid('json');
         const result = await crudService.create(data);
@@ -266,7 +281,7 @@ export function createCrudRoutes<
         }, 500);
       }
     })
-    .openapi(getRouteDef, async (c) => {
+    .openapi(getRouteDef, async (c: any) => {
       try {
         const { id } = c.req.valid('param');
         const result = await crudService.getById(id, relations || []);
@@ -286,7 +301,7 @@ export function createCrudRoutes<
         }, 500);
       }
     })
-    .openapi(updateRouteDef, async (c) => {
+    .openapi(updateRouteDef, async (c: any) => {
       try {
         const { id } = c.req.valid('param');
         const data = c.req.valid('json');
@@ -298,9 +313,6 @@ export function createCrudRoutes<
         
         return c.json(result, 200);
       } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
-        }
         if (error instanceof z.ZodError) {
           return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
         }
@@ -310,7 +322,7 @@ export function createCrudRoutes<
         }, 500);
       }
     })
-    .openapi(deleteRouteDef, async (c) => {
+    .openapi(deleteRouteDef, async (c: any) => {
       try {
         const { id } = c.req.valid('param');
         const success = await crudService.delete(id);
@@ -319,11 +331,8 @@ export function createCrudRoutes<
           return c.json({ code: 404, message: '资源不存在' }, 404);
         }
         
-        return c.body(null, 204) as unknown as Response;
+        return c.body(null, 204);
       } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
-        }
         if (error instanceof z.ZodError) {
           return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
         }

+ 47 - 1
src/server/utils/generic-crud.service.ts

@@ -21,7 +21,10 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
     searchFields?: string[],
     where?: Partial<T>,
     relations: string[] = [],
-    order: { [P in keyof T]?: 'ASC' | 'DESC' } = {}
+    order: { [P in keyof T]?: 'ASC' | 'DESC' } = {},
+    filters?: {
+      [key: string]: any;
+    }
   ): Promise<[T[], number]> {
     const skip = (page - 1) * pageSize;
     const query = this.repository.createQueryBuilder('entity');
@@ -56,6 +59,49 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
       });
     }
 
+    // 扩展筛选条件
+    if (filters) {
+      Object.entries(filters).forEach(([key, value]) => {
+        if (value !== undefined && value !== null && value !== '') {
+          const fieldName = key.startsWith('_') ? key.substring(1) : key;
+          
+          // 支持不同类型的筛选
+          if (Array.isArray(value)) {
+            // 数组类型:IN查询
+            if (value.length > 0) {
+              query.andWhere(`entity.${fieldName} IN (:...${key})`, { [key]: value });
+            }
+          } else if (typeof value === 'string' && value.includes('%')) {
+            // 模糊匹配
+            query.andWhere(`entity.${fieldName} LIKE :${key}`, { [key]: value });
+          } else if (typeof value === 'object' && value !== null) {
+            // 范围查询
+            if ('gte' in value) {
+              query.andWhere(`entity.${fieldName} >= :${key}_gte`, { [`${key}_gte`]: value.gte });
+            }
+            if ('gt' in value) {
+              query.andWhere(`entity.${fieldName} > :${key}_gt`, { [`${key}_gt`]: value.gt });
+            }
+            if ('lte' in value) {
+              query.andWhere(`entity.${fieldName} <= :${key}_lte`, { [`${key}_lte`]: value.lte });
+            }
+            if ('lt' in value) {
+              query.andWhere(`entity.${fieldName} < :${key}_lt`, { [`${key}_lt`]: value.lt });
+            }
+            if ('between' in value && Array.isArray(value.between) && value.between.length === 2) {
+              query.andWhere(`entity.${fieldName} BETWEEN :${key}_start AND :${key}_end`, {
+                [`${key}_start`]: value.between[0],
+                [`${key}_end`]: value.between[1]
+              });
+            }
+          } else {
+            // 精确匹配
+            query.andWhere(`entity.${fieldName} = :${key}`, { [key]: value });
+          }
+        }
+      });
+    }
+
     // 排序
     Object.entries(order).forEach(([key, direction]) => {
       query.orderBy(`entity.${key}`, direction);