context = []; } /** * 전역 변수 설정 * 환경변수 참조 ({{$env.XXX}})도 이 시점에 치환 */ public function setVariables(array $variables): void { // 환경변수 참조를 먼저 치환 $resolvedVariables = []; foreach ($variables as $key => $value) { if (is_string($value)) { $resolvedVariables[$key] = $this->resolveBuiltins($value); } else { $resolvedVariables[$key] = $value; } } $this->context['variables'] = $resolvedVariables; } /** * 컨텍스트에 변수 추가 */ public function setVariable(string $key, mixed $value): void { data_set($this->context, $key, $value); } /** * 단계 결과 저장 */ public function setStepResult(string $stepId, array $extracted, array $fullResponse): void { $this->context['steps'][$stepId] = [ 'extracted' => $extracted, 'response' => $fullResponse, ]; // 편의를 위해 extracted 값을 stepId.key 형태로도 접근 가능하게 foreach ($extracted as $key => $value) { $this->context['steps'][$stepId][$key] = $value; } } /** * 문자열/배열 내 모든 변수 치환 */ public function bind(mixed $input): mixed { if (is_string($input)) { return $this->bindString($input); } if (is_array($input)) { return array_map(fn ($v) => $this->bind($v), $input); } return $input; } /** * 문자열 내 변수 패턴 치환 */ private function bindString(string $input): string { // 내장 변수 처리 $input = $this->resolveBuiltins($input); // 컨텍스트 변수 처리 return preg_replace_callback( '/\{\{([^}]+)\}\}/', fn ($matches) => $this->resolveReference($matches[1]), $input ); } /** * 내장 변수 처리 ($timestamp, $uuid, $random:N, $env.XXX, $auth.token) */ private function resolveBuiltins(string $input): string { // {{$timestamp}} → 현재 타임스탬프 $input = str_replace('{{$timestamp}}', (string) time(), $input); // {{$uuid}} → 랜덤 UUID $input = str_replace('{{$uuid}}', (string) Str::uuid(), $input); // {{$random:N}} → N자리 랜덤 숫자 $input = preg_replace_callback( '/\{\{\$random:(\d+)\}\}/', function ($m) { $digits = (int) $m[1]; $max = (int) pow(10, $digits) - 1; return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT); }, $input ); // {{$date}} → 현재 날짜 (Y-m-d) $input = str_replace('{{$date}}', date('Y-m-d'), $input); // {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s) $input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input); // {{$env.VAR_NAME}} → 환경변수에서 읽기 $input = preg_replace_callback( '/\{\{\$env\.([A-Z_][A-Z0-9_]*)\}\}/i', fn ($m) => env($m[1], ''), $input ); // {{$auth.token}} → 현재 로그인 사용자의 API 토큰 if (str_contains($input, '{{$auth.token}}')) { $token = $this->getAuthToken(); $input = str_replace('{{$auth.token}}', $token, $input); } // {{$auth.apiKey}} → .env의 API Key $input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input); // {{$session.xxx}} → 세션에 저장된 인증 정보 (API Explorer와 공유) $input = $this->resolveSessionVariables($input); // {{$hmac.xxx}} → HMAC 인증용 동적 값 생성 $input = $this->resolveHmac($input); // {{$faker.xxx}} → Faker 기반 랜덤 데이터 생성 $input = $this->resolveFaker($input); return $input; } /** * Faker 인스턴스 가져오기 (지연 초기화) */ private function getFaker(): FakerGenerator { if ($this->faker === null) { $this->faker = FakerFactory::create('ko_KR'); } return $this->faker; } /** * Faker 변수 처리 * * 지원 패턴: * - {{$faker.name}} - 랜덤 이름 * - {{$faker.company}} - 랜덤 회사명 * - {{$faker.email}} - 랜덤 이메일 * - {{$faker.phone}} - 랜덤 전화번호 * - {{$faker.word}} - 랜덤 단어 * - {{$faker.sentence}} - 랜덤 문장 * - {{$faker.text}} - 랜덤 텍스트 * - {{$faker.number:MIN:MAX}} - 범위 내 랜덤 정수 * - {{$faker.price:MIN:MAX}} - 범위 내 랜덤 가격 (소수점 2자리) * - {{$faker.code:PREFIX:LENGTH}} - 코드 생성 (PREFIX + 숫자) * - {{$faker.itemCode:PREFIX}} - 품목코드 생성 (PREFIX + 6자리 숫자) * - {{$faker.address}} - 랜덤 주소 * - {{$faker.url}} - 랜덤 URL * - {{$faker.boolean}} - true/false */ private function resolveFaker(string $input): string { // {{$faker.xxx}} 또는 {{$faker.xxx:param1:param2}} 패턴 처리 return preg_replace_callback( '/\{\{\$faker\.([a-zA-Z]+)(?::([^}]*))?\}\}/', fn ($m) => $this->generateFakerValue($m[1], $m[2] ?? ''), $input ); } /** * Faker 값 생성 */ private function generateFakerValue(string $type, string $params): string { $faker = $this->getFaker(); $paramList = $params !== '' ? explode(':', $params) : []; return match ($type) { // 기본 텍스트 'name' => $faker->name(), 'firstName' => $faker->firstName(), 'lastName' => $faker->lastName(), 'company' => $faker->company(), 'email' => $faker->unique()->safeEmail(), 'phone' => $faker->phoneNumber(), 'word' => $faker->word(), 'sentence' => $faker->sentence(), 'text' => $faker->text(100), 'paragraph' => $faker->paragraph(), // 주소 'address' => $faker->address(), 'city' => $faker->city(), 'postcode' => $faker->postcode(), // 숫자 'number' => $this->fakerNumber($faker, $paramList), 'price' => $this->fakerPrice($faker, $paramList), 'quantity' => (string) $faker->numberBetween(1, 100), // 코드 생성 'code' => $this->fakerCode($paramList), 'itemCode' => $this->fakerItemCode($paramList), 'sku' => 'SKU-'.strtoupper($faker->bothify('??###')), 'barcode' => $faker->ean13(), // 기타 'url' => $faker->url(), 'boolean' => $faker->boolean() ? 'true' : 'false', 'uuid' => $faker->uuid(), 'date' => $faker->date('Y-m-d'), 'datetime' => $faker->dateTime()->format('Y-m-d H:i:s'), // 품목 관련 'productName' => $this->fakerProductName($faker), 'unit' => $faker->randomElement(['EA', 'SET', 'BOX', 'KG', 'M', 'L', 'PCS']), 'category' => $faker->randomElement(['원자재', '부품', '반제품', '완제품', '소모품']), // HR 관련 (별칭 포함) 'koreanName' => $faker->name(), // ko_KR locale이므로 한글 이름 생성 'employeeCode' => $this->fakerEmployeeCode($paramList), 'departmentName' => $faker->randomElement(['개발팀', '영업팀', '인사팀', '재무팀', '마케팅팀', '기획팀']), 'positionName' => $faker->randomElement(['사원', '대리', '과장', '차장', '부장', '이사']), // 날짜 범위 지원 'dateRange' => $this->fakerDateRange($faker, $paramList), default => "{{faker.{$type}}}", }; } /** * 범위 내 랜덤 정수 생성 */ private function fakerNumber(FakerGenerator $faker, array $params): string { $min = isset($params[0]) ? (int) $params[0] : 1; $max = isset($params[1]) ? (int) $params[1] : 1000; return (string) $faker->numberBetween($min, $max); } /** * 범위 내 랜덤 가격 생성 (소수점 2자리) */ private function fakerPrice(FakerGenerator $faker, array $params): string { $min = isset($params[0]) ? (float) $params[0] : 1000; $max = isset($params[1]) ? (float) $params[1] : 100000; return number_format($faker->randomFloat(2, $min, $max), 2, '.', ''); } /** * 커스텀 코드 생성 (PREFIX + 숫자) */ private function fakerCode(array $params): string { $prefix = $params[0] ?? 'CODE'; $length = isset($params[1]) ? (int) $params[1] : 6; $digits = str_pad((string) random_int(0, (int) pow(10, $length) - 1), $length, '0', STR_PAD_LEFT); return $prefix.$digits; } /** * 품목코드 생성 (PREFIX + 6자리 숫자) */ private function fakerItemCode(array $params): string { $prefix = $params[0] ?? 'ITEM'; $digits = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); return $prefix.'-'.$digits; } /** * 제품명 생성 (한글) */ private function fakerProductName(FakerGenerator $faker): string { $prefixes = ['고급', '프리미엄', '스탠다드', '베이직', '프로', '울트라']; $types = ['부품', '자재', '모듈', '키트', '세트', '패키지']; $models = ['A', 'B', 'C', 'X', 'Y', 'Z', 'Pro', 'Plus', 'Max']; return $faker->randomElement($prefixes).' ' .$faker->randomElement($types).' ' .$faker->randomElement($models).'-' .$faker->numberBetween(100, 999); } /** * 사원코드 생성 (PREFIX-XXXXXX 형식) * * 사용법: {{$faker.employeeCode}} → EMP-123456 * {{$faker.employeeCode:STAFF}} → STAFF-123456 * {{$faker.employeeCode:EMP:8}} → EMP-12345678 */ private function fakerEmployeeCode(array $params): string { $prefix = $params[0] ?? 'EMP'; $length = isset($params[1]) ? (int) $params[1] : 6; $digits = str_pad((string) random_int(0, (int) pow(10, $length) - 1), $length, '0', STR_PAD_LEFT); return $prefix.'-'.$digits; } /** * 날짜 범위 내 랜덤 날짜 생성 * * 사용법: {{$faker.dateRange:2024-01-01:2024-12-31}} * {{$faker.dateRange:2024-01-01}} → 2024-01-01 ~ 오늘 * {{$faker.dateRange}} → 최근 1년 내 */ private function fakerDateRange(FakerGenerator $faker, array $params): string { $startDate = $params[0] ?? date('Y-m-d', strtotime('-1 year')); $endDate = $params[1] ?? date('Y-m-d'); return $faker->dateTimeBetween($startDate, $endDate)->format('Y-m-d'); } /** * 현재 로그인 사용자의 API 토큰 조회 */ private function getAuthToken(): string { $user = auth()->user(); if (! $user) { return env('FLOW_TESTER_API_TOKEN', ''); } // 사용자에게 저장된 API 토큰이 있으면 사용 if (! empty($user->api_token)) { return $user->api_token; } // 없으면 .env의 기본 토큰 사용 return env('FLOW_TESTER_API_TOKEN', ''); } /** * HMAC 인증용 동적 값 생성 * * 지원 패턴: * - {{$hmac.exp}} - 만료 시간 (현재 + 300초) * - {{$hmac.user_id}} - 선택된 사용자 ID (또는 현재 로그인 사용자) * - {{$hmac.tenant_id}} - 선택된 사용자의 테넌트 ID * - {{$hmac.signature}} - HMAC-SHA256 서명 * * 서명 생성 방식: * - payload: "{user_id}:{tenant_id}:{exp}" * - signature: hash_hmac('sha256', payload, exchange_secret) * * 세션에서 선택된 사용자 정보를 우선 사용 (API Explorer와 공유) */ private function resolveHmac(string $input): string { // HMAC 변수가 없으면 조기 반환 if (! str_contains($input, '{{$hmac.')) { return $input; } // 세션에서 선택된 사용자 ID 가져오기 (API Explorer와 공유) $selectedUserId = session('api_explorer_user_id'); // 선택된 사용자가 있으면 해당 사용자 정보 사용, 없으면 현재 로그인 사용자 if ($selectedUserId) { $user = \App\Models\User::find($selectedUserId); } else { $user = auth()->user(); } $userId = $user?->id ?? env('FLOW_TESTER_USER_ID', ''); // tenant_id는 user_tenants 피벗 테이블에서 가져옴 (MNG users 테이블에는 tenant_id 컬럼 없음) $tenantId = env('FLOW_TESTER_TENANT_ID', ''); if ($user) { $userTenant = \Illuminate\Support\Facades\DB::table('user_tenants') ->where('user_id', $user->id) ->where('is_default', true) ->whereNull('deleted_at') ->first(); if ($userTenant) { $tenantId = $userTenant->tenant_id; } } // 만료 시간 (현재 + 300초) $exp = time() + 300; // 서명 생성 $exchangeSecret = config('services.api.exchange_secret', ''); $payload = "{$userId}:{$tenantId}:{$exp}"; $signature = hash_hmac('sha256', $payload, $exchangeSecret); // 각 HMAC 변수 치환 $input = str_replace('{{$hmac.exp}}', (string) $exp, $input); $input = str_replace('{{$hmac.user_id}}', (string) $userId, $input); $input = str_replace('{{$hmac.tenant_id}}', (string) $tenantId, $input); $input = str_replace('{{$hmac.signature}}', $signature, $input); return $input; } /** * 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값) * * 지원 패턴: * - {{variables.xxx}} - 명시적 변수 참조 * - {{xxx}} - 단축형 변수 참조 (variables.xxx로 폴백) * - {{stepId.xxx}} - 스텝 추출값 참조 * - {{stepId.response.xxx}} - 스텝 전체 응답 참조 */ private function resolveReference(string $path): string { $path = trim($path); // variables.xxx → $this->context['variables']['xxx'] if (str_starts_with($path, 'variables.')) { $value = data_get($this->context, $path, ''); return $this->valueToString($value); } // stepId.xxx 패턴 감지 (stepId는 등록된 step의 ID) // 첫 번째 점(.) 기준으로 분리 $dotPos = strpos($path, '.'); if ($dotPos !== false) { $potentialStepId = substr($path, 0, $dotPos); $subPath = substr($path, $dotPos + 1); // 등록된 step인지 확인 if (isset($this->context['steps'][$potentialStepId])) { // stepId.response.xxx → 전체 응답에서 추출 if (str_starts_with($subPath, 'response.')) { $responsePath = substr($subPath, 9); // "response." 제거 $value = data_get($this->context['steps'][$potentialStepId]['response'] ?? [], $responsePath, ''); return $this->valueToString($value); } // stepId.xxx → extracted 또는 직접 접근 $value = data_get($this->context['steps'][$potentialStepId] ?? [], $subPath, ''); return $this->valueToString($value); } } // 기타 경로는 context에서 직접 조회 $value = data_get($this->context, $path, null); // 값이 없으면 variables.xxx로 폴백 시도 (단축형 지원) // 예: {{user_id}} → variables.user_id if ($value === null && ! str_contains($path, '.')) { $value = data_get($this->context, 'variables.'.$path, ''); } return $this->valueToString($value ?? ''); } /** * 값을 문자열로 변환 (배열/객체는 JSON) */ private function valueToString(mixed $value): string { if (is_null($value)) { return ''; } if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_array($value) || is_object($value)) { return json_encode($value, JSON_UNESCAPED_UNICODE); } return (string) $value; } /** * 현재 컨텍스트 반환 (디버깅용) */ public function getContext(): array { return $this->context; } /** * 세션 변수 처리 * * 지원 패턴: * - {{$session.token}} - 세션에 저장된 Bearer 토큰 * - {{$session.user_id}} - 세션 사용자 ID * - {{$session.user.email}} - 세션 사용자 이메일 * - {{$session.user.name}} - 세션 사용자 이름 * - {{$session.tenant_id}} - 세션 사용자의 기본 테넌트 ID */ private function resolveSessionVariables(string $input): string { // 세션 변수가 없으면 조기 반환 if (! str_contains($input, '{{$session.')) { return $input; } // {{$session.token}} → 세션에 저장된 Bearer 토큰 if (str_contains($input, '{{$session.token}}')) { $token = session('api_explorer_token', ''); $input = str_replace('{{$session.token}}', $token, $input); } // {{$session.user_id}} → 세션 사용자 ID if (str_contains($input, '{{$session.user_id}}')) { $userId = session('api_explorer_user_id', ''); $input = str_replace('{{$session.user_id}}', (string) $userId, $input); } // 사용자 상세 정보가 필요한 경우 (user.email, user.name, tenant_id) $needsUserDetails = str_contains($input, '{{$session.user.') || str_contains($input, '{{$session.tenant_id}}'); if ($needsUserDetails) { $userId = session('api_explorer_user_id'); $user = $userId ? \App\Models\User::find($userId) : null; // {{$session.user.email}} if (str_contains($input, '{{$session.user.email}}')) { $email = $user?->email ?? ''; $input = str_replace('{{$session.user.email}}', $email, $input); } // {{$session.user.name}} if (str_contains($input, '{{$session.user.name}}')) { $name = $user?->name ?? ''; $input = str_replace('{{$session.user.name}}', $name, $input); } // {{$session.tenant_id}} → 사용자의 기본 테넌트 ID if (str_contains($input, '{{$session.tenant_id}}')) { $tenantId = ''; if ($user) { $userTenant = \Illuminate\Support\Facades\DB::table('user_tenants') ->where('user_id', $user->id) ->where('is_default', true) ->whereNull('deleted_at') ->first(); $tenantId = $userTenant?->tenant_id ?? ''; } $input = str_replace('{{$session.tenant_id}}', (string) $tenantId, $input); } } return $input; } /** * 세션 인증 정보 조회 (FlowExecutor에서 사용) * * @return array{token: string, user_id: int|null, user: array|null, tenant_id: int|null} */ public function getSessionAuth(): array { $token = session('api_explorer_token', ''); $userId = session('api_explorer_user_id'); $user = null; $tenantId = null; if ($userId) { $userModel = \App\Models\User::find($userId); if ($userModel) { $user = [ 'id' => $userModel->id, 'name' => $userModel->name, 'email' => $userModel->email, ]; // 기본 테넌트 조회 $userTenant = \Illuminate\Support\Facades\DB::table('user_tenants') ->where('user_id', $userModel->id) ->where('is_default', true) ->whereNull('deleted_at') ->first(); $tenantId = $userTenant?->tenant_id; } } return [ 'token' => $token, 'user_id' => $userId, 'user' => $user, 'tenant_id' => $tenantId, ]; } }