diff --git a/backend/app/Controller/Api/V1/ApiKeyController.php b/backend/app/Controller/Api/V1/ApiKeyController.php index 2d5cf6b..fcea614 100644 --- a/backend/app/Controller/Api/V1/ApiKeyController.php +++ b/backend/app/Controller/Api/V1/ApiKeyController.php @@ -7,12 +7,11 @@ namespace App\Controller\Api\V1; use App\Controller\AbstractController; use App\Middleware\AuthMiddleware; use App\Model\ApiKey; -use App\Model\User; use Hyperf\HttpServer\Annotation\Controller; use Hyperf\HttpServer\Annotation\Middleware; use Hyperf\HttpServer\Annotation\RequestMapping; use OpenApi\Attributes as OA; -use Qbhy\HyperfAuth\AuthManager; + #[OA\Tag(name: 'API Keys', description: 'API Key 管理')] #[Controller(prefix: "/api/v1/me/api-keys")] @@ -66,11 +65,11 @@ class ApiKeyController extends AbstractController )] #[RequestMapping(path: "", methods: "POST")] #[Middleware(AuthMiddleware::class)] - public function store(AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function store(): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $this->response->json([ 'code' => 401, 'message' => '未授权', @@ -183,11 +182,11 @@ class ApiKeyController extends AbstractController )] #[RequestMapping(path: "", methods: "GET")] #[Middleware(AuthMiddleware::class)] - public function index(AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function index(): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $this->response->json([ 'code' => 401, 'message' => '未授权', @@ -232,11 +231,11 @@ class ApiKeyController extends AbstractController )] #[RequestMapping(path: "{id}", methods: "DELETE")] #[Middleware(AuthMiddleware::class)] - public function destroy(int $id, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function destroy(int $id): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $this->response->json([ 'code' => 401, 'message' => '未授权', @@ -309,11 +308,11 @@ class ApiKeyController extends AbstractController )] #[RequestMapping(path: "{id}/toggle", methods: "PATCH")] #[Middleware(AuthMiddleware::class)] - public function toggle(int $id, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function toggle(int $id): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $this->response->json([ 'code' => 401, 'message' => '未授权', diff --git a/backend/app/Controller/Api/V1/AuthController.php b/backend/app/Controller/Api/V1/AuthController.php index 088bd27..b31dc57 100644 --- a/backend/app/Controller/Api/V1/AuthController.php +++ b/backend/app/Controller/Api/V1/AuthController.php @@ -408,7 +408,7 @@ class AuthController extends AbstractController )] #[RequestMapping(path: "me", methods: "GET")] #[Middleware(AuthMiddleware::class)] - public function me(AuthManager $auth, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array + public function me(ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array { $user = $this->getAuthUser(); @@ -478,11 +478,11 @@ class AuthController extends AbstractController )] #[RequestMapping(path: "me/profile", methods: "PUT")] #[Middleware(AuthMiddleware::class)] - public function updateProfile(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function updateProfile(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $response->json([ 'code' => 401, 'message' => '未授权', @@ -598,11 +598,11 @@ class AuthController extends AbstractController )] #[RequestMapping(path: "me/password", methods: "PUT")] #[Middleware(AuthMiddleware::class)] - public function changePassword(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + public function changePassword(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if (!$user instanceof User) { + if (!$user) { return $response->json([ 'code' => 401, 'message' => '未授权', @@ -689,9 +689,9 @@ class AuthController extends AbstractController #[Middleware(AuthMiddleware::class)] public function logout(AuthManager $auth): array { - $user = $auth->guard('jwt')->user(); + $user = $this->getAuthUser(); - if ($user instanceof User) { + if ($user) { OperationLogService::log( user_id: $user->id, action: 'auth.logout', diff --git a/backend/app/Controller/Api/V1/RoleController.php b/backend/app/Controller/Api/V1/RoleController.php index 84be5fe..83d790a 100644 --- a/backend/app/Controller/Api/V1/RoleController.php +++ b/backend/app/Controller/Api/V1/RoleController.php @@ -21,7 +21,7 @@ use Hyperf\HttpServer\Annotation\Middleware; use Hyperf\HttpServer\Annotation\RequestMapping; use OpenApi\Attributes as OA; use Psr\Http\Message\ResponseInterface; -use Qbhy\HyperfAuth\AuthManager; + #[OA\Tag(name: 'Roles', description: '角色与授权管理')] #[Controller(prefix: "/api/v1/roles")] @@ -125,7 +125,7 @@ class RoleController extends AbstractController #[RequestMapping(path: "/api/v1/users/{id}/role", methods: "PUT")] #[Middleware(AuthMiddleware::class)] #[Middleware(PermissionMiddleware::class)] - public function assignRole(int $id, AuthManager $auth): ResponseInterface|array + public function assignRole(int $id): ResponseInterface|array { $target_user = User::query()->with('role')->find($id); @@ -156,8 +156,8 @@ class RoleController extends AbstractController } // 防止 administrator 降级自己 - $current_user = $auth->guard('jwt')->user(); - if ($current_user instanceof User + $current_user = $this->getAuthUser(); + if ($current_user && $current_user->id === $id && $target_user->isAdministrator() && $new_role->name !== 'administrator' diff --git a/backend/app/Service/OperationLogService.php b/backend/app/Service/OperationLogService.php index 160b1eb..ef06bde 100644 --- a/backend/app/Service/OperationLogService.php +++ b/backend/app/Service/OperationLogService.php @@ -48,10 +48,22 @@ class OperationLogService /** * 从当前认证上下文获取用户 ID + * + * 优先从 request attribute 获取(兼容 API Key 认证),再 fallback 到 JWT guard */ public static function getCurrentUserId(): ?int { try { + // 优先从 request context 获取(API Key 认证存储在 auth_user attribute) + $request = \Hyperf\Context\Context::get(\Psr\Http\Message\ServerRequestInterface::class); + if ($request) { + $user = $request->getAttribute('auth_user'); + if ($user instanceof User) { + return $user->id; + } + } + + // Fallback: JWT guard $container = \Hyperf\Context\ApplicationContext::getContainer(); $auth = $container->get(AuthManager::class); $user = $auth->guard('jwt')->user(); diff --git a/backend/migrations/2026_04_14_120000_make_platform_product_id_nullable.php b/backend/migrations/2026_04_14_120000_make_platform_product_id_nullable.php index 423c6b0..5e34958 100644 --- a/backend/migrations/2026_04_14_120000_make_platform_product_id_nullable.php +++ b/backend/migrations/2026_04_14_120000_make_platform_product_id_nullable.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Hyperf\Database\Schema\Schema; -use Hyperf\Database\Schema\Blueprint; use Hyperf\Database\Migrations\Migration; return new class extends Migration @@ -13,13 +12,15 @@ return new class extends Migration */ public function up(): void { - Schema::table('skus_mapping', function (Blueprint $table) { - $table->text('platform_product_id')->nullable()->change(); - }); - - // 删除原有唯一约束(如果存在)并创建部分唯一索引 $connection = Schema::getConnection(); + + // 将 platform_product_id 改为可空 + $connection->statement('ALTER TABLE skus_mapping ALTER COLUMN platform_product_id DROP NOT NULL'); + + // 删除原有唯一约束(CONSTRAINT + INDEX 二选一,兼容两种形式) + $connection->statement('ALTER TABLE skus_mapping DROP CONSTRAINT IF EXISTS uk_platform_product'); $connection->statement('DROP INDEX IF EXISTS uk_platform_product'); + // 创建部分唯一索引(NULL 值不参与唯一性判断) $connection->statement( 'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id) WHERE platform_product_id IS NOT NULL' ); @@ -31,13 +32,13 @@ return new class extends Migration public function down(): void { $connection = Schema::getConnection(); + $connection->statement('DROP INDEX IF EXISTS uk_platform_product'); $connection->statement( 'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id)' ); - Schema::table('skus_mapping', function (Blueprint $table) { - $table->text('platform_product_id')->nullable(false)->change(); - }); + // 恢复 NOT NULL(需确保无 NULL 数据) + $connection->statement('ALTER TABLE skus_mapping ALTER COLUMN platform_product_id SET NOT NULL'); } }; diff --git a/backend/test/Cases/Integration/Auth/ApiKeyAuthEndToEndTest.php b/backend/test/Cases/Integration/Auth/ApiKeyAuthEndToEndTest.php new file mode 100644 index 0000000..1ba1f36 --- /dev/null +++ b/backend/test/Cases/Integration/Auth/ApiKeyAuthEndToEndTest.php @@ -0,0 +1,117 @@ +fetchAdminRole(); + $user = $this->fetchUser(static function ($query) use ($admin_role): void { + $query->where('status', 1)->where('role_id', $admin_role->id); + }); + $this->runInCoroutine(static function () use ($user): void { + if (!$user->api_key_enabled) { + $user->api_key_enabled = true; + $user->save(); + } + // 清理旧测试 keys,确保不超过上限 + ApiKey::query()->where('user_id', $user->id) + ->where('name', 'like', 'E2E Test Key%') + ->delete(); + }); + + $token = $this->getAdminToken(); + + $response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'E2E Test Key ' . bin2hex(random_bytes(4)), + ], [ + 'Authorization' => 'Bearer ' . $token, + ]); + + $response->assertStatus(200); + + return $response->json('data.plain_key'); + } + + protected function apiKeyHeaders(): array + { + return ['X-API-Key' => $this->getAdminApiKey()]; + } + + // ========== 业务端点 API Key 认证 ========== + + public function test_apikey_auth_products_endpoint(): void + { + $response = $this->get('/api/v1/products', [], $this->apiKeyHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_apikey_auth_orders_endpoint(): void + { + $response = $this->get('/api/v1/orders', [], $this->apiKeyHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_apikey_auth_refunds_endpoint(): void + { + $response = $this->get('/api/v1/refunds', [], $this->apiKeyHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_apikey_auth_list_own_keys(): void + { + $response = $this->get('/api/v1/me/api-keys', [], $this->apiKeyHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + // ========== 认证失败场景 ========== + + public function test_invalid_apikey_returns_401(): void + { + $response = $this->get('/api/v1/products', [], [ + 'X-API-Key' => 'dh_invalid_key_that_does_not_exist_00000000', + ]); + + $response->assertStatus(401); + } + + // ========== JWT 认证仍正常 ========== + + public function test_jwt_auth_still_works(): void + { + $response = $this->get('/api/v1/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } +}