*/ protected array $errorMessages = [ 'SMTP connection refused: smtp.gmail.com:587', 'Connection timed out after 10s', 'Mailbox unavailable: 550 5.1.1 user unknown', 'TLS handshake failed', 'Rate limit exceeded (provider quota)', 'Recipient address rejected: domain not found', 'Authentication failed: invalid credentials', 'Greylisted, retry later (450 4.2.0)', ]; /** * 발송 건너뜀 사유. * * @var array */ protected array $skipReasons = [ 'Template inactive', 'User opted out', 'Channel disabled by user preference', 'Quiet hours policy applied', 'Duplicate suppression window', ]; /** * 알림 정의 쿼리에 영역 필터를 적용한다 (예: extension_type='core' 또는 extension_identifier='sirsoft-board'). * * @param Builder $query NotificationDefinition 쿼리 * @return Builder 영역 필터가 적용된 쿼리 */ abstract protected function applyDefinitionScope(Builder $query): Builder; /** * 카운트 옵션 키 (예: 'core_notification_logs', 'ecommerce_notification_logs'). * * @return string 카운트 옵션 키 */ abstract protected function countKey(): string; /** * 기본 생성 건수. * * @return int 기본 건수 */ abstract protected function defaultCount(): int; /** * 콘솔 메시지에 사용할 영역 라벨 (예: '코어', '이커머스 모듈'). * * @return string 영역 라벨 */ abstract protected function scopeLabel(): string; /** * 알림 타입별 한국어 제목 맵. * * @return array [type => subject] */ abstract protected function subjectMap(): array; /** * 알림 타입별 한국어 본문 빌더 맵. * * @return array [type => fn($recipient, $sentAt) => string] */ abstract protected function bodyMap(): array; /** * 시더 실행. */ 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; } $definitions = $this->applyDefinitionScope( NotificationDefinition::query()->where('is_active', true) )->get(['type', 'extension_type', 'extension_identifier', 'channels']); if ($definitions->isEmpty()) { $this->command->warn("{$label} 영역 활성 알림 정의가 없어 시더를 건너뜁니다."); return; } $admin = User::query() ->whereHas('roles', fn ($q) => $q->where('identifier', 'admin')) ->first(); $adminId = $admin?->id; $this->command->info("{$label} 알림 발송 이력 시딩 시작... ({$count}건)"); $now = Carbon::now(); $batch = []; for ($i = 0; $i < $count; $i++) { $definition = $definitions->random(); $channel = $this->pickChannel($definition->channels ?? ['mail']); $recipient = $users->random(); [$status, $error] = $this->randomStatusAndError(); $sentAt = $this->randomSentAt($now); $batch[] = [ 'channel' => $channel, 'notification_type' => $definition->type, 'extension_type' => $definition->extension_type, 'extension_identifier' => $definition->extension_identifier, 'recipient_user_id' => $recipient->id, 'recipient_identifier' => $channel === 'mail' ? $recipient->email : (string) $recipient->id, 'recipient_name' => $recipient->name, 'sender_user_id' => $this->isAdminBoundType($definition->type) ? null : $adminId, 'subject' => $this->renderSubject($definition->type), 'body' => $this->renderBody($definition->type, $recipient, $sentAt), 'status' => $status->value, 'error_message' => $error, 'source' => 'notification', 'sent_at' => $sentAt, 'created_at' => $sentAt, 'updated_at' => $sentAt, ]; } foreach (array_chunk($batch, 100) as $chunk) { NotificationLog::insert($chunk); } $this->command->info("{$label} 알림 발송 이력 시딩 완료 ({$count}건)"); } /** * 알림 정의에 등록된 채널 중 하나를 선택한다. * * @param array $channels 활성 채널 배열 * @return string 선택된 채널 */ protected function pickChannel(array $channels): string { if (empty($channels)) { return 'mail'; } if (in_array('mail', $channels, true) && in_array('database', $channels, true)) { return mt_rand(1, 100) <= 60 ? 'mail' : 'database'; } return $channels[array_rand($channels)]; } /** * 가중치 기반 상태/에러 메시지 페어를 반환한다. * * @return array{0: NotificationLogStatus, 1: string|null} */ protected function randomStatusAndError(): array { $rand = mt_rand(1, 100); if ($rand <= 80) { return [NotificationLogStatus::Sent, null]; } if ($rand <= 93) { return [ NotificationLogStatus::Failed, $this->errorMessages[array_rand($this->errorMessages)], ]; } return [ NotificationLogStatus::Skipped, $this->skipReasons[array_rand($this->skipReasons)], ]; } /** * 최근 60일 내 임의 발송 시각을 반환한다. * * @param Carbon $now 기준 시각 * @return Carbon 발송 시각 */ protected function randomSentAt(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)); } /** * 알림 타입이 관리자 수신용인지 (sender_user_id null 처리). * * @param string $type 알림 타입 * @return bool 관리자 수신용이면 true */ protected function isAdminBoundType(string $type): bool { return str_ends_with($type, '_admin'); } /** * 알림 타입별 제목 (서브클래스 subjectMap 우선, fallback 은 generic). * * @param string $type 알림 타입 * @return string 제목 */ protected function renderSubject(string $type): string { return $this->subjectMap()[$type] ?? "[G7] {$type} 알림"; } /** * 알림 타입별 본문 (서브클래스 bodyMap 우선, fallback 은 generic). * * @param string $type 알림 타입 * @param User $recipient 수신자 * @param Carbon $sentAt 발송 시각 * @return string 본문 */ protected function renderBody(string $type, User $recipient, Carbon $sentAt): string { $builder = $this->bodyMap()[$type] ?? null; if ($builder === null) { return "안녕하세요 {$recipient->name}님,\n\n{$type} 알림이 도착했습니다."; } return $builder($recipient, $sentAt); } }