2
0
Просмотр исходного кода

✨ feat(crud): 增强通用CRUD功能

- 添加relationFields支持关联字段处理
- 实现readOnly模式,只读模式下仅注册GET路由
- 添加parseWithAwait工具函数处理异步数据验证
- 优化zod错误处理,使用JSON.parse(error.message)标准化错误信息
- 扩展用户跟踪选项,新增userIdField配置
- 为page和pageSize查询参数添加类型注解

♻️ refactor(crud): 重构CRUD服务和路由实现

- 分离路由注册逻辑,根据readOnly模式条件注册不同路由
- 重构ConcreteCrudService构造函数,传递relationFields参数
- 优化create和update方法,增加关联字段处理逻辑
- 重构路由处理函数,统一错误处理和数据验证流程
- 提取handleRelationFields私有方法处理关联字段逻辑

✅ test(crud): 增强数据验证和错误处理

- 为筛选条件解析添加try-catch块,返回格式错误提示
- 增强Zod错误处理,确保所有验证错误被正确捕获和返回
- 为查询参数添加更严格的类型检查和默认值设置
yourname 7 месяцев назад
Родитель
Сommit
d586fccfbe

+ 205 - 124
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,13 +14,13 @@ 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 } = options;
+  const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, relations, middleware = [], userTracking, relationFields, readOnly = false } = options;
   
   // 创建CRUD服务实例
   // 抽象类不能直接实例化,需要创建具体实现类
   class ConcreteCrudService extends GenericCrudService<T> {
     constructor() {
-      super(AppDataSource, entity, { userTracking });
+      super(AppDataSource, entity, { userTracking, relationFields });
     }
   }
   const crudService = new ConcreteCrudService();
@@ -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,
-          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(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;
 }

+ 93 - 3
src/server/utils/generic-crud.service.ts

