update route service
This commit is contained in:
@@ -4,12 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Model\Route;
|
use App\Service\RouteSyncService;
|
||||||
use Hyperf\Command\Annotation\Command;
|
use Hyperf\Command\Annotation\Command;
|
||||||
use Hyperf\Command\Command as HyperfCommand;
|
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;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
#[Command]
|
#[Command]
|
||||||
@@ -28,80 +25,7 @@ class RouteSyncCommand extends HyperfCommand
|
|||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
$factory = $this->container->get(DispatcherFactory::class);
|
$result = $this->container->get(RouteSyncService::class)->sync();
|
||||||
$router = $factory->getRouter('http');
|
$this->info("Synced {$result['synced']} routes to database.");
|
||||||
$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<int, array{method: string, path: string, name: string|null}>
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Middleware\AuthMiddleware;
|
|||||||
use App\Middleware\PermissionMiddleware;
|
use App\Middleware\PermissionMiddleware;
|
||||||
use App\Model\Route;
|
use App\Model\Route;
|
||||||
use App\Model\RouteGroup;
|
use App\Model\RouteGroup;
|
||||||
|
use App\Service\RouteSyncService;
|
||||||
use Hyperf\DbConnection\Db;
|
use Hyperf\DbConnection\Db;
|
||||||
use Hyperf\HttpServer\Annotation\Controller;
|
use Hyperf\HttpServer\Annotation\Controller;
|
||||||
use Hyperf\HttpServer\Annotation\Middleware;
|
use Hyperf\HttpServer\Annotation\Middleware;
|
||||||
@@ -641,4 +642,46 @@ class RouteGroupController extends AbstractController
|
|||||||
'data' => $group,
|
'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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Model\Route;
|
||||||
|
use Hyperf\HttpServer\Router\DispatcherFactory;
|
||||||
|
use Hyperf\HttpServer\Router\Handler;
|
||||||
|
use Hyperf\HttpServer\Router\RouteCollector;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
class RouteSyncService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly ContainerInterface $container,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步 Hyperf 注解路由到 routes 表
|
||||||
|
*
|
||||||
|
* @return array{synced: int, total: int}
|
||||||
|
*/
|
||||||
|
public function sync(): array
|
||||||
|
{
|
||||||
|
$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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'synced' => $synced,
|
||||||
|
'total' => count($routes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从路由收集器中提取所有 API 路由
|
||||||
|
*
|
||||||
|
* @return array<int, array{method: string, path: string, name: string|null}>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Integration\System;
|
||||||
|
|
||||||
|
use App\Model\User;
|
||||||
|
use HyperfTest\TestCase;
|
||||||
|
use HyperfTest\Traits\AuthenticatedTestTrait;
|
||||||
|
use Qbhy\HyperfAuth\AuthManager;
|
||||||
|
|
||||||
|
use function Hyperf\Support\make;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/routes/sync 集成测试
|
||||||
|
*
|
||||||
|
* 覆盖管理员成功、非管理员 403、重复调用幂等
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class RouteSyncTest extends TestCase
|
||||||
|
{
|
||||||
|
use AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
private static ?User $nonAdminUser = null;
|
||||||
|
|
||||||
|
protected function getNonAdminHeaders(): array
|
||||||
|
{
|
||||||
|
if (self::$nonAdminUser === null) {
|
||||||
|
self::$nonAdminUser = $this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user