2
0
Эх сурвалжийг харах

✨ feat(api): 实现角色管理功能

- 添加角色CRUD路由模块,支持角色的创建、查询、更新和删除操作
- 实现角色服务层,包含按名称查询角色和权限检查功能
- 在API主路由中注册角色路由,路径为/api/v1/roles

✨ feat(utils): 添加通用CRUD工具

- 创建generic-crud.service.ts,提供基础的CRUD操作抽象类
- 实现generic-crud.routes.ts,自动生成CRUD路由及OpenAPI文档
- 支持分页、搜索、排序和权限中间件等功能

📝 docs(swagger): 优化Swagger UI加载方式

- 自定义Swagger UI HTML模板,使用外部CDN资源
- 优化Swagger UI加载性能和授权状态持久化
yourname 5 сар өмнө
parent
commit
4cf01840dc

+ 3 - 0
src/server/api.ts

@@ -2,6 +2,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'
 import { errorHandler } from './utils/errorHandler'
 import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
+import rolesRoute from './api/roles/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 
@@ -51,8 +52,10 @@ if(!import.meta.env.PROD){
 
 const userRoutes = api.route('/api/v1/users', usersRouter)
 const authRoutes = api.route('/api/v1/auth', authRoute)
+const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
+export type RoleRoutes = typeof roleRoutes
 
 export default api

+ 22 - 0
src/server/api/roles/index.ts

@@ -0,0 +1,22 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Role } from '@/server/modules/users/role.entity';
+import { RoleSchema, CreateRoleDto, UpdateRoleDto } from '@/server/modules/users/role.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { checkPermission } from '@/server/middleware/permission.middleware';
+import { OpenAPIHono } from '@hono/zod-openapi';
+
+// 创建角色CRUD路由
+const roleRoutes = createCrudRoutes({
+  entity: Role,
+  createSchema: CreateRoleDto,
+  updateSchema: UpdateRoleDto,
+  getSchema: RoleSchema,
+  listSchema: RoleSchema,
+  searchFields: ['name', 'description'],
+  middleware: [authMiddleware, checkPermission(['role:manage'])]
+})
+const app = new OpenAPIHono()
+  .route('/', roleRoutes)
+// .route('/', customRoute)
+
+export default app;

+ 17 - 1
src/server/index.tsx

@@ -25,7 +25,23 @@ app.route('/', createApi)
 if(!import.meta.env.PROD){
   app.get('/ui', swaggerUI({
     url: '/doc',
-    persistAuthorization: true
+    persistAuthorization: true,
+    manuallySwaggerUIHtml: (asset) => `
+      <div>
+        <div id="swagger-ui"></div>
+        <link rel="stylesheet" href="https://ai-oss.d8d.fun/swagger-ui-dist/swagger-ui.css" />
+        <script src="https://ai-oss.d8d.fun/swagger-ui-dist/swagger-ui-bundle.js" crossorigin="anonymous"></script>
+        <script>
+          window.onload = () => {
+            window.ui = SwaggerUIBundle({
+              dom_id: '#swagger-ui',
+              url: '/doc',
+              persistAuthorization: true
+            })
+          }
+        </script>
+      </div>
+    `
   }))
 }
 

+ 20 - 0
src/server/modules/users/role.service.ts

@@ -0,0 +1,20 @@
+import { DataSource, Repository } from 'typeorm';
+import { Role } from './role.entity';
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+
+export class RoleService extends GenericCrudService<Role> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Role);
+  }
+  
+  // 可以添加角色特有的业务逻辑方法
+  async getRoleByName(name: string): Promise<Role | null> {
+    return this.repository.findOneBy({ name });
+  }
+  
+  async checkPermission(roleId: number, permission: string): Promise<boolean> {
+    const role = await this.getById(roleId);
+    if (!role) return false;
+    return role.permissions.includes(permission);
+  }
+}

+ 332 - 0
src/server/utils/generic-crud.routes.ts

