option('title'); $body = $this->option('body'); if (empty($title) || empty($body)) { $this->error('--title과 --body는 필수입니다.'); return self::FAILURE; } // 대상 토큰 조회 $query = PushDeviceToken::withoutGlobalScopes()->active(); if ($tenantId = $this->option('tenant')) { $query->forTenant((int) $tenantId); } if ($userId = $this->option('user')) { $query->forUser((int) $userId); } if ($platform = $this->option('platform')) { $query->platform($platform); } $limit = (int) $this->option('limit'); $tokens = $query->limit($limit)->pluck('token', 'id')->toArray(); $tokenCount = count($tokens); if ($tokenCount === 0) { $this->warn('발송 대상 토큰이 없습니다.'); return self::SUCCESS; } // 발송 정보 출력 $this->info('FCM 대량 발송'); $this->line(''); $this->table(['항목', '값'], [ ['대상 토큰 수', $tokenCount], ['테넌트', $this->option('tenant') ?: '전체'], ['사용자', $this->option('user') ?: '전체'], ['플랫폼', $this->option('platform') ?: '전체'], ['채널', $this->option('channel')], ['제목', $title], ['내용', mb_substr($body, 0, 30).(mb_strlen($body) > 30 ? '...' : '')], ['타입', $this->option('type') ?: '(없음)'], ['URL', $this->option('url') ?: '(없음)'], ]); $this->line(''); // Dry-run 모드 if ($this->option('dry-run')) { $this->info("🔍 Dry-run: {$tokenCount}개 토큰 발송 예정"); return self::SUCCESS; } // 확인 if (! $this->confirm("정말 {$tokenCount}개 토큰에 발송하시겠습니까?")) { $this->info('취소되었습니다.'); return self::SUCCESS; } // payload 데이터 구성 $data = array_filter([ 'type' => $this->option('type'), 'url' => $this->option('url'), 'sound_key' => $this->option('sound-key'), ]); // 발송 실행 $this->info('발송 중...'); $bar = $this->output->createProgressBar($tokenCount); $bar->start(); $sender = new FcmSender; $channel = $this->option('channel'); $tokenValues = array_values($tokens); $tokenIds = array_keys($tokens); $result = $sender->sendToMany($tokenValues, $title, $body, $channel, $data); $bar->finish(); $this->line(''); $this->line(''); // 무효 토큰 처리 $invalidTokens = $result->getInvalidTokens(); if (count($invalidTokens) > 0) { $this->info('무효 토큰 비활성화 중...'); // 토큰 값으로 DB 업데이트 foreach ($invalidTokens as $token) { $response = $result->getResponse($token); $errorCode = $response?->getErrorCode(); PushDeviceToken::withoutGlobalScopes() ->where('token', $token) ->update([ 'is_active' => false, 'last_error' => $errorCode, 'last_error_at' => now(), ]); } $this->warn(' 비활성화된 토큰: '.count($invalidTokens).'개'); } // 결과 출력 $summary = $result->toSummary(); $this->line(''); $this->info('✅ 발송 완료'); $this->table(['항목', '값'], [ ['전체', $summary['total']], ['성공', $summary['success']], ['실패', $summary['failure']], ['무효 토큰', $summary['invalid_tokens']], ['성공률', $summary['success_rate'].'%'], ]); return self::SUCCESS; } }