Browse Source

✨ feat(crud): add read-only mode and enhance query capabilities

- add readOnly option to CrudOptions to control route accessibility
- implement nested relation query support with consistent alias generation
- enhance keyword search to support nested relation fields
- improve filter conditions to support nested relation fields
- add parseWithAwait utility for async schema validation

♻️ refactor(crud): optimize type definitions and error handling

- add explicit type annotations to Zod number schemas
- improve Zod error message handling with JSON.parse
- optimize user tracking field assignment logic
- restructure route registration for better readability and maintainability
yourname 3 months ago
parent
commit
a9dfae9556
2 changed files with 256 additions and 140 deletions
  1. 204 123
      src/server/utils/generic-crud.routes.ts
  2. 52 17
      src/server/utils/generic-crud.service.ts

+ 204 - 123
src/server/utils/generic-crud.routes.ts

@@ -5,6 +5,7 @@ import { ErrorSchema } from './errorHandler';
 import { AuthContext } from '../types/context';
 import { ObjectLiteral } from 'typeorm';
 import { AppDataSource } from '../data-source';
+import { parseWithAwait } from './parseWithAwait';
 
 export function createCrudRoutes<
   T extends ObjectLiteral,
@@ -13,7 +14,7 @@ export function createCrudRoutes<
   GetSchema extends z.ZodSchema = z.ZodSchema,
   ListSchema extends z.ZodSchema = z.ZodSchema
 >(options: CrudOptions<T, CreateSchema, UpdateSchema, GetSchema, ListSchema>) {
-  const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, relations, middleware = [], userTracking, relationFields } = options;
+  const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, relations, middleware = [], userTracking, relationFields, readOnly = false } = options;
   
   // 创建CRUD服务实例
   // 抽象类不能直接实例化,需要创建具体实现类
@@ -34,11 +35,11 @@ export function createCrudRoutes<
     middleware,
     request: {
       query: z.object({
-        page: z.coerce.number().int().positive().default(1).openapi({
+        page: z.coerce.number<number>().int().positive().default(1).openapi({
           example: 1,
           description: '页码,从1开始'
         }),
-        pageSize: z.coerce.number().int().positive().default(10).openapi({
+        pageSize: z.coerce.number<number>().int().positive().default(10).openapi({
           example: 10,
           description: '每页数量'
         }),
@@ -217,133 +218,213 @@ export function createCrudRoutes<
   });
   
   // 注册路由处理函数
-  const routes = app
-    .openapi(listRoute, async (c) => {
-      try {
-        const query = c.req.valid('query') as any;
-        const { page, pageSize, keyword, sortBy, sortOrder, filters } = query;
-        
-        // 构建排序对象
-        const order: any = {};
-        if (sortBy) {
-          order[sortBy] = sortOrder || 'DESC';
-        } else {
-          order['id'] = 'DESC';
-        }
-        
-        // 解析筛选条件
-        let parsedFilters: any = undefined;
-        if (filters) {
-          try {
-            parsedFilters = JSON.parse(filters);
-          } catch (e) {
-            return c.json({ code: 400, message: '筛选条件格式错误' }, 400);
+  
+  // 只读模式下只注册 GET 路由
+  if (!readOnly) {
+    // 完整 CRUD 路由
+    const routes = app
+      .openapi(listRoute, async (c) => {
+        try {
+          const query = c.req.valid('query') as any;
+          const { page, pageSize, keyword, sortBy, sortOrder, filters } = query;
+          
+          // 构建排序对象
+          const order: any = {};
+          if (sortBy) {
+            order[sortBy] = sortOrder || 'DESC';
+          } else {
+            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(
+            page,
+            pageSize,
+            keyword,
+            searchFields,
+            undefined,
+            relations || [],
+            order,
+            parsedFilters
+          );
+          
+          return c.json({
+            // data: z.array(listSchema).parse(data),
+            data: await parseWithAwait(z.array(listSchema), data),
+            pagination: { total, current: page, pageSize }
+          }, 200);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '获取列表失败'
+          }, 500);
         }
-        
-        const [data, total] = await crudService.getList(
-          page,
-          pageSize,
-          keyword,
-          searchFields,
-          undefined,
-          relations || [],
-          order,
-          parsedFilters
-        );
-        
-        return c.json({
-          data: z.array(listSchema).parse(data),
-          pagination: { total, current: page, pageSize }
-        }, 200);
-      } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
-        }
-        return c.json({
-          code: 500,
-          message: error instanceof Error ? error.message : '获取列表失败'
-        }, 500);
-      }
-    })
-    .openapi(createRouteDef, async (c: any) => {
-      try {
-        const data = c.req.valid('json');
-        const user = c.get('user');
-        const result = await crudService.create(data, user?.id);
-        return c.json(result, 201);
-      } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
-        }
-        return c.json({
-          code: 500,
-          message: error instanceof Error ? error.message : '创建资源失败'
-        }, 500);
-      }
-    })
-    .openapi(getRouteDef, async (c: any) => {
-      try {
-        const { id } = c.req.valid('param');
-        const result = await crudService.getById(id, relations || []);
-        
-        if (!result) {
-          return c.json({ code: 404, message: '资源不存在' }, 404);
+      })
+      .openapi(createRouteDef, async (c: any) => {
+        try {
+          const data = c.req.valid('json');
+          const user = c.get('user');
+          const result = await crudService.create(data, user?.id);
+          return c.json(result, 201);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '创建资源失败'
+          }, 500);
         }
-        
-        return c.json(getSchema.parse(result), 200);
-      } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+      })
+      .openapi(getRouteDef, async (c: any) => {
+        try {
+          const { id } = c.req.valid('param');
+          const result = await crudService.getById(id, relations || []);
+          
+          if (!result) {
+            return c.json({ code: 404, message: '资源不存在' }, 404);
+          }
+          
+          // return c.json(await getSchema.parseAsync(result), 200);
+          return c.json(await parseWithAwait(getSchema, result), 200);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '获取资源失败'
+          }, 500);
         }
-        return c.json({
-          code: 500,
-          message: error instanceof Error ? error.message : '获取资源失败'
-        }, 500);
-      }
-    })
-    .openapi(updateRouteDef, async (c: any) => {
-      try {
-        const { id } = c.req.valid('param');
-        const data = c.req.valid('json');
-        const user = c.get('user');
-        const result = await crudService.update(id, data, user?.id);
-        
-        if (!result) {
-          return c.json({ code: 404, message: '资源不存在' }, 404);
+      })
+      .openapi(updateRouteDef, async (c: any) => {
+        try {
+          const { id } = c.req.valid('param');
+          const data = c.req.valid('json');
+          const user = c.get('user');
+          const result = await crudService.update(id, data, user?.id);
+          
+          if (!result) {
+            return c.json({ code: 404, message: '资源不存在' }, 404);
+          }
+          
+          return c.json(result, 200);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '更新资源失败'
+          }, 500);
         }
-        
-        return c.json(result, 200);
-      } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+      })
+      .openapi(deleteRouteDef, async (c: any) => {
+        try {
+          const { id } = c.req.valid('param');
+          const success = await crudService.delete(id);
+          
+          if (!success) {
+            return c.json({ code: 404, message: '资源不存在' }, 404);
+          }
+          
+          return c.body(null, 204);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '删除资源失败'
+          }, 500);
         }