@@ -0,0 +1,332 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { GenericCrudService, CrudOptions } from './generic-crud.service';
+import { ErrorSchema } from './errorHandler';
+import { AuthContext } from '../types/context';
+import { ObjectLiteral } from 'typeorm';
+import { AppDataSource } from '../data-source';
+
+export function createCrudRoutes<T extends ObjectLiteral>(options: CrudOptions<T>) {
+  const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, middleware = [] } = options;
+  
+  // 创建CRUD服务实例
+  // 抽象类不能直接实例化,需要创建具体实现类
+  class ConcreteCrudService extends GenericCrudService<T> {
+    constructor() {
+      super(AppDataSource, entity);
+    }
+  }
+  const crudService = new ConcreteCrudService();
+  
+  // 创建路由实例
+  const app = new OpenAPIHono<AuthContext>();
+  
+  // 分页查询路由
+  const listRoute = createRoute({
+    method: 'get',
+    path: '/',
+    middleware,
+    request: {
+      query: z.object({
+        page: z.coerce.number().int().positive().default(1).openapi({
+          example: 1,
+          description: '页码,从1开始'
+        }),
+        pageSize: z.coerce.number().int().positive().default(10).openapi({
+          example: 10,
+          description: '每页数量'
+        }),
+        keyword: z.string().optional().openapi({
+          example: '搜索关键词',
+          description: '搜索关键词'
+        }),
+        sortBy: z.string().optional().openapi({
+          example: 'createdAt',
+          description: '排序字段'
+        }),
+        sortOrder: z.enum(['ASC', 'DESC']).optional().default('DESC').openapi({
+          example: 'DESC',
+          description: '排序方向'
+        })
+      })
+    },
+    responses: {
+      200: {
+        description: '成功获取列表',
+        content: {
+          'application/json': {
+            schema: z.object({
+              data: z.array(listSchema),
+              pagination: z.object({
+                total: z.number().openapi({ example: 100, description: '总记录数' }),
+                current: z.number().openapi({ example: 1, description: '当前页码' }),
+                pageSize: z.number().openapi({ example: 10, description: '每页数量' })
+              })
+            })
+          }
+        }
+      },
+      400: {
+        description: '参数错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 创建资源路由
+  const createRouteDef = createRoute({
+    method: 'post',
+    path: '/',
+    middleware,
+    request: {
+      body: {
+        content: {
+          'application/json': { schema: createSchema }
+        }
+      }
+    },
+    responses: {
+      201: {
+        description: '创建成功',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '输入数据无效',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 获取单个资源路由
+  const getRouteDef = createRoute({
+    method: 'get',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      })
+    },
+    responses: {
+      200: {
+        description: '成功获取详情',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      404: {
+        description: '参数验证失败',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 更新资源路由
+  const updateRouteDef = createRoute({
+    method: 'put',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      }),
+      body: {
+        content: {
+          'application/json': { schema: updateSchema }
+        }
+      }
+    },
+    responses: {
+      200: {
+        description: '更新成功',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '无效输入',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      404: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 删除资源路由
+  const deleteRouteDef = createRoute({
+    method: 'delete',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      })
+    },
+    responses: {
+      204: { description: '删除成功' },
+      404: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 注册路由处理函数
+  const routes = app
+    .openapi(listRoute, async (c) => {
+      try {
+        const { page, pageSize, keyword, sortBy, sortOrder } = c.req.valid('query');
+        
+        // 构建排序对象
+        // 使用Record和类型断言解决泛型索引写入问题
+        const order: Partial<Record<keyof T, 'ASC' | 'DESC'>> = {};
+        if (sortBy) {
+          (order as Record<string, 'ASC' | 'DESC'>)[sortBy] = sortOrder || 'DESC';
+        } else {
+          // 默认按id降序排序
+          (order as Record<string, 'ASC' | 'DESC'>)['id'] = 'DESC';
+        }
+        
+        const [data, total] = await crudService.getList(
+          page,
+          pageSize,
+          keyword,
+          searchFields,
+          undefined, // where条件
+          [], // relations
+          order
+        );
+        
+        return c.json({
+          data: data as any[],
+          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) => {
+      try {
+        const data = c.req.valid('json');
+        const result = await crudService.create(data);
+        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) => {
+      try {
+        const { id } = c.req.valid('param');
+        const result = await crudService.getById(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: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '获取资源失败'
+        }, 500);
+      }
+    })
+    .openapi(updateRouteDef, async (c) => {
+      try {
+        const { id } = c.req.valid('param');
+        const data = c.req.valid('json');
+        const result = await crudService.update(id, data);
+        
+        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: error.errors }, 400);
+        }
+        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(deleteRouteDef, async (c) => {
+      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) as unknown as Response;
+      } 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);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '删除资源失败'
+        }, 500);
+      }
+    });
+  
+  return routes;
+}

+ 110 - 0
src/server/utils/generic-crud.service.ts

@@ -0,0 +1,110 @@
+import { DataSource, Repository, ObjectLiteral, DeepPartial } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+export abstract class GenericCrudService<T extends ObjectLiteral> {
+  protected repository: Repository<T>;
+  
+  constructor(
+    protected dataSource: DataSource,
+    protected entity: new () => T
+  ) {
+    this.repository = this.dataSource.getRepository(entity);
+  }
+
+  /**
+   * 获取分页列表
+   */
+  /**
+   * 获取分页列表,支持高级查询
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields?: string[],
+    where?: Partial<T>,
+    relations: string[] = [],
+    order: { [P in keyof T]?: 'ASC' | 'DESC' } = {}
+  ): Promise<[T[], number]> {
+    const skip = (page - 1) * pageSize;
+    const query = this.repository.createQueryBuilder('entity');
+    
+    // 关联查询
+    if (relations.length > 0) {
+      relations.forEach(relation => {
+        query.leftJoinAndSelect(`entity.${relation}`, relation);
+      });
+    }
+    
+    // 关键词搜索
+    if (keyword && searchFields && searchFields.length > 0) {
+      query.andWhere(searchFields.map(field => `entity.${field} LIKE :keyword`).join(' OR '), {
+        keyword: `%${keyword}%`
+      });
+    }
+    
+    // 条件查询
+    if (where) {
+      Object.entries(where).forEach(([key, value]) => {
+        if (value !== undefined && value !== null) {
+          query.andWhere(`entity.${key} = :${key}`, { [key]: value });
+        }
+      });
+    }
+    
+    // 排序
+    Object.entries(order).forEach(([key, direction]) => {
+      query.orderBy(`entity.${key}`, direction);
+    });
+    
+    return query.skip(skip).take(pageSize).getManyAndCount();
+  }
+
+  /**
+   * 高级查询方法
+   */
+  createQueryBuilder(alias: string = 'entity') {
+    return this.repository.createQueryBuilder(alias);
+  }
+
+  /**
+   * 根据ID获取单个实体
+   */
+  async getById(id: number): Promise<T | null> {
+    return this.repository.findOneBy({ id } as any);
+  }
+
+  /**
+   * 创建实体
+   */
+  async create(data: DeepPartial<T>): Promise<T> {
+    const entity = this.repository.create(data as DeepPartial<T>);
+    return this.repository.save(entity);
+  }
+
+  /**
+   * 更新实体
+   */
+  async update(id: number, data: Partial<T>): Promise<T | null> {
+    await this.repository.update(id, data);
+    return this.getById(id);
+  }
+
+  /**
+   * 删除实体
+   */
+  async delete(id: number): Promise<boolean> {
+    const result = await this.repository.delete(id);
+    return result.affected === 1;
+  }
+}
+
+export type CrudOptions<T extends ObjectLiteral> = {
+  entity: new () => T;
+  createSchema: z.ZodSchema;
+  updateSchema: z.ZodSchema;
+  getSchema: z.ZodSchema;
+  listSchema: z.ZodSchema;
+  searchFields?: string[];
+  middleware?: any[];
+};