diff --git a/backend/app/Command/RouteSyncCommand.php b/backend/app/Command/RouteSyncCommand.php index adba0c0..8cea8d6 100644 --- a/backend/app/Command/RouteSyncCommand.php +++ b/backend/app/Command/RouteSyncCommand.php @@ -4,12 +4,9 @@ declare(strict_types=1); namespace App\Command; -use App\Model\Route; +use App\Service\RouteSyncService; use Hyperf\Command\Annotation\Command; use Hyperf\Command\Command as HyperfCommand; -use Hyperf\HttpServer\Router\DispatcherFactory; -use Hyperf\HttpServer\Router\Handler; -use Hyperf\HttpServer\Router\RouteCollector; use Psr\Container\ContainerInterface; #[Command] @@ -28,80 +25,7 @@ class RouteSyncCommand extends HyperfCommand public function handle(): void { - $factory = $this->container->get(DispatcherFactory::class); - $router = $factory->getRouter('http'); - $routes = $this->extractRoutes($router); - - $synced = 0; - foreach ($routes as $route_info) { - Route::query()->updateOrCreate( - ['method' => $route_info['method'], 'path' => $route_info['path']], - ['name' => $route_info['name']] - ); - $synced++; - } - - $this->info("Synced {$synced} routes to database."); - } - - /** - * 从路由收集器中提取所有 API 路由 - * - * @return array - */ - protected function extractRoutes(RouteCollector $router): array - { - $routes = []; - [$staticRouters, $variableRouters] = $router->getData(); - - // 静态路由(无路径参数) - foreach ($staticRouters as $method => $items) { - foreach ($items as $handler) { - $this->collectRoute($routes, $method, $handler); - } - } - - // 动态路由(含路径参数如 {id}) - foreach ($variableRouters as $method => $items) { - foreach ($items as $item) { - if (is_array($item['routeMap'] ?? false)) { - foreach ($item['routeMap'] as $routeMap) { - $this->collectRoute($routes, $method, $routeMap[0]); - } - } - } - } - - return $routes; - } - - /** - * 收集单条路由信息,仅同步 /api/ 前缀的路由 - */ - protected function collectRoute(array &$routes, string $method, Handler $handler): void - { - $path = $handler->route; - - // 仅同步 API 路由 - if (!str_starts_with($path, '/api/')) { - return; - } - - // 解析 action 名称 - $name = null; - if (is_array($handler->callback)) { - $name = $handler->callback[0] . '::' . $handler->callback[1]; - } elseif (is_string($handler->callback)) { - $name = $handler->callback; - } - - $key = $method . '|' . $path; - if (!isset($routes[$key])) { - $routes[$key] = [ - 'method' => $method, - 'path' => $path, - 'name' => $name, - ]; - } + $result = $this->container->get(RouteSyncService::class)->sync(); + $this->info("Synced {$result['synced']} routes to database."); } } diff --git a/backend/app/Controller/api/v1/RouteGroupController.php b/backend/app/Controller/api/v1/RouteGroupController.php index cf8232c..d2a2634 100644 --- a/backend/app/Controller/api/v1/RouteGroupController.php +++ b/backend/app/Controller/api/v1/RouteGroupController.php @@ -9,6 +9,7 @@ use App\Middleware\AuthMiddleware; use App\Middleware\PermissionMiddleware; use App\Model\Route; use App\Model\RouteGroup; +use App\Service\RouteSyncService; use Hyperf\DbConnection\Db; use Hyperf\HttpServer\Annotation\Controller; use Hyperf\HttpServer\Annotation\Middleware; @@ -641,4 +642,46 @@ class RouteGroupController extends AbstractController 'data' => $group, ]; } + + /** + * 同步注解路由到数据库 + * + * 将 Hyperf 注解定义的 API 路由同步到 routes 表,管理员专用 + */ + #[OA\Post( + path: '/routes/sync', + summary: '同步注解路由到数据库', + description: '将 Hyperf 注解定义的 API 路由同步到 routes 表,支持重复调用(幂等)', + security: [['bearerAuth' => []]], + tags: ['Route Groups'], + responses: [ + new OA\Response( + response: 200, + description: '同步成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '同步成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'synced', type: 'integer', description: '同步的路由数量', example: 50), + new OA\Property(property: 'total', type: 'integer', description: '总路由数量', example: 50), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "/api/v1/routes/sync", methods: "POST")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function sync(): array + { + $result = $this->container->get(RouteSyncService::class)->sync(); + + return [ + 'code' => 0, + 'message' => '同步成功', + 'data' => $result, + ]; + } } diff --git a/backend/app/Service/RouteSyncService.php b/backend/app/Service/RouteSyncService.php new file mode 100644 index 0000000..8b85042 --- /dev/null +++ b/backend/app/Service/RouteSyncService.php @@ -0,0 +1,105 @@ +container->get(DispatcherFactory::class); + $router = $factory->getRouter('http'); + $routes = $this->extractRoutes($router); + + $synced = 0; + foreach ($routes as $route_info) { + Route::query()->updateOrCreate( + ['method' => $route_info['method'], 'path' => $route_info['path']], + ['name' => $route_info['name']] + ); + $synced++; + } + + return [ + 'synced' => $synced, + 'total' => count($routes), + ]; + } + + /** + * 从路由收集器中提取所有 API 路由 + * + * @return array + */ + private function extractRoutes(RouteCollector $router): array + { + $routes = []; + [$staticRouters, $variableRouters] = $router->getData(); + + // 静态路由(无路径参数) + foreach ($staticRouters as $method => $items) { + foreach ($items as $handler) { + $this->collectRoute($routes, $method, $handler); + } + } + + // 动态路由(含路径参数如 {id}) + foreach ($variableRouters as $method => $items) { + foreach ($items as $item) { + if (is_array($item['routeMap'] ?? false)) { + foreach ($item['routeMap'] as $routeMap) { + $this->collectRoute($routes, $method, $routeMap[0]); + } + } + } + } + + return array_values($routes); + } + + /** + * 收集单条路由信息,仅同步 /api/ 前缀的路由 + */ + private function collectRoute(array &$routes, string $method, Handler $handler): void + { + $path = $handler->route; + + // 仅同步 API 路由 + if (!str_starts_with($path, '/api/')) { + return; + } + + // 解析 action 名称 + $name = null; + if (is_array($handler->callback)) { + $name = $handler->callback[0] . '::' . $handler->callback[1]; + } elseif (is_string($handler->callback)) { + $name = $handler->callback; + } + + $key = $method . '|' . $path; + if (!isset($routes[$key])) { + $routes[$key] = [ + 'method' => $method, + 'path' => $path, + 'name' => $name, + ]; + } + } +} diff --git a/backend/test/Cases/Integration/System/RouteSyncTest.php b/backend/test/Cases/Integration/System/RouteSyncTest.php new file mode 100644 index 0000000..69927dd --- /dev/null +++ b/backend/test/Cases/Integration/System/RouteSyncTest.php @@ -0,0 +1,120 @@ +runInCoroutine(static function (): User { + return User::query()->create([ + 'username' => 'route_sync_test_nonadmin_' . uniqid(), + 'password' => 'TestPass123', + 'email' => 'route_sync_nonadmin_' . uniqid() . '@example.com', + 'status' => 1, + ]); + }); + } + + return $this->runInCoroutine(function (): array { + $auth = make(AuthManager::class); + $token = $auth->guard('jwt')->login(self::$nonAdminUser); + return ['Authorization' => 'Bearer ' . $token]; + }); + } + + public static function tearDownAfterClass(): void + { + if (self::$nonAdminUser !== null) { + $user_id = self::$nonAdminUser->id; + $cleanup = static function () use ($user_id): void { + User::query()->where('id', $user_id)->delete(); + }; + + if (\Swoole\Coroutine::getCid() > 0) { + $cleanup(); + } else { + \Swoole\Coroutine\run($cleanup); + } + self::$nonAdminUser = null; + } + + parent::tearDownAfterClass(); + } + + // ========== 管理员成功 ========== + + public function test_admin_can_sync_routes(): void + { + $response = $this->post('/api/v1/routes/sync', [], $this->authHeaders()); + + $response->assertOk(); + $body = $response->json(); + + $this->assertSame(0, $body['code']); + $this->assertSame('同步成功', $body['message']); + $this->assertIsArray($body['data']); + $this->assertArrayHasKey('synced', $body['data']); + $this->assertArrayHasKey('total', $body['data']); + $this->assertIsInt($body['data']['synced']); + $this->assertIsInt($body['data']['total']); + $this->assertGreaterThan(0, $body['data']['synced']); + $this->assertSame($body['data']['synced'], $body['data']['total']); + } + + // ========== 非管理员 403 ========== + + public function test_non_admin_receives_403(): void + { + $response = $this->post('/api/v1/routes/sync', [], $this->getNonAdminHeaders()); + + $response->assertStatus(403); + } + + // ========== 未认证 401 ========== + + public function test_unauthenticated_returns_401(): void + { + $response = $this->post('/api/v1/routes/sync'); + + $response->assertStatus(401); + } + + // ========== 重复调用幂等 ========== + + public function test_repeated_sync_is_idempotent(): void + { + $response1 = $this->post('/api/v1/routes/sync', [], $this->authHeaders()); + $response1->assertOk(); + $data1 = $response1->json('data'); + + $response2 = $this->post('/api/v1/routes/sync', [], $this->authHeaders()); + $response2->assertOk(); + $data2 = $response2->json('data'); + + $this->assertSame($data1['synced'], $data2['synced']); + $this->assertSame($data1['total'], $data2['total']); + } +}