Ver Fonte

♻️ refactor(crud): 重构CRUD服务和路由系统

- 移除package.json中exports的types字段,优化模块导出配置
- 修改ConcreteCrudService构造函数,移除dataSource参数依赖
- 重构createCrudRoutes函数,移除dataSource参数并更新返回类型
- 调整路由中ConcreteCrudService实例化方式,不再传递dataSource

✅ test(crud): 添加CRUD服务单元测试

- 创建concrete-crud.service.test.ts,测试具体CRUD服务功能
- 创建generic-crud.service.test.ts,测试通用CRUD服务功能
- 覆盖构造函数初始化、列表查询、创建、更新和删除等核心功能

📝 docs(types): 扩展共享类型定义

- 在shared-types中添加AuthContext接口定义,支持认证上下文类型
yourname há 4 semanas atrás
pai
commit
41b4273581

+ 3 - 6
packages/shared-crud/package.json

@@ -8,18 +8,15 @@
   "exports": {
     ".": {
       "import": "./src/index.ts",
-      "require": "./src/index.ts",
-      "types": "./src/index.ts"
+      "require": "./src/index.ts"
     },
     "./services": {
       "import": "./src/services/index.ts",
-      "require": "./src/services/index.ts",
-      "types": "./src/services/index.ts"
+      "require": "./src/services/index.ts"
     },
     "./routes": {
       "import": "./src/routes/index.ts",
-      "require": "./src/routes/index.ts",
-      "types": "./src/routes/index.ts"
+      "require": "./src/routes/index.ts"
     }
   },
   "scripts": {

+ 9 - 10
packages/shared-crud/src/routes/generic-crud.routes.ts

@@ -1,9 +1,9 @@
 import { createRoute, OpenAPIHono, extendZodWithOpenApi } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
+import { ObjectLiteral } from 'typeorm';
 import { CrudOptions } from '../services/generic-crud.service';
 import { ErrorSchema } from '@d8d/shared-utils';
 import { AuthContext } from '@d8d/shared-types';
-import { ObjectLiteral } from 'typeorm';
 import { parseWithAwait } from '@d8d/shared-utils';
 import { ConcreteCrudService } from '../services/concrete-crud.service';
 
@@ -16,9 +16,8 @@ export function createCrudRoutes<
   GetSchema extends z.ZodSchema = z.ZodSchema,
   ListSchema extends z.ZodSchema = z.ZodSchema
 >(
-  dataSource: DataSource,
   options: CrudOptions<T, CreateSchema, UpdateSchema, GetSchema, ListSchema>
-) {
+): OpenAPIHono<AuthContext> {
   const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, relations, middleware = [], userTracking, relationFields, readOnly = false } = options;
 
   // 创建路由实例
@@ -241,7 +240,7 @@ export function createCrudRoutes<
               return c.json({ code: 400, message: '筛选条件格式错误' }, 400);
             }
           }
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -278,7 +277,7 @@ export function createCrudRoutes<
           const data = c.req.valid('json');
           const user = c.get('user');
 
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -299,7 +298,7 @@ export function createCrudRoutes<
         try {
           const { id } = c.req.valid('param');
 
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -328,7 +327,7 @@ export function createCrudRoutes<
           const data = c.req.valid('json');
           const user = c.get('user');
 
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -353,7 +352,7 @@ export function createCrudRoutes<
         try {
           const { id } = c.req.valid('param');
 
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -401,7 +400,7 @@ export function createCrudRoutes<
               return c.json({ code: 400, message: '筛选条件格式错误' }, 400);
             }
           }
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });
@@ -436,7 +435,7 @@ export function createCrudRoutes<
         try {
           const { id } = c.req.valid('param');
 
-          const crudService = new ConcreteCrudService(dataSource, entity, {
+          const crudService = new ConcreteCrudService(entity, {
             userTracking: userTracking,
             relationFields: relationFields
           });

+ 3 - 3
packages/shared-crud/src/services/concrete-crud.service.ts

@@ -1,13 +1,13 @@
-import { DataSource, ObjectLiteral } from 'typeorm';
+import { ObjectLiteral } from 'typeorm';
 import { GenericCrudService, UserTrackingOptions, RelationFieldOptions } from './generic-crud.service';
+import { AppDataSource } from '@d8d/shared-utils';
 
 // 创建具体CRUD服务类
 export class ConcreteCrudService<T extends ObjectLiteral> extends GenericCrudService<T> {
   constructor(
-    dataSource: DataSource,
     entity: new () => T,
     options?: { userTracking?: UserTrackingOptions; relationFields?: RelationFieldOptions }
   ) {
-    super(dataSource, entity, options);
+    super(AppDataSource, entity, options);
   }
 }

+ 97 - 0
packages/shared-crud/tests/unit/concrete-crud.service.test.ts

@@ -0,0 +1,97 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { DataSource, ObjectLiteral } from 'typeorm';
+import { ConcreteCrudService } from '../../src/services/concrete-crud.service';
+
+// 测试实体类
+class TestEntity implements ObjectLiteral {
+  id!: number;
+  name!: string;
+}
+
+describe('ConcreteCrudService', () => {
+  let mockDataSource: DataSource;
+  let concreteService: ConcreteCrudService<TestEntity>;
+
+  beforeEach(() => {
+    mockDataSource = {
+      getRepository: vi.fn().mockReturnValue({
+        createQueryBuilder: vi.fn().mockReturnValue({
+          leftJoinAndSelect: vi.fn().mockReturnThis(),
+          andWhere: vi.fn().mockReturnThis(),
+          orderBy: vi.fn().mockReturnThis(),
+          skip: vi.fn().mockReturnThis(),
+          take: vi.fn().mockReturnThis(),
+          getManyAndCount: vi.fn().mockResolvedValue([[], 0])
+        }),
+        findOne: vi.fn().mockResolvedValue(null),
+        create: vi.fn().mockImplementation((data) => ({ ...data, id: 1 })),
+        save: vi.fn().mockImplementation((entity) => Promise.resolve(entity)),
+        update: vi.fn().mockResolvedValue({ affected: 1 }),
+        delete: vi.fn().mockResolvedValue({ affected: 1 })
+      })
+    } as any;
+
+    concreteService = new ConcreteCrudService(TestEntity);
+  });
+
+  describe('构造函数', () => {
+    it('应该正确初始化父类', () => {
+      expect(concreteService).toBeDefined();
+      expect(mockDataSource.getRepository).toHaveBeenCalledWith(TestEntity);
+    });
+
+    it('应该支持用户跟踪选项', () => {
+      const serviceWithTracking = new ConcreteCrudService(
+        TestEntity,
+        { userTracking: { createdByField: 'creator' } }
+      );
+
+      expect(serviceWithTracking).toBeDefined();
+    });
+
+    it('应该支持关联字段选项', () => {
+      const serviceWithRelations = new ConcreteCrudService(
+        TestEntity,
+        { relationFields: { relatedIds: { relationName: 'related', targetEntity: TestEntity } } }
+      );
+
+      expect(serviceWithRelations).toBeDefined();
+    });
+  });
+
+  describe('继承的方法', () => {
+    it('应该能够调用 getList 方法', async () => {
+      const [data, total] = await concreteService.getList(1, 10);
+
+      expect(data).toEqual([]);
+      expect(total).toBe(0);
+    });
+
+    it('应该能够调用 getById 方法', async () => {
+      const result = await concreteService.getById(1);
+
+      expect(result).toBeNull();
+    });
+
+    it('应该能够调用 create 方法', async () => {
+      const createData = { name: 'Test Entity' };
+      const result = await concreteService.create(createData);
+
+      expect(result).toBeDefined();
+      expect(result.id).toBe(1);
+    });
+
+    it('应该能够调用 update 方法', async () => {
+      const updateData = { name: 'Updated Entity' };
+      const result = await concreteService.update(1, updateData);
+
+      expect(result).toBeNull(); // 因为模拟的 findOne 返回 null
+    });
+
+    it('应该能够调用 delete 方法', async () => {
+      const result = await concreteService.delete(1);
+
+      expect(result).toBe(true);
+    });
+  });
+});

+ 264 - 0
packages/shared-crud/tests/unit/generic-crud.service.test.ts

@@ -0,0 +1,264 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { DataSource, Repository, ObjectLiteral } from 'typeorm';
+import { GenericCrudService, UserTrackingOptions, RelationFieldOptions } from '../../src/services/generic-crud.service';
+
+// 测试实体类
+class TestEntity implements ObjectLiteral {
+  id!: number;
+  name!: string;
+  createdBy?: string;
+  updatedBy?: string;
+  userId?: string;
+}
+
+// 测试关联实体类
+class RelatedEntity implements ObjectLiteral {
+  id!: number;
+  name!: string;
+}
+
+describe('GenericCrudService', () => {
+  let mockDataSource: DataSource;
+  let mockRepository: Repository<TestEntity>;
+  let crudService: GenericCrudService<TestEntity>;
+
+  beforeEach(() => {
+    // 创建模拟数据源和仓库
+    mockRepository = {
+      createQueryBuilder: vi.fn().mockReturnValue({
+        leftJoinAndSelect: vi.fn().mockReturnThis(),
+        andWhere: vi.fn().mockReturnThis(),
+        orderBy: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        take: vi.fn().mockReturnThis(),
+        getManyAndCount: vi.fn().mockResolvedValue([[], 0])
+      }),
+      findOne: vi.fn().mockResolvedValue(null),
+      create: vi.fn().mockImplementation((data) => ({ ...data, id: 1 })),
+      save: vi.fn().mockImplementation((entity) => Promise.resolve(entity)),
+      update: vi.fn().mockResolvedValue({ affected: 1 }),
+      delete: vi.fn().mockResolvedValue({ affected: 1 })
+    } as any;
+
+    mockDataSource = {
+      getRepository: vi.fn().mockReturnValue(mockRepository)
+    } as any;
+
+    crudService = new GenericCrudService(mockDataSource, TestEntity);
+  });
+
+  describe('构造函数', () => {
+    it('应该正确初始化仓库', () => {
+      expect(mockDataSource.getRepository).toHaveBeenCalledWith(TestEntity);
+    });
+
+    it('应该支持用户跟踪选项', () => {
+      const userTrackingOptions: UserTrackingOptions = {
+        createdByField: 'creator',
+        updatedByField: 'updater',
+        userIdField: 'ownerId'
+      };
+
+      const serviceWithTracking = new GenericCrudService(
+        mockDataSource,
+        TestEntity,
+        { userTracking: userTrackingOptions }
+      );
+
+      expect(serviceWithTracking).toBeDefined();
+    });
+
+    it('应该支持关联字段选项', () => {
+      const relationFields: RelationFieldOptions = {
+        relatedIds: {
+          relationName: 'relatedEntities',
+          targetEntity: RelatedEntity
+        }
+      };
+
+      const serviceWithRelations = new GenericCrudService(
+        mockDataSource,
+        TestEntity,
+        { relationFields }
+      );
+
+      expect(serviceWithRelations).toBeDefined();
+    });
+  });
+
+  describe('getList 方法', () => {
+    it('应该调用查询构建器获取列表', async () => {
+      const mockQueryBuilder = {
+        leftJoinAndSelect: vi.fn().mockReturnThis(),
+        andWhere: vi.fn().mockReturnThis(),
+        orderBy: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        take: vi.fn().mockReturnThis(),
+        getManyAndCount: vi.fn().mockResolvedValue([[], 0])
+      };
+
+      vi.mocked(mockRepository.createQueryBuilder).mockReturnValue(mockQueryBuilder as any);
+
+      const [data, total] = await crudService.getList(1, 10);
+
+      expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('entity');
+      expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
+      expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
+      expect(data).toEqual([]);
+      expect(total).toBe(0);
+    });
+
+    it('应该支持关键词搜索', async () => {
+      const mockQueryBuilder = {
+        leftJoinAndSelect: vi.fn().mockReturnThis(),
+        andWhere: vi.fn().mockReturnThis(),
+        orderBy: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        take: vi.fn().mockReturnThis(),
+        getManyAndCount: vi.fn().mockResolvedValue([[], 0])
+      };
+
+      vi.mocked(mockRepository.createQueryBuilder).mockReturnValue(mockQueryBuilder as any);
+
+      await crudService.getList(1, 10, 'test', ['name']);
+
+      expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
+    });
+  });
+
+  describe('getById 方法', () => {
+    it('应该根据ID获取实体', async () => {
+      const testEntity = { id: 1, name: 'Test' };
+      vi.mocked(mockRepository.findOne).mockResolvedValue(testEntity as any);
+
+      const result = await crudService.getById(1);
+
+      expect(mockRepository.findOne).toHaveBeenCalledWith({
+        where: { id: 1 },
+        relations: []
+      });
+      expect(result).toEqual(testEntity);
+    });
+
+    it('应该支持关联关系', async () => {
+      await crudService.getById(1, ['relatedEntity']);
+
+      expect(mockRepository.findOne).toHaveBeenCalledWith({
+        where: { id: 1 },
+        relations: ['relatedEntity']
+      });
+    });
+  });
+
+  describe('create 方法', () => {
+    it('应该创建新实体', async () => {
+      const createData = { name: 'New Entity' };
+      const savedEntity = { id: 1, ...createData };
+
+      vi.mocked(mockRepository.create).mockReturnValue(savedEntity as any);
+      vi.mocked(mockRepository.save).mockResolvedValue(savedEntity as any);
+
+      const result = await crudService.create(createData);
+
+      expect(mockRepository.create).toHaveBeenCalledWith(createData);
+      expect(mockRepository.save).toHaveBeenCalledWith(savedEntity);
+      expect(result).toEqual(savedEntity);
+    });
+
+    it('应该支持用户跟踪', async () => {
+      const userTrackingOptions: UserTrackingOptions = {
+        createdByField: 'createdBy',
+        updatedByField: 'updatedBy'
+      };
+
+      const serviceWithTracking = new GenericCrudService(
+        mockDataSource,
+        TestEntity,
+        { userTracking: userTrackingOptions }
+      );
+
+      const createData = { name: 'New Entity' };
+      const savedEntity = { id: 1, ...createData, createdBy: 'user123', updatedBy: 'user123' };
+
+      vi.mocked(mockRepository.create).mockReturnValue(savedEntity as any);
+      vi.mocked(mockRepository.save).mockResolvedValue(savedEntity as any);
+
+      await serviceWithTracking.create(createData, 'user123');
+
+      expect(mockRepository.create).toHaveBeenCalledWith({
+        ...createData,
+        createdBy: 'user123',
+        updatedBy: 'user123',
+        userId: 'user123'
+      });
+    });
+  });
+
+  describe('update 方法', () => {
+    it('应该更新现有实体', async () => {
+      const updateData = { name: 'Updated Entity' };
+      const existingEntity = { id: 1, name: 'Original Entity' };
+      const updatedEntity = { ...existingEntity, ...updateData };
+
+      vi.mocked(mockRepository.findOne).mockResolvedValue(existingEntity as any);
+      vi.mocked(mockRepository.save).mockResolvedValue(updatedEntity as any);
+
+      const result = await crudService.update(1, updateData);
+
+      expect(mockRepository.update).toHaveBeenCalledWith(1, updateData);
+      expect(mockRepository.findOne).toHaveBeenCalledWith({
+        where: { id: 1 },
+        relations: []
+      });
+      expect(result).toEqual(updatedEntity);
+    });
+
+    it('应该返回 null 当实体不存在时', async () => {
+      vi.mocked(mockRepository.findOne).mockResolvedValue(null);
+
+      const result = await crudService.update(999, { name: 'Updated' });
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('delete 方法', () => {
+    it('应该删除实体', async () => {
+      vi.mocked(mockRepository.delete).mockResolvedValue({ affected: 1 } as any);
+
+      const result = await crudService.delete(1);
+
+      expect(mockRepository.delete).toHaveBeenCalledWith(1);
+      expect(result).toBe(true);
+    });
+
+    it('应该返回 false 当实体不存在时', async () => {
+      vi.mocked(mockRepository.delete).mockResolvedValue({ affected: 0 } as any);
+
+      const result = await crudService.delete(999);
+
+      expect(result).toBe(false);
+    });
+  });
+
+  describe('createQueryBuilder 方法', () => {
+    it('应该返回查询构建器', () => {
+      const mockQueryBuilder = {} as any;
+      vi.mocked(mockRepository.createQueryBuilder).mockReturnValue(mockQueryBuilder);
+
+      const result = crudService.createQueryBuilder('test');
+
+      expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('test');
+      expect(result).toBe(mockQueryBuilder);
+    });
+
+    it('应该使用默认别名', () => {
+      const mockQueryBuilder = {} as any;
+      vi.mocked(mockRepository.createQueryBuilder).mockReturnValue(mockQueryBuilder);
+
+      crudService.createQueryBuilder();
+
+      expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('entity');
+    });
+  });
+});

+ 9 - 1
packages/shared-types/src/index.ts

@@ -74,4 +74,12 @@ export interface JWTPayload {
   username: string;
   roles?: string[];
   openid?: string;
-}
+}
+
+// Hono 认证上下文类型
+export type AuthContext = {
+  Variables: {
+    user: any; // 用户类型将在具体模块中定义
+    token: string;
+  }
+};

+ 28 - 19
pnpm-lock.yaml

@@ -229,25 +229,6 @@ importers:
         specifier: ^0.0.10
         version: 0.0.10(webpack@5.91.0(@swc/core@1.3.96))
 
-  packages/auth-core:
-    dependencies:
-      '@d8d/shared-types':
-        specifier: workspace:*
-        version: link:../shared-types
-      bcrypt:
-        specifier: ^6.0.0
-        version: 6.0.0
-      jsonwebtoken:
-        specifier: ^9.0.2
-        version: 9.0.2
-    devDependencies:
-      typescript:
-        specifier: ^5.8.3
-        version: 5.8.3
-      vitest:
-        specifier: ^3.2.4
-        version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
-
   packages/server:
     dependencies:
       '@asteasolutions/zod-to-openapi':
@@ -327,6 +308,34 @@ importers:
         specifier: ^3.2.4
         version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
 
+  packages/shared-crud:
+    dependencies:
+      '@d8d/shared-types':
+        specifier: workspace:*
+        version: link:../shared-types
+      '@d8d/shared-utils':
+        specifier: workspace:*
+        version: link:../shared-utils
+      '@hono/zod-openapi':
+        specifier: 1.0.2
+        version: 1.0.2(hono@4.8.5)(zod@4.1.12)
+      typeorm:
+        specifier: ^0.3.20
+        version: 0.3.27(ioredis@5.8.2)(pg@8.16.3)(redis@4.7.1)(reflect-metadata@0.2.2)
+      zod:
+        specifier: ^4.1.12
+        version: 4.1.12
+    devDependencies:
+      '@vitest/coverage-v8':
+        specifier: ^3.2.4
+        version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
+      typescript:
+        specifier: ^5.8.3
+        version: 5.8.3
+      vitest:
+        specifier: ^3.2.4
+        version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
+
   packages/shared-types:
     devDependencies:
       typescript: