*/ protected array $statusBuckets; /** * 한국/해외 IP 풀. * * @var array */ protected array $ips = [ '121.78.45.12', '211.234.111.5', '125.142.88.91', '210.94.0.74', '203.241.185.20', '180.182.50.7', '175.223.18.143', '218.236.42.61', '14.45.110.222', '112.184.99.180', '61.43.232.18', '59.16.7.205', '110.45.234.12', '106.247.83.190', '220.86.55.121', '203.0.113.42', '198.51.100.7', '172.217.27.142', '8.8.8.8', '1.1.1.1', ]; /** * 데스크톱/모바일 UA 풀. * * @var array */ protected array $userAgents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (Linux; Android 14; SM-S921N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36', 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0', ]; public function __construct() { $this->statusBuckets = [ [IdentityVerificationStatus::Verified, 55], [IdentityVerificationStatus::Expired, 15], [IdentityVerificationStatus::Failed, 12], [IdentityVerificationStatus::Sent, 7], [IdentityVerificationStatus::Cancelled, 5], [IdentityVerificationStatus::Requested, 3], [IdentityVerificationStatus::PolicyViolationLogged, 3], ]; } /** * IdentityPolicy 쿼리에 영역 필터를 적용한다. * * @param Builder $query IdentityPolicy 쿼리 * @return Builder 영역 필터가 적용된 쿼리 */ abstract protected function applyPolicyScope(Builder $query): Builder; /** * 카운트 옵션 키. * * @return string 카운트 옵션 키 */ abstract protected function countKey(): string; /** * 기본 생성 건수. * * @return int 기본 건수 */ abstract protected function defaultCount(): int; /** * 콘솔 메시지에 사용할 영역 라벨. * * @return string 영역 라벨 */ abstract protected function scopeLabel(): string; /** * 시더 실행. */ public function run(): void { $count = $this->getSeederCount($this->countKey(), $this->defaultCount()); $label = $this->scopeLabel(); $users = User::query()->get(['id', 'name', 'email']); if ($users->isEmpty()) { $this->command->warn("사용자 데이터가 없어 {$label} 본인인증 이력 시더를 건너뜁니다."); return; } $policies = $this->applyPolicyScope(IdentityPolicy::query()) ->get(['key', 'purpose', 'source_type', 'source_identifier', 'provider_id']); if ($policies->isEmpty()) { $this->command->warn("{$label} 영역 IdentityPolicy 가 없어 시더를 건너뜁니다."); return; } $manager = app(IdentityVerificationManager::class); $providerIds = array_keys($manager->all()); if (empty($providerIds)) { $this->command->warn("등록된 본인인증 프로바이더가 없어 {$label} 시더를 건너뜁니다."); return; } $this->command->info("{$label} 본인인증 이력 시딩 시작... ({$count}건)"); $ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15); $maxAttempts = (int) config('settings.identity.max_attempts', 5); $now = Carbon::now(); $batch = []; for ($i = 0; $i < $count; $i++) { $user = $users->random(); $policy = $policies->random(); $providerId = $policy->provider_id ?: $providerIds[array_rand($providerIds)]; $status = $this->pickStatus(); $renderHint = mt_rand(1, 100) <= 70 ? 'text_code' : 'email_link'; $createdAt = $this->randomCreatedAt($now); [$expiresAt, $verifiedAt, $consumedAt, $attempts] = $this->buildLifecycle( $status, $createdAt, $ttlMinutes, $maxAttempts, ); $properties = $renderHint === 'text_code' ? ['code_length' => 6] : ['link_hint' => 'email_link']; $metadata = $status === IdentityVerificationStatus::PolicyViolationLogged ? ['violation_reason' => 'fail_mode_log_only'] : ['hint_used' => $renderHint]; $batch[] = [ 'id' => (string) Str::uuid(), 'provider_id' => $providerId, 'purpose' => $policy->purpose, 'channel' => 'email', 'user_id' => $user->id, 'target_hash' => hash('sha256', mb_strtolower($user->email)), 'status' => $status->value, 'render_hint' => $renderHint, 'attempts' => $attempts, 'max_attempts' => $maxAttempts, 'ip_address' => $this->ips[array_rand($this->ips)], 'user_agent' => $this->userAgents[array_rand($this->userAgents)], // 본 시더의 모든 challenge 는 IdentityPolicy enforce 경로를 통한 것이므로 // origin_type 은 'policy' 로 분류한다 (이전 버전에서는 source_type 을 잘못 매핑). 'origin_type' => IdentityOriginType::Policy->value, 'origin_identifier' => $policy->source_identifier, 'origin_policy_key' => $policy->key, 'properties' => json_encode($properties, JSON_UNESCAPED_UNICODE), 'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE), 'verification_token' => $status === IdentityVerificationStatus::Verified ? bin2hex(random_bytes(32)) : null, 'expires_at' => $expiresAt, 'verified_at' => $verifiedAt, 'consumed_at' => $consumedAt, 'created_at' => $createdAt, 'updated_at' => $verifiedAt ?? $createdAt, ]; } foreach (array_chunk($batch, 100) as $chunk) { IdentityVerificationLog::insert($chunk); } $this->command->info("{$label} 본인인증 이력 시딩 완료 ({$count}건)"); } /** * 가중치 기반 상태 선택. * * @return IdentityVerificationStatus 선택된 상태 */ protected function pickStatus(): IdentityVerificationStatus { $r = mt_rand(1, 100); $acc = 0; foreach ($this->statusBuckets as [$status, $weight]) { $acc += $weight; if ($r <= $acc) { return $status; } } return IdentityVerificationStatus::Verified; } /** * 상태별 라이프사이클 일관성 있게 구성. * * @param IdentityVerificationStatus $status Challenge 상태 * @param Carbon $createdAt 생성 시각 * @param int $ttlMinutes TTL (분) * @param int $maxAttempts 최대 시도 횟수 * @return array{0: Carbon|null, 1: Carbon|null, 2: Carbon|null, 3: int} [expires_at, verified_at, consumed_at, attempts] */ protected function buildLifecycle( IdentityVerificationStatus $status, Carbon $createdAt, int $ttlMinutes, int $maxAttempts, ): array { $expiresAt = (clone $createdAt)->addMinutes($ttlMinutes); $verifiedAt = null; $consumedAt = null; $attempts = 0; switch ($status) { case IdentityVerificationStatus::Verified: $attempts = mt_rand(1, 3); $verifiedAt = (clone $createdAt)->addSeconds(mt_rand(20, 600)); if (mt_rand(0, 1)) { $consumedAt = (clone $verifiedAt)->addSeconds(mt_rand(1, 30)); } break; case IdentityVerificationStatus::Expired: $attempts = mt_rand(0, 2); break; case IdentityVerificationStatus::Failed: $attempts = $maxAttempts; break; case IdentityVerificationStatus::Cancelled: $attempts = mt_rand(0, 2); break; case IdentityVerificationStatus::Sent: case IdentityVerificationStatus::Requested: $attempts = 0; break; case IdentityVerificationStatus::PolicyViolationLogged: $expiresAt = null; $attempts = 0; break; } return [$expiresAt, $verifiedAt, $consumedAt, $attempts]; } /** * 최근 60일 내 임의 생성 시각. * * @param Carbon $now 기준 시각 * @return Carbon Challenge 생성 시각 */ protected function randomCreatedAt(Carbon $now): Carbon { return (clone $now) ->subDays(mt_rand(0, 60)) ->subHours(mt_rand(0, 23)) ->subMinutes(mt_rand(0, 59)) ->subSeconds(mt_rand(0, 59)); } }