-        return c.json({
-          code: 500,
-          message: error instanceof Error ? error.message : '更新资源失败'
-        }, 500);
-      }
-    })
-    .openapi(deleteRouteDef, async (c: any) => {
-      try {
-        const { id } = c.req.valid('param');
-        const success = await crudService.delete(id);
-        
-        if (!success) {
-          return c.json({ code: 404, message: '资源不存在' }, 404);
+      });
+
+    return routes;
+  } else {
+    // 只读模式,只注册 GET 路由
+    const routes = app
+      .openapi(listRoute, async (c) => {
+        try {
+          const query = c.req.valid('query') as any;
+          const { page, pageSize, keyword, sortBy, sortOrder, filters } = query;
+          
+          // 构建排序对象
+          const order: any = {};
+          if (sortBy) {
+            order[sortBy] = sortOrder || 'DESC';
+          } else {
+            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(
+            page,
+            pageSize,
+            keyword,
+            searchFields,
+            undefined,
+            relations || [],
+            order,
+            parsedFilters
+          );
+          
+          return c.json({
+            data: await parseWithAwait(z.array(listSchema), data),
+            pagination: { total, current: page, pageSize }
+          }, 200);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '获取列表失败'
+          }, 500);
         }
-        
-        return c.body(null, 204);
-      } catch (error) {
-        if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+      })
+      .openapi(getRouteDef, async (c: any) => {
+        try {
+          const { id } = c.req.valid('param');
+          const result = await crudService.getById(id, relations || []);
+          
+          if (!result) {
+            return c.json({ code: 404, message: '资源不存在' }, 404);
+          }
+          
+          return c.json(await parseWithAwait(getSchema, result), 200);
+        } catch (error) {
+          if (error instanceof z.ZodError) {
+            return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
+          }
+          return c.json({
+            code: 500,
+            message: error instanceof Error ? error.message : '获取资源失败'
+          }, 500);
         }
-        return c.json({
-          code: 500,
-          message: error instanceof Error ? error.message : '删除资源失败'
-        }, 500);
-      }
-    });
+      });
+    return routes;
+  }
   
-  return routes;
 }

+ 52 - 17
src/server/utils/generic-crud.service.ts

@@ -39,24 +39,43 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
     const query = this.repository.createQueryBuilder('entity');
 
     // 添加关联关系(支持嵌套关联,如 ['contract.client'])