@@ -1,19 +1,23 @@
-import { DataSource, Repository, ObjectLiteral, DeepPartial } from 'typeorm';
+import { DataSource, Repository, ObjectLiteral, DeepPartial, In } from 'typeorm';
 import { z } from '@hono/zod-openapi';
 
 export abstract class GenericCrudService<T extends ObjectLiteral> {
   protected repository: Repository<T>;
   private userTrackingOptions?: UserTrackingOptions;
 
+  protected relationFields?: RelationFieldOptions;
+
   constructor(
     protected dataSource: DataSource,
     protected entity: new () => T,
     options?: {
       userTracking?: UserTrackingOptions;
+      relationFields?: RelationFieldOptions;
     }
   ) {
     this.repository = this.dataSource.getRepository(entity);
     this.userTrackingOptions = options?.userTracking;
+    this.relationFields = options?.relationFields;
   }
 
   /**
@@ -133,15 +137,53 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
       return;
     }
 
-    const { createdByField = 'createdBy', updatedByField = 'updatedBy' } = this.userTrackingOptions;
+    const {
+      createdByField = 'createdBy',
+      updatedByField = 'updatedBy',
+      userIdField = 'userId'
+    } = this.userTrackingOptions;
 
+    // 设置创建人
     if (isCreate && createdByField) {
       data[createdByField] = userId;
     }
 
+    // 设置更新人
     if (updatedByField) {
       data[updatedByField] = userId;
     }
+
+    // 设置关联的用户ID(如userId字段)
+    if (isCreate && userIdField) {
+      data[userIdField] = userId;
+    }
+  }
+
+  /**
+   * 创建实体
+   */
+  /**
+   * 处理关联字段
+   */
+  private async handleRelationFields(data: any, entity: T, isUpdate: boolean = false): Promise<void> {
+    if (!this.relationFields) return;
+
+    for (const [fieldName, config] of Object.entries(this.relationFields)) {
+      if (data[fieldName] !== undefined) {
+        const ids = data[fieldName];
+        const relationRepository = this.dataSource.getRepository(config.targetEntity);
+        
+        if (ids && Array.isArray(ids) && ids.length > 0) {
+          const relatedEntities = await relationRepository.findBy({ id: In(ids) });
+          (entity as any)[config.relationName] = relatedEntities;
+        } else {
+          (entity as any)[config.relationName] = [];
+        }
+        
+        // 清理原始数据中的关联字段
+        delete data[fieldName];
+      }
+    }
   }
 
   /**
@@ -150,7 +192,23 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
   async create(data: DeepPartial<T>, userId?: string | number): Promise<T> {
     const entityData = { ...data };
     this.setUserFields(entityData, userId, true);
+    
+    // 分离关联字段数据
+    const relationData: any = {};
+    if (this.relationFields) {
+      for (const fieldName of Object.keys(this.relationFields)) {
+        if (fieldName in entityData) {
+          relationData[fieldName] = (entityData as any)[fieldName];
+          delete (entityData as any)[fieldName];
+        }
+      }
+    }
+
     const entity = this.repository.create(entityData as DeepPartial<T>);
+    
+    // 处理关联字段
+    await this.handleRelationFields(relationData, entity);
+    
     return this.repository.save(entity);
   }
 
@@ -160,8 +218,29 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
   async update(id: number, data: Partial<T>, userId?: string | number): Promise<T | null> {
     const updateData = { ...data };
     this.setUserFields(updateData, userId, false);
+    
+    // 分离关联字段数据
+    const relationData: any = {};
+    if (this.relationFields) {
+      for (const fieldName of Object.keys(this.relationFields)) {
+        if (fieldName in updateData) {
+          relationData[fieldName] = (updateData as any)[fieldName];
+          delete (updateData as any)[fieldName];
+        }
+      }
+    }
+
+    // 先更新基础字段
     await this.repository.update(id, updateData);
-    return this.getById(id);
+    
+    // 获取完整实体并处理关联字段
+    const entity = await this.getById(id);
+    if (!entity) return null;
+    
+    // 处理关联字段
+    await this.handleRelationFields(relationData, entity, true);
+    
+    return this.repository.save(entity);
   }
 
   /**
@@ -183,6 +262,15 @@ export abstract class GenericCrudService<T extends ObjectLiteral> {
 export interface UserTrackingOptions {
   createdByField?: string;
   updatedByField?: string;
+  userIdField?: string;
+}
+
+export interface RelationFieldOptions {
+  [fieldName: string]: {
+    relationName: string;
+    targetEntity: new () => any;
+    joinTableName?: string;
+  };
 }
 
 export type CrudOptions<
@@ -201,4 +289,6 @@ export type CrudOptions<
   relations?: string[];
   middleware?: any[];
   userTracking?: UserTrackingOptions;
+  relationFields?: RelationFieldOptions;
+  readOnly?: boolean;
 };

+ 59 - 0
src/server/utils/parseWithAwait.ts

@@ -0,0 +1,59 @@
+import { z } from '@hono/zod-openapi';
+
+export async function parseWithAwait<T>(schema: z.ZodSchema<T>, data: unknown): Promise<T> {  
+    // 先尝试同步解析,捕获 Promise 错误  
+    const syncResult = schema.safeParse(data);  
+      
+    if (!syncResult.success) {  
+      // 提取 Promise 错误的路径信息  
+      const promiseErrors = syncResult.error.issues.filter(issue =>   
+        issue.code === 'invalid_type' &&   
+        issue.message.includes('received Promise')  
+      );  
+        
+      if (promiseErrors.length > 0) {  
+        // 根据路径直接 await Promise  
+        const processedData = await resolvePromisesByPath(data, promiseErrors);  
+          
+        // 重新解析处理后的数据  
+        return schema.parse(processedData) as T;  
+      }  
+        
+      throw syncResult.error;  
+    }  
+      
+    return syncResult.data as T;  
+  }  
+    
+  async function resolvePromisesByPath(data: any, promiseErrors: any[]): Promise<any> {  
+    const clonedData = JSON.parse(JSON.stringify(data, (key, value) => {  
+      // 保留 Promise 对象,不进行序列化  
+      return typeof value?.then === 'function' ? value : value;  
+    }));  
+      
+    // 根据错误路径逐个处理 Promise  
+    for (const error of promiseErrors) {  
+      const path = error.path;  
+      const promiseValue = getValueByPath(data, path);  
+        
+      if (promiseValue && typeof promiseValue.then === 'function') {  
+        const resolvedValue = await promiseValue;  
+        setValueByPath(clonedData, path, resolvedValue);  
+      }  
+    }  
+      
+    return clonedData;  
+  }  
+    
+  function getValueByPath(obj: any, path: (string | number)[]): any {  
+    return path.reduce((current, key) => current?.[key], obj);  
+  }  
+    
+  function setValueByPath(obj: any, path: (string | number)[], value: any): void {  
+    const lastKey = path[path.length - 1];  
+    const parentPath = path.slice(0, -1);  
+    const parent = getValueByPath(obj, parentPath);  
+    if (parent) {  
+      parent[lastKey] = value;  
+    }  
+  }