瀏覽代碼

✨ feat(crud): add relation field support for generic CRUD system

- add `relations` option in route configuration to specify associated query relationships
- add `relationFields` configuration for many-to-many relationship management
- implement automatic handling of many-to-many relationship operations in transactions
- add support for associated entity queries in GET / and GET /{id} endpoints
- add `userIdField` option in user tracking for automatic user association

✨ fix(crud): improve user tracking field handling

- fix user tracking to only set fields that exist in the data
- add userIdField support for automatic user ID association
- optimize createdBy and updatedBy field assignment logic

📝 docs(crud): update documentation for relationship features

- document new `relations` and `relationFields` configuration options
- add examples for many-to-many relationship setup and usage
- update API documentation for enhanced CRUD endpoints
- add RelationFieldOptions interface documentation
- update entity service examples with relationship handling
yourname 4 月之前
父節點
當前提交
9cf1fa854a
共有 3 個文件被更改,包括 201 次插入12 次删除
  1. 106 5
      .roo/rules/12-generic-crud.md
  2. 2 2
      src/server/utils/generic-crud.routes.ts
  3. 93 5
      src/server/utils/generic-crud.service.ts

+ 106 - 5
.roo/rules/12-generic-crud.md

@@ -32,7 +32,7 @@ export class YourEntityService extends GenericCrudService<YourEntity> {
 | 方法 | 描述 | 参数 | 返回值 |
 |------|------|------|--------|
 | `getList` | 获取分页列表 | `page`, `pageSize`, `keyword`, `searchFields`, `where`, `relations`, `order` | `[T[], number]` |
-| `getById` | 根据ID获取单个实体 | `id: number` | `T \| null` |
+| `getById` | 根据ID获取单个实体 | `id: number`, `relations?: string[]` | `T \| null` |
 | `create` | 创建实体 | `data: DeepPartial<T>` | `T` |
 | `update` | 更新实体 | `id: number`, `data: Partial<T>` | `T \| null` |
 | `delete` | 删除实体 | `id: number` | `boolean` |
@@ -73,6 +73,7 @@ const yourEntityRoutes = createCrudRoutes({
   getSchema: YourEntitySchema,
   listSchema: YourEntitySchema,
   searchFields: ['name', 'description'], // 可选,指定搜索字段
+  relations: ['relatedEntity'], // 可选,指定关联查询关系(支持嵌套关联,如 ['relatedEntity.client'])
   middleware: [authMiddleware] // 可选,添加中间件
 });
 
@@ -89,7 +90,9 @@ export default yourEntityRoutes;
 | `getSchema` | `z.ZodSchema` | 获取单个实体的响应 schema | 是 |
 | `listSchema` | `z.ZodSchema` | 获取列表的响应 schema | 是 |
 | `searchFields` | `string[]` | 搜索字段数组,用于关键词搜索 | 否 |
+| `relations` | `string[]` | 关联查询配置,指定需要关联查询的关系 | 否 |
 | `middleware` | `any[]` | 应用于所有CRUD路由的中间件数组 | 否 |
+| `relationFields` | `RelationFieldOptions` | 多对多关联字段配置,支持通过ID数组操作关联关系 | 否 |
 
 ### 2.3 生成的路由
 
@@ -97,9 +100,9 @@ export default yourEntityRoutes;
 
 | 方法 | 路径 | 描述 |
 |------|------|------|
-| GET | `/` | 获取实体列表(支持分页、搜索、排序) |
+| GET | `/` | 获取实体列表(支持分页、搜索、排序、关联查询) |
 | POST | `/` | 创建新实体 |
-| GET | `/{id}` | 获取单个实体详情 |
+| GET | `/{id}` | 获取单个实体详情(支持关联查询) |
 | PUT | `/{id}` | 更新实体 |
 | DELETE | `/{id}` | 删除实体 |
 
@@ -129,8 +132,9 @@ api.route('/api/v1/your-entities', yourEntityRoutes);
 
 ```typescript
 // your-entity.entity.ts
-import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';
 import { z } from '@hono/zod-openapi';
+import { RelatedEntity } from './related-entity.entity';
 
 @Entity('your_entity')
 export class YourEntity {
@@ -140,6 +144,9 @@ export class YourEntity {
   @Column({ name: 'name', type: 'varchar', length: 255 })
   name!: string;
   
+  @ManyToOne(() => RelatedEntity, related => related.yourEntities)
+  relatedEntity!: RelatedEntity;
+  
   // 其他字段...
 }
 
@@ -147,20 +154,113 @@ export class YourEntity {
 export const YourEntitySchema = z.object({
   id: z.number().int().positive().openapi({ description: '实体ID' }),
   name: z.string().max(255).openapi({ description: '名称', example: '示例名称' }),
+  relatedEntity: RelatedEntitySchema, // 关联实体schema
   // 其他字段schema...
 });
 
 export const CreateYourEntityDto = z.object({
   name: z.string().max(255).openapi({ description: '名称', example: '示例名称' }),
+  relatedEntityId: z.number().int().positive().openapi({ description: '关联实体ID', example: 1 }),
   // 其他创建字段schema...
 });
 
 export const UpdateYourEntityDto = z.object({
   name: z.string().max(255).optional().openapi({ description: '名称', example: '示例名称' }),
+  relatedEntityId: z.number().int().positive().optional().openapi({ description: '关联实体ID', example: 1 }),
   // 其他更新字段schema...
 });
 ```
 
+### 2.5 多对多关联字段配置
+
+#### 2.5.1 关联字段配置 (RelationFieldOptions)
+
+新增 `relationFields` 配置选项,用于处理多对多关联字段:
+
+```typescript
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { PolicyNews, PolicyNewsSchema, CreatePolicyNewsDto, UpdatePolicyNewsDto } from '@/server/modules/silver-users/policy-news.entity';
+import { File } from '@/server/modules/files/file.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const policyNewsRoutes = createCrudRoutes({
+  entity: PolicyNews,
+  createSchema: CreatePolicyNewsDto,
+  updateSchema: UpdatePolicyNewsDto,
+  getSchema: PolicyNewsSchema,
+  listSchema: PolicyNewsSchema,
+  relations: ['files'],
+  middleware: [authMiddleware],
+  relationFields: {
+    fileIds: {
+      relationName: 'files',        // 实体中的关联属性名
+      targetEntity: File,           // 关联的目标实体类
+      joinTableName: 'policy_news_files' // 可选:中间表名
+    }
+  }
+});
+```
+
+#### 2.5.2 RelationFieldOptions 类型定义
+
+```typescript
+interface RelationFieldOptions {
+  [fieldName: string]: {
+    relationName: string;      // 实体中的关联属性名
+    targetEntity: new () => any; // 关联的目标实体类
+    joinTableName?: string;    // 中间表名(可选)
+  };
+}
+```
+
+#### 2.5.3 使用示例
+
+**实体定义:**
+```typescript
+@Entity('policy_news')
+export class PolicyNews {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'news_title', type: 'varchar', length: 255 })
+  newsTitle!: string;
+
+  // 关联文件的多对多关系
+  @ManyToMany(() => File)
+  @JoinTable({
+    name: 'policy_news_files',
+    joinColumn: { name: 'policy_news_id', referencedColumnName: 'id' },
+    inverseJoinColumn: { name: 'file_id', referencedColumnName: 'id' }
+  })
+  files?: File[];
+}
+```
+
+**请求格式:**
+```typescript
+// 创建政策资讯
+POST /api/v1/policy-news
+{
+  "newsTitle": "新政策解读",
+  "fileIds": [1, 2, 3]  // 关联文件ID数组
+}
+
+// 更新政策资讯
+PUT /api/v1/policy-news/1
+{
+  "newsTitle": "更新后的政策解读",
+  "fileIds": [2, 4, 5]  // 更新后的文件ID数组
+}
+```
+
+#### 2.5.4 特性说明
+
+- **自动处理**:系统会自动处理多对多关联的中间表操作
+- **事务安全**:所有关联操作都在事务中执行
+- **空数组支持**:传空数组 `[]` 表示清除所有关联
+- **向后兼容**:不影响现有实体的使用方式
+- **类型安全**:支持完整的TypeScript类型检查
+
 ## 4. 高级用法
 
 ### 4.1 自定义中间件
@@ -249,4 +349,5 @@ export class YourEntityService extends GenericCrudService<YourEntity> {
 4. **数据验证**:确保Zod schema包含完整的验证规则和OpenAPI元数据
 5. **搜索优化**:合理设置`searchFields`,避免在大表的文本字段上进行模糊搜索
 6. **分页处理**:所有列表接口必须支持分页,避免返回大量数据
-7. **事务管理**:复杂操作应使用事务确保数据一致性
+7. **关联查询**:使用`relations`配置时,避免过度关联导致性能问题
+8. **事务管理**:复杂操作应使用事务确保数据一致性

+ 2 - 2
src/server/utils/generic-crud.routes.ts

@@ -13,13 +13,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 } = options;
   
   // 创建CRUD服务实例
   // 抽象类不能直接实例化,需要创建具体实现类
   class ConcreteCrudService extends GenericCrudService<T> {
     constructor() {
-      super(AppDataSource, entity, { userTracking });
+      super(AppDataSource, entity, { userTracking, relationFields });
     }
   }
   const crudService = new ConcreteCrudService();

+ 93 - 5
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,52 @@ 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) {
+    // 设置创建人和更新人
+    if (isCreate && createdByField && data.hasOwnProperty(createdByField)) {
       data[createdByField] = userId;
     }
 
-    if (updatedByField) {
+    if (updatedByField && data.hasOwnProperty(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 +191,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 +217,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 +261,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 +288,5 @@ export type CrudOptions<
   relations?: string[];
   middleware?: any[];
   userTracking?: UserTrackingOptions;
+  relationFields?: RelationFieldOptions;
 };