+    // 使用一致的别名生成策略,确保搜索时能正确引用关联字段
     if (relations.length > 0) {
-      relations.forEach((relation, relationIndex) => {
+      relations.forEach((relation) => {
         const parts = relation.split('.');
         let currentAlias = 'entity';
         
         parts.forEach((part, index) => {
-          const newAlias = index === 0 ? part : `${currentAlias}_${relationIndex}`;
+          // 生成一致的别名:对于嵌套关联,使用下划线连接路径
+          const newAlias = index === 0 ? part : parts.slice(0, index + 1).join('_');
           query.leftJoinAndSelect(`${currentAlias}.${part}`, newAlias);
           currentAlias = newAlias;
         });
       });
     }
 
-    // 关键词搜索
+    // 关键词搜索 - 支持关联字段搜索(格式:relation.field 或 relation.nestedRelation.field)
     if (keyword && searchFields && searchFields.length > 0) {
-      query.andWhere(searchFields.map(field => `entity.${field} LIKE :keyword`).join(' OR '), {
-        keyword: `%${keyword}%`
+      const searchConditions: string[] = [];
+      const searchParams: Record<string, string> = { keyword: `%${keyword}%` };
+
+      searchFields.forEach((field) => {
+        // 检查是否为关联字段(包含点号)
+        if (field.includes('.')) {
+          const parts = field.split('.');
+          const alias = parts.slice(0, -1).join('_'); // 使用下划线连接关系路径作为别名
+          const fieldName = parts[parts.length - 1];
+          
+          searchConditions.push(`${alias}.${fieldName} LIKE :keyword`);
+        } else {
+          // 普通字段搜索
+          searchConditions.push(`entity.${field} LIKE :keyword`);
+        }
       });
+
+      if (searchConditions.length > 0) {
+        query.andWhere(`(${searchConditions.join(' OR ')})`, searchParams);
+      }
     }
 
     // 条件查询
@@ -74,38 +93,48 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
         if (value !== undefined && value !== null && value !== '') {
           const fieldName = key.startsWith('_') ? key.substring(1) : key;
           
+          // 检查是否为关联字段(包含点号)
+          let tableAlias = 'entity';
+          let actualFieldName = fieldName;
+          
+          if (fieldName.includes('.')) {
+            const parts = fieldName.split('.');
+            tableAlias = parts.slice(0, -1).join('_'); // 使用下划线连接关系路径作为别名
+            actualFieldName = parts[parts.length - 1];
+          }
+          
           // 支持不同类型的筛选
           if (Array.isArray(value)) {
             // 数组类型:IN查询
             if (value.length > 0) {
-              query.andWhere(`entity.${fieldName} IN (:...${key})`, { [key]: value });
+              query.andWhere(`${tableAlias}.${actualFieldName} IN (:...${key})`, { [key]: value });
             }
           } else if (typeof value === 'string' && value.includes('%')) {
             // 模糊匹配
-            query.andWhere(`entity.${fieldName} LIKE :${key}`, { [key]: value });
+            query.andWhere(`${tableAlias}.${actualFieldName} 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 });
+              query.andWhere(`${tableAlias}.${actualFieldName} >= :${key}_gte`, { [`${key}_gte`]: value.gte });
             }
             if ('gt' in value) {
-              query.andWhere(`entity.${fieldName} > :${key}_gt`, { [`${key}_gt`]: value.gt });
+              query.andWhere(`${tableAlias}.${actualFieldName} > :${key}_gt`, { [`${key}_gt`]: value.gt });
             }
             if ('lte' in value) {
-              query.andWhere(`entity.${fieldName} <= :${key}_lte`, { [`${key}_lte`]: value.lte });
+              query.andWhere(`${tableAlias}.${actualFieldName} <= :${key}_lte`, { [`${key}_lte`]: value.lte });
             }
             if ('lt' in value) {
-              query.andWhere(`entity.${fieldName} < :${key}_lt`, { [`${key}_lt`]: value.lt });
+              query.andWhere(`${tableAlias}.${actualFieldName} < :${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`, {
+              query.andWhere(`${tableAlias}.${actualFieldName} BETWEEN :${key}_start AND :${key}_end`, {
                 [`${key}_start`]: value.between[0],
                 [`${key}_end`]: value.between[1]
               });
             }
           } else {
             // 精确匹配
-            query.andWhere(`entity.${fieldName} = :${key}`, { [key]: value });
+            query.andWhere(`${tableAlias}.${actualFieldName} = :${key}`, { [key]: value });
           }
         }
       });
@@ -116,7 +145,11 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
       query.orderBy(`entity.${key}`, direction);
     });
 
-    return query.skip(skip).take(pageSize).getManyAndCount();
+    const finalQuery = query.skip(skip).take(pageSize);
+
+    // console.log(finalQuery.getSql())
+
+    return finalQuery.getManyAndCount();
   }
 
   /**
@@ -143,12 +176,13 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
       userIdField = 'userId'
     } = this.userTrackingOptions;
 
-    // 设置创建人和更新人
-    if (isCreate && createdByField && data.hasOwnProperty(createdByField)) {
+    // 设置创建人
+    if (isCreate && createdByField) {
       data[createdByField] = userId;
     }
 
-    if (updatedByField && data.hasOwnProperty(updatedByField)) {
+    // 设置更新人
+    if (updatedByField) {
       data[updatedByField] = userId;
     }
 
@@ -289,4 +323,5 @@ export type CrudOptions<
   middleware?: any[];
   userTracking?: UserTrackingOptions;
   relationFields?: RelationFieldOptions;
+  readOnly?: boolean;
 };