diff --git a/backend/app/Command/AppInstall.php b/backend/app/Command/AppInstall.php index a9f2230..9a40ff4 100644 --- a/backend/app/Command/AppInstall.php +++ b/backend/app/Command/AppInstall.php @@ -54,6 +54,9 @@ class AppInstall extends HyperfCommand $this->line('Fixed: username corrected to "administrator".', 'info'); } + $this->call('route:sync'); + $this->call('route-group:seed'); + return 0; } @@ -81,8 +84,11 @@ class AppInstall extends HyperfCommand $this->line(''); $this->warn('Please change the default password after first login!'); + $this->call('route:sync'); + $this->call('route-group:seed'); + return 0; - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->error('Failed to create admin user: ' . $e->getMessage()); return 1; } diff --git a/backend/app/Command/RouteGroupSeedCommand.php b/backend/app/Command/RouteGroupSeedCommand.php new file mode 100644 index 0000000..af8ab21 --- /dev/null +++ b/backend/app/Command/RouteGroupSeedCommand.php @@ -0,0 +1,71 @@ +setDescription('创建默认路由组并按前缀自动分配路由'); + } + + public function handle(): void + { + $default_groups = [ + ['name' => 'store-management', 'label' => '店铺管理', 'sort_order' => 1, 'patterns' => ['/api/v1/stores']], + ['name' => 'company-management', 'label' => '公司管理', 'sort_order' => 2, 'patterns' => ['/api/v1/companies']], + ['name' => 'product-management', 'label' => '产品管理', 'sort_order' => 3, 'patterns' => ['/api/v1/products']], + ['name' => 'order-management', 'label' => '订单管理', 'sort_order' => 4, 'patterns' => ['/api/v1/orders', '/api/v1/order-items']], + ['name' => 'refund-management', 'label' => '退款管理', 'sort_order' => 5, 'patterns' => ['/api/v1/refunds', '/api/v1/refund-items']], + ['name' => 'raw-data', 'label' => '原始数据', 'sort_order' => 6, 'patterns' => ['/api/v1/raw/']], + ['name' => 'log-management', 'label' => '日志管理', 'sort_order' => 7, 'patterns' => ['/api/v1/logs/']], + ['name' => 'user-permission', 'label' => '用户与权限', 'sort_order' => 8, 'patterns' => ['/api/v1/users', '/api/v1/roles', '/api/v1/route-groups', '/api/v1/routes']], + ['name' => 'platform-management', 'label' => '平台管理', 'sort_order' => 9, 'patterns' => ['/api/v1/platforms']], + ['name' => 'system', 'label' => '系统功能', 'sort_order' => 10, 'patterns' => ['/api/v1/me/', '/api/v1/dashboard', '/api/v1/mq', '/api/v1/failed-messages', '/api/v1/auth/']], + ]; + + $group_count = 0; + $route_count = 0; + + foreach ($default_groups as $config) { + $patterns = $config['patterns']; + unset($config['patterns']); + + $group = RouteGroup::query()->updateOrCreate( + ['name' => $config['name']], + ['label' => $config['label'], 'sort_order' => $config['sort_order']] + ); + $group_count++; + + // 按前缀匹配路由并分配到组 + foreach ($patterns as $pattern) { + $affected = Route::query() + ->where('path', 'like', $pattern . '%') + ->update(['group_id' => $group->id]); + $route_count += $affected; + } + } + + $this->info("Seeded {$group_count} route groups, assigned {$route_count} routes."); + + $unassigned = Route::query()->whereNull('group_id')->count(); + if ($unassigned > 0) { + $this->warn("Warning: {$unassigned} routes remain unassigned."); + } + } +} diff --git a/backend/app/Controller/api/v1/RouteGroupController.php b/backend/app/Controller/api/v1/RouteGroupController.php index 449e010..cf8232c 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 Hyperf\DbConnection\Db; use Hyperf\HttpServer\Annotation\Controller; use Hyperf\HttpServer\Annotation\Middleware; use Hyperf\HttpServer\Annotation\RequestMapping; @@ -552,4 +553,92 @@ class RouteGroupController extends AbstractController 'data' => $route, ]; } + + /** + * 批量同步路由到路由组 + * + * 替换式同步:先清空旧归属,再设置新归属,事务保证原子性 + * + * @param int $id 路由组 ID + */ + #[OA\Put( + path: '/route-groups/{id}/routes', + summary: '批量同步路由到路由组', + description: '替换式同步:提交 route_ids 数组,组内路由将被完全替换为指定路由。提交空数组清空组内所有路由。若指定路由已属于其他组,将自动从原组移出。', + security: [['bearerAuth' => []]], + tags: ['Route Groups'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '路由组 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['route_ids'], + properties: [ + new OA\Property(property: 'route_ids', type: 'array', items: new OA\Items(type: 'integer'), description: '路由 ID 数组,空数组表示清空', example: [1, 2, 3]), + ] + ) + ), + 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: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + new OA\Property(property: 'routes_count', type: 'integer', example: 5), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: '路由组不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}/routes", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function syncRoutes(int $id): ResponseInterface|array + { + $group = RouteGroup::query()->find($id); + + if (!$group) { + return $this->response->json([ + 'code' => 404, + 'message' => '路由组不存在', + ])->withStatus(404); + } + + $body = $this->request->getParsedBody(); + if (!array_key_exists('route_ids', $body) || !is_array($body['route_ids'])) { + return $this->response->json([ + 'code' => 422, + 'message' => 'route_ids 必须为数组', + ])->withStatus(422); + } + + $route_ids = array_values(array_map('intval', array_filter($body['route_ids'], 'is_numeric'))); + + Db::transaction(function () use ($id, $route_ids): void { + // 清空旧归属 + Route::query()->where('group_id', $id)->update(['group_id' => null]); + + // 设置新归属 + if ($route_ids !== []) { + Route::query()->whereIn('id', $route_ids)->update(['group_id' => $id]); + } + }); + + $group->loadCount('routes'); + + return [ + 'code' => 0, + 'message' => '同步成功', + 'data' => $group, + ]; + } } diff --git a/backend/test/Cases/Integration/System/RouteGroupSyncTest.php b/backend/test/Cases/Integration/System/RouteGroupSyncTest.php new file mode 100644 index 0000000..6ae3b51 --- /dev/null +++ b/backend/test/Cases/Integration/System/RouteGroupSyncTest.php @@ -0,0 +1,257 @@ +runInCoroutine(static function (): array { + $groupA = RouteGroup::query()->create([ + 'name' => 'sync-test-group-a', + 'label' => '同步测试组A', + 'sort_order' => 99, + ]); + + $groupB = RouteGroup::query()->create([ + 'name' => 'sync-test-group-b', + 'label' => '同步测试组B', + 'sort_order' => 99, + ]); + + $ids = []; + for ($i = 1; $i <= 3; $i++) { + $route = Route::query()->create([ + 'method' => 'GET', + 'path' => "/api/v1/sync-test/route-{$i}", + 'name' => "sync-test-route-{$i}", + ]); + $ids[] = $route->id; + } + + return [$groupA->id, $groupB->id, $ids]; + }); + + self::$groupAId = $groupAId; + self::$groupBId = $groupBId; + self::$routeIds = $routeIds; + } + + public static function tearDownAfterClass(): void + { + $groupAId = self::$groupAId; + $groupBId = self::$groupBId; + $routeIds = self::$routeIds; + + if ($groupAId !== null) { + $cleanup = static function () use ($groupAId, $groupBId, $routeIds): void { + Route::query()->whereIn('id', $routeIds)->delete(); + RouteGroup::query()->whereIn('id', [$groupAId, $groupBId])->delete(); + }; + + if (\Swoole\Coroutine::getCid() > 0) { + $cleanup(); + } else { + \Swoole\Coroutine\run($cleanup); + } + + self::$groupAId = null; + self::$groupBId = null; + self::$routeIds = []; + } + + parent::tearDownAfterClass(); + } + + // ========== 正常同步 ========== + + public function test_sync_routes_assigns_routes_to_group(): void + { + $this->ensureTestData(); + + $response = $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + ['route_ids' => self::$routeIds], + $this->authHeaders() + ); + + $response->assertOk(); + $body = $response->json(); + + $this->assertSame(0, $body['code']); + $this->assertSame('同步成功', $body['message']); + $this->assertSame(3, $body['data']['routes_count']); + + // 验证数据库 + $this->runInCoroutine(function (): void { + foreach (self::$routeIds as $route_id) { + $route = Route::query()->find($route_id); + $this->assertSame(self::$groupAId, $route->group_id); + } + }); + } + + // ========== 空数组清空 ========== + + public function test_sync_empty_array_clears_all_routes(): void + { + $this->ensureTestData(); + + // 先分配路由到组A + $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + ['route_ids' => self::$routeIds], + $this->authHeaders() + ); + + // 用空数组清空 + $response = $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + ['route_ids' => []], + $this->authHeaders() + ); + + $response->assertOk(); + $body = $response->json(); + + $this->assertSame(0, $body['code']); + $this->assertSame(0, $body['data']['routes_count']); + + // 验证数据库 + $this->runInCoroutine(function (): void { + foreach (self::$routeIds as $route_id) { + $route = Route::query()->find($route_id); + $this->assertNull($route->group_id); + } + }); + } + + // ========== 跨组移动 ========== + + public function test_sync_moves_routes_from_another_group(): void + { + $this->ensureTestData(); + + // 先分配路由到组A + $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + ['route_ids' => self::$routeIds], + $this->authHeaders() + ); + + // 将其中两条路由同步到组B + $moved_ids = [self::$routeIds[0], self::$routeIds[1]]; + $response = $this->put( + "/api/v1/route-groups/" . self::$groupBId . "/routes", + ['route_ids' => $moved_ids], + $this->authHeaders() + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame(2, $body['data']['routes_count']); + + // 验证:移动的路由属于组B,第三条仍属于组A + $this->runInCoroutine(function () use ($moved_ids): void { + foreach ($moved_ids as $route_id) { + $route = Route::query()->find($route_id); + $this->assertSame(self::$groupBId, $route->group_id); + } + $remaining = Route::query()->find(self::$routeIds[2]); + $this->assertSame(self::$groupAId, $remaining->group_id); + }); + } + + // ========== 无效 route_id ========== + + public function test_sync_with_invalid_route_ids_succeeds_silently(): void + { + $this->ensureTestData(); + + $response = $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + ['route_ids' => [999999, 999998]], + $this->authHeaders() + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame(0, $body['code']); + $this->assertSame(0, $body['data']['routes_count']); + } + + // ========== 404 路由组 ========== + + public function test_sync_nonexistent_group_returns_404(): void + { + $response = $this->put( + '/api/v1/route-groups/999999/routes', + ['route_ids' => [1]], + $this->authHeaders() + ); + + $response->assertStatus(404); + $body = $response->json(); + $this->assertSame(404, $body['code']); + } + + // ========== 参数校验 ========== + + public function test_sync_without_route_ids_returns_422(): void + { + $this->ensureTestData(); + + $response = $this->put( + "/api/v1/route-groups/" . self::$groupAId . "/routes", + [], + $this->authHeaders() + ); + + $response->assertStatus(422); + $body = $response->json(); + $this->assertSame(422, $body['code']); + } + + // ========== 未认证 ========== + + public function test_sync_unauthenticated_returns_401(): void + { + $response = $this->put( + '/api/v1/route-groups/1/routes', + ['route_ids' => [1]], + ); + + $response->assertStatus(401); + } +}