From aa1fd76a99bd9b2dadcde4160ec5c04f5f7b20ab Mon Sep 17 00:00:00 2001 From: kent Date: Sun, 21 Dec 2025 01:35:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Flow=20Tester=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowTesterController: 테스트 실행 로직 개선 - 에러 핸들링 강화 - 응답 형식 표준화 - FlowExecutor: API 호출 실행기 개선 - 다단계 플로우 지원 강화 - 변수 바인딩 및 검증 개선 - index.blade.php: UI 개선 - 테스트 결과 표시 개선 - 사용성 향상 - routes/web.php: 라우트 정리 - composer.lock: 의존성 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../DevTools/FlowTesterController.php | 150 +++++++++----- app/Services/FlowTester/FlowExecutor.php | 192 ++++++++++++------ composer.lock | 48 ++--- .../dev-tools/flow-tester/index.blade.php | 84 +++----- routes/web.php | 5 +- 5 files changed, 288 insertions(+), 191 deletions(-) diff --git a/app/Http/Controllers/DevTools/FlowTesterController.php b/app/Http/Controllers/DevTools/FlowTesterController.php index 5367bdce..0f5ed537 100644 --- a/app/Http/Controllers/DevTools/FlowTesterController.php +++ b/app/Http/Controllers/DevTools/FlowTesterController.php @@ -342,73 +342,115 @@ public function runDetail(int $runId): View */ /** - * 현재 테넌트의 사용자 목록 + * API 서버에 직접 로그인하여 토큰 발급 + * + * MNG에서 토큰을 발급하면 API 서버에서 인식하지 못하므로, + * API 서버에 직접 로그인하여 토큰을 발급받습니다. */ - public function users() + public function loginToApi(Request $request) { - // 현재 선택된 테넌트 ID (세션 기반) - $tenantId = session('selected_tenant_id'); + $validated = $request->validate([ + 'user_id' => 'required|string', + 'user_pwd' => 'required|string', + ]); - if (! $tenantId) { - // 세션에 없으면 기본 테넌트 사용 - $currentTenant = auth()->user()->currentTenant(); - $tenantId = $currentTenant?->id; + try { + // API Base URL 결정 + $baseUrl = $this->getApiBaseUrl(); + + // Docker 환경에서는 내부 URL로 변환 + $requestUrl = $baseUrl.'/login'; + $headers = ['Accept' => 'application/json', 'Content-Type' => 'application/json']; + + if ($this->isDockerEnvironment()) { + $parsedUrl = parse_url($baseUrl); + $host = $parsedUrl['host'] ?? ''; + + // *.sam.kr 도메인을 nginx 컨테이너로 라우팅 + if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') { + $headers['Host'] = $host; + $requestUrl = preg_replace('#https?://[^/]+#', 'https://nginx', $baseUrl).'/login'; + } + } + + // API 서버에 로그인 요청 + $response = \Illuminate\Support\Facades\Http::withHeaders($headers) + ->withoutVerifying() // Docker 내부 SSL 인증서 무시 + ->timeout(10) + ->post($requestUrl, [ + 'user_id' => $validated['user_id'], + 'user_pwd' => $validated['user_pwd'], + ]); + + if (! $response->successful()) { + $body = $response->json(); + + return response()->json([ + 'success' => false, + 'message' => $body['message'] ?? '로그인 실패: '.$response->status(), + ], 401); + } + + $body = $response->json(); + $token = $body['access_token'] ?? $body['data']['token'] ?? null; + + if (! $token) { + return response()->json([ + 'success' => false, + 'message' => '토큰을 받지 못했습니다. 응답: '.json_encode($body, JSON_UNESCAPED_UNICODE), + ], 500); + } + + // 세션에 저장 (API Explorer와 공유) + session([ + 'api_explorer_token' => $token, + 'api_explorer_user_id' => $body['user']['id'] ?? null, + 'api_explorer_user_name' => $body['user']['name'] ?? $validated['user_id'], + ]); + + return response()->json([ + 'success' => true, + 'message' => 'API 서버 로그인 성공!', + 'user' => $body['user'] ?? ['name' => $validated['user_id']], + 'token_preview' => substr($token, 0, 20).'...', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'API 서버 연결 실패: '.$e->getMessage(), + ], 500); } - - if (! $tenantId) { - return response()->json([]); - } - - // user_tenants 피벗 테이블을 통해 해당 테넌트의 사용자 조회 - $users = \App\Models\User::whereHas('tenants', function ($query) use ($tenantId) { - $query->where('tenants.id', $tenantId) - ->where('user_tenants.is_active', true); - }) - ->select(['id', 'name', 'email']) - ->orderBy('name') - ->limit(100) - ->get(); - - return response()->json($users); } /** - * 사용자 선택 (Sanctum 토큰 발급) + * API Base URL 결정 */ - public function selectUser(Request $request) + private function getApiBaseUrl(): string { - $validated = $request->validate([ - 'user_id' => 'required|integer', - ]); - - $user = \App\Models\User::find($validated['user_id']); - - if (! $user) { - return response()->json([ - 'success' => false, - 'message' => '사용자를 찾을 수 없습니다.', - ], 404); + // 환경변수 우선 + $envUrl = env('FLOW_TESTER_API_BASE_URL'); + if ($envUrl) { + return rtrim($envUrl, '/'); } - // Sanctum 토큰 발급 - $token = $user->createToken('flow-tester', ['*'])->plainTextToken; + // config에서 로컬 환경 URL + $environments = config('api-explorer.default_environments', []); + foreach ($environments as $env) { + if ($env['name'] === '로컬') { + return rtrim($env['base_url'], '/'); + } + } - // 세션에 저장 (API Explorer와 공유) - session([ - 'api_explorer_token' => $token, - 'api_explorer_user_id' => $user->id, - ]); + // 기본값 + return 'https://api.sam.kr'; + } - return response()->json([ - 'success' => true, - 'message' => "'{$user->name}' 사용자로 인증되었습니다.", - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - ], - 'token_preview' => substr($token, 0, 20).'...', - ]); + /** + * Docker 환경인지 확인 + */ + private function isDockerEnvironment(): bool + { + return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true'); } /** diff --git a/app/Services/FlowTester/FlowExecutor.php b/app/Services/FlowTester/FlowExecutor.php index 1ef9106b..e8e7418d 100644 --- a/app/Services/FlowTester/FlowExecutor.php +++ b/app/Services/FlowTester/FlowExecutor.php @@ -311,7 +311,8 @@ private function executeStep(array $step): array * 세션 인증 스텝 실행 * * useSessionAuth: true 옵션이 있는 login 스텝에서 사용 - * 실제 API 호출 없이 세션에 저장된 토큰을 바인딩합니다. + * - 세션 토큰이 있으면 → 세션 토큰 사용 (API 호출 스킵) + * - 세션 토큰이 없으면 → .env 크레덴셜로 실제 API 로그인 * * @param array $step 스텝 정의 * @param string $stepId 스텝 ID @@ -324,55 +325,142 @@ private function executeSessionAuthStep(array $step, string $stepId, string $ste // 세션 인증 정보 조회 $sessionAuth = $this->binder->getSessionAuth(); - // 세션 토큰이 없으면 실패 - if (empty($sessionAuth['token'])) { - return $this->buildStepResult($stepId, $stepName, $startTime, false, [ - 'error' => '세션 인증 정보가 없습니다. 페이지에서 먼저 인증을 완료해주세요.', - 'reason' => '✗ 세션 토큰 없음 - 페이지 인증 필요', + // 세션 토큰이 있으면 사용 (API 호출 스킵) + if (! empty($sessionAuth['token'])) { + $extracted = [ + 'token' => $sessionAuth['token'], + 'access_token' => $sessionAuth['token'], + ]; + + if ($sessionAuth['user']) { + $extracted['user_id'] = $sessionAuth['user']['id']; + $extracted['user_name'] = $sessionAuth['user']['name']; + $extracted['user_email'] = $sessionAuth['user']['email']; + } + if ($sessionAuth['tenant_id']) { + $extracted['tenant_id'] = $sessionAuth['tenant_id']; + } + + $this->binder->setStepResult($stepId, $extracted, [ + 'message' => '세션 인증 사용', + 'user' => $sessionAuth['user'], + 'tenant_id' => $sessionAuth['tenant_id'], + ]); + + return $this->buildStepResult($stepId, $stepName, $startTime, true, [ + 'description' => $step['description'] ?? '세션 인증 정보 사용', + 'reason' => '✓ 세션 인증 사용 (API 호출 생략)', 'useSessionAuth' => true, + 'sessionUser' => $sessionAuth['user'], + 'extracted' => $extracted, + 'response' => [ + 'status' => 200, + 'body' => [ + 'message' => '세션 인증 정보를 사용합니다.', + 'access_token' => substr($sessionAuth['token'], 0, 20).'...', + 'user' => $sessionAuth['user'], + 'tenant_id' => $sessionAuth['tenant_id'], + ], + 'duration' => 0, + ], ]); } - // 세션 토큰을 스텝 결과로 바인딩 ({{login.token}} 등에서 사용 가능) - $extracted = [ - 'token' => $sessionAuth['token'], - 'access_token' => $sessionAuth['token'], // 호환성을 위해 추가 - ]; + // 세션 토큰이 없으면 .env로 폴백하여 실제 API 로그인 + // useSessionAuth 옵션을 제거하고 일반 스텝처럼 실행 + $fallbackStep = $step; + unset($fallbackStep['useSessionAuth']); + $fallbackStep['description'] = '.env 크레덴셜로 API 로그인 (세션 토큰 없음)'; - // 사용자 정보도 바인딩 - if ($sessionAuth['user']) { - $extracted['user_id'] = $sessionAuth['user']['id']; - $extracted['user_name'] = $sessionAuth['user']['name']; - $extracted['user_email'] = $sessionAuth['user']['email']; - } - if ($sessionAuth['tenant_id']) { - $extracted['tenant_id'] = $sessionAuth['tenant_id']; - } + return $this->executeLoginStep($fallbackStep, $stepId, $stepName, $startTime); + } - // 스텝 결과 저장 (다음 스텝에서 {{login.token}} 등으로 참조 가능) - $this->binder->setStepResult($stepId, $extracted, [ - 'message' => '세션 인증 사용', - 'user' => $sessionAuth['user'], - 'tenant_id' => $sessionAuth['tenant_id'], - ]); + /** + * 일반 로그인 스텝 실행 (실제 API 호출) + * + * @param array $step 스텝 정의 + * @param string $stepId 스텝 ID + * @param string $stepName 스텝 이름 + * @param float $startTime 시작 시간 + * @return array 스텝 결과 + */ + private function executeLoginStep(array $step, string $stepId, string $stepName, float $startTime): array + { + try { + // 딜레이 적용 + $delay = $step['delay'] ?? 0; + if ($delay > 0) { + usleep($delay * 1000); + } - return $this->buildStepResult($stepId, $stepName, $startTime, true, [ - 'description' => $step['description'] ?? '세션 인증 정보 사용', - 'reason' => '✓ 세션 인증 사용 (API 호출 생략)', - 'useSessionAuth' => true, - 'sessionUser' => $sessionAuth['user'], - 'extracted' => $extracted, - 'response' => [ - 'status' => 200, - 'body' => [ - 'message' => '세션 인증 정보를 사용합니다.', - 'access_token' => substr($sessionAuth['token'], 0, 20).'...', - 'user' => $sessionAuth['user'], - 'tenant_id' => $sessionAuth['tenant_id'], + // 변수 바인딩 + $endpoint = $this->binder->bind($step['endpoint']); + $headers = $this->binder->bind($step['headers'] ?? []); + $body = $this->binder->bind($step['body'] ?? []); + $query = $this->binder->bind($step['query'] ?? []); + + // HTTP 요청 실행 + $method = strtoupper($step['method']); + $response = $this->httpClient->request($method, $endpoint, [ + 'headers' => $headers, + 'body' => $body, + 'query' => $query, + ]); + + // HTTP 에러 체크 + if ($response['error']) { + return $this->buildStepResult($stepId, $stepName, $startTime, false, [ + 'error' => $response['error'], + 'reason' => '✗ .env 폴백 로그인 실패: '.$response['error'], + 'request' => [ + 'method' => $method, + 'endpoint' => $endpoint, + 'headers' => $headers, + 'body' => $body, + ], + ]); + } + + // 응답 검증 + $expect = $step['expect'] ?? ['status' => [200, 201]]; + $validation = $this->validator->validate($response, $expect); + + // 변수 추출 + $extracted = []; + if (isset($step['extract'])) { + $extracted = $this->validator->extractValues($response['body'], $step['extract']); + $this->binder->setStepResult($stepId, $extracted, $response['body']); + } + + $success = $validation['success']; + + return $this->buildStepResult($stepId, $stepName, $startTime, $success, [ + 'description' => $step['description'] ?? '.env 크레덴셜로 API 로그인', + 'reason' => $success + ? '✓ .env 폴백 로그인 성공' + : '✗ .env 폴백 로그인 실패: '.implode('; ', $validation['errors']), + 'fallbackLogin' => true, + 'request' => [ + 'method' => $method, + 'endpoint' => $endpoint, + 'headers' => $headers, + 'body' => $body, ], - 'duration' => 0, - ], - ]); + 'response' => [ + 'status' => $response['status'], + 'body' => $response['body'], + 'duration' => $response['duration'], + ], + 'extracted' => $extracted, + 'validation' => $validation, + 'error' => $success ? null : implode('; ', $validation['errors']), + ]); + } catch (Exception $e) { + return $this->buildStepResult($stepId, $stepName, $startTime, false, [ + 'error' => $e->getMessage(), + 'reason' => '✗ .env 폴백 로그인 예외: '.$e->getMessage(), + ]); + } } /** @@ -476,24 +564,14 @@ private function applyConfig(array $config): void /** * 기본 Bearer 토큰 조회 - * 우선순위: 세션 토큰 → 사용자 api_token → .env FLOW_TESTER_API_TOKEN + * + * 세션 토큰만 사용 (API 서버 로그인으로 발급받은 토큰) + * .env 폴백은 MNG 토큰이 API 서버에서 인식되지 않으므로 제거됨 */ private function getDefaultBearerToken(): ?string { - // 1. 세션에 저장된 토큰 (API Explorer/Flow Tester 인증 모달에서 저장) - $sessionToken = session('api_explorer_token'); - if (! empty($sessionToken)) { - return $sessionToken; - } - - // 2. 로그인 사용자의 api_token - $user = auth()->user(); - if ($user && ! empty($user->api_token)) { - return $user->api_token; - } - - // 3. 환경변수 기본 토큰 (fallback) - return env('FLOW_TESTER_API_TOKEN'); + // 세션에 저장된 토큰 (API 서버 로그인으로 발급받은 토큰) + return session('api_explorer_token') ?: null; } /** diff --git a/composer.lock b/composer.lock index 13034492..4c2917ee 100644 --- a/composer.lock +++ b/composer.lock @@ -1337,16 +1337,16 @@ }, { "name": "laravel/framework", - "version": "v12.42.0", + "version": "v12.43.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75" + "reference": "195b893593a9298edee177c0844132ebaa02102f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75", + "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", + "reference": "195b893593a9298edee177c0844132ebaa02102f", "shasum": "" }, "require": { @@ -1555,7 +1555,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-09T15:51:23+00:00" + "time": "2025-12-16T18:53:08+00:00" }, { "name": "laravel/prompts", @@ -3457,16 +3457,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.16", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -3530,9 +3530,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-12-07T03:39:01+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -3656,20 +3656,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3728,22 +3728,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "spatie/laravel-permission", - "version": "6.23.0", + "version": "6.24.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-permission.git", - "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb" + "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/9e41247bd512b1e6c229afbc1eb528f7565ae3bb", - "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7", + "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7", "shasum": "" }, "require": { @@ -3805,7 +3805,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-permission/issues", - "source": "https://github.com/spatie/laravel-permission/tree/6.23.0" + "source": "https://github.com/spatie/laravel-permission/tree/6.24.0" }, "funding": [ { @@ -3813,7 +3813,7 @@ "type": "github" } ], - "time": "2025-11-03T20:16:13+00:00" + "time": "2025-12-13T21:45:21+00:00" }, { "name": "swagger-api/swagger-ui", diff --git a/resources/views/dev-tools/flow-tester/index.blade.php b/resources/views/dev-tools/flow-tester/index.blade.php index 7afa9f45..4469c910 100644 --- a/resources/views/dev-tools/flow-tester/index.blade.php +++ b/resources/views/dev-tools/flow-tester/index.blade.php @@ -454,21 +454,22 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition" - +
-
@@ -775,81 +776,58 @@ function confirmDelete(id, name) { // 현재 토큰 상태 let currentAuthToken = @json($savedToken ?? ''); - let usersLoaded = false; function openAuthModal() { document.getElementById('authModal').classList.remove('hidden'); document.getElementById('authBearerToken').value = currentAuthToken || ''; - - // 사용자 목록 로딩 (최초 1회) - if (!usersLoaded) { - loadUsers(); - } } function closeAuthModal() { document.getElementById('authModal').classList.add('hidden'); } - // 사용자 목록 로딩 - function loadUsers() { - const select = document.getElementById('userSelect'); - select.innerHTML = ''; + // API 서버 로그인 + function loginToApi() { + const userId = document.getElementById('authUserId').value.trim(); + const userPwd = document.getElementById('authUserPwd').value; - fetch('{{ route("dev-tools.flow-tester.users") }}', { - headers: { - 'Accept': 'application/json', - }, - }) - .then(response => response.json()) - .then(users => { - select.innerHTML = ''; - users.forEach(user => { - const option = document.createElement('option'); - option.value = user.id; - option.textContent = `${user.name} (${user.email})`; - select.appendChild(option); - }); - usersLoaded = true; - }) - .catch(error => { - select.innerHTML = ''; - console.error('사용자 목록 로딩 실패:', error); - }); - } - - // 사용자 선택 (Sanctum 토큰 발급) - function selectUser() { - const select = document.getElementById('userSelect'); - const userId = select.value; - - if (!userId) { - showToast('사용자를 선택해주세요.', 'warning'); + if (!userId || !userPwd) { + showToast('사용자 ID와 비밀번호를 입력해주세요.', 'warning'); return; } - fetch('{{ route("dev-tools.flow-tester.user.select") }}', { + // 버튼 로딩 상태 + const btn = event.target; + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = '로그인 중...'; + + fetch('{{ route("dev-tools.flow-tester.login-to-api") }}', { method: 'POST', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Content-Type': 'application/json', 'Accept': 'application/json', }, - body: JSON.stringify({ user_id: parseInt(userId) }), + body: JSON.stringify({ user_id: userId, user_pwd: userPwd }), }) .then(response => response.json()) .then(data => { + btn.disabled = false; + btn.textContent = originalText; + if (data.success) { - // 페이지 새로고침하여 상태 반영 showToast(data.message, 'success'); setTimeout(() => { location.reload(); }, 500); } else { - showToast(data.message || '사용자 선택 실패', 'error'); + showToast(data.message || 'API 로그인 실패', 'error'); } }) .catch(error => { + btn.disabled = false; + btn.textContent = originalText; showToast('오류 발생: ' + error.message, 'error'); }); } diff --git a/routes/web.php b/routes/web.php index ecd61eed..4d8b226a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -307,9 +307,8 @@ Route::post('/', [FlowTesterController::class, 'store'])->name('store'); Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json'); - // 토큰 및 사용자 관리 라우트 (API Explorer와 공유) - Route::get('/users', [FlowTesterController::class, 'users'])->name('users'); - Route::post('/user/select', [FlowTesterController::class, 'selectUser'])->name('user.select'); + // 토큰 및 인증 관리 라우트 (API Explorer와 공유) + Route::post('/login-to-api', [FlowTesterController::class, 'loginToApi'])->name('login-to-api'); Route::post('/token/save', [FlowTesterController::class, 'saveToken'])->name('token.save'); Route::post('/token/clear', [FlowTesterController::class, 'clearToken'])->name('token.clear'); Route::get('/token/status', [FlowTesterController::class, 'tokenStatus'])->name('token.status');