diff --git a/app/Console/Commands/FcmPruneInvalidCommand.php b/app/Console/Commands/FcmPruneInvalidCommand.php new file mode 100644 index 0000000..d4f48e0 --- /dev/null +++ b/app/Console/Commands/FcmPruneInvalidCommand.php @@ -0,0 +1,105 @@ +option('days'); + $includeInactive = $this->option('inactive'); + $dryRun = $this->option('dry-run'); + + $this->info('FCM 무효 토큰 정리'); + $this->line(''); + + // 1. 에러가 있는 토큰 (N일 이상) + $errorQuery = PushDeviceToken::withoutGlobalScopes() + ->hasError() + ->where('last_error_at', '<', now()->subDays($days)); + + $errorCount = $errorQuery->count(); + + // 2. 비활성 토큰 (옵션) + $inactiveCount = 0; + if ($includeInactive) { + $inactiveQuery = PushDeviceToken::withoutGlobalScopes() + ->where('is_active', false) + ->whereNull('last_error') // 에러 없이 비활성화된 것만 + ->where('updated_at', '<', now()->subDays($days)); + + $inactiveCount = $inactiveQuery->count(); + } + + $totalCount = $errorCount + $inactiveCount; + + // 현황 출력 + $this->table(['구분', '대상 수'], [ + ["에러 토큰 ({$days}일 이상)", $errorCount], + ['비활성 토큰', $includeInactive ? $inactiveCount : '(미포함)'], + ['합계', $totalCount], + ]); + $this->line(''); + + if ($totalCount === 0) { + $this->info('정리할 토큰이 없습니다.'); + + return self::SUCCESS; + } + + // Dry-run 모드 + if ($dryRun) { + $this->info("🔍 Dry-run: {$totalCount}개 토큰 삭제 예정"); + + return self::SUCCESS; + } + + // 확인 + if (! $this->option('force') && ! $this->confirm("정말 {$totalCount}개 토큰을 삭제하시겠습니까?")) { + $this->info('취소되었습니다.'); + + return self::SUCCESS; + } + + // 삭제 실행 + $deletedError = 0; + $deletedInactive = 0; + + // 에러 토큰 삭제 + if ($errorCount > 0) { + $deletedError = PushDeviceToken::withoutGlobalScopes() + ->hasError() + ->where('last_error_at', '<', now()->subDays($days)) + ->delete(); + + $this->info(" 에러 토큰 삭제: {$deletedError}개"); + } + + // 비활성 토큰 삭제 + if ($includeInactive && $inactiveCount > 0) { + $deletedInactive = PushDeviceToken::withoutGlobalScopes() + ->where('is_active', false) + ->whereNull('last_error') + ->where('updated_at', '<', now()->subDays($days)) + ->delete(); + + $this->info(" 비활성 토큰 삭제: {$deletedInactive}개"); + } + + $this->line(''); + $this->info('✅ 총 '.($deletedError + $deletedInactive).'개 토큰 삭제 완료 (soft delete)'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/FcmSendCommand.php b/app/Console/Commands/FcmSendCommand.php new file mode 100644 index 0000000..9177da2 --- /dev/null +++ b/app/Console/Commands/FcmSendCommand.php @@ -0,0 +1,151 @@ +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; + } +} diff --git a/app/Models/PushDeviceToken.php b/app/Models/PushDeviceToken.php index fd87ee5..b4a1ff6 100644 --- a/app/Models/PushDeviceToken.php +++ b/app/Models/PushDeviceToken.php @@ -21,11 +21,14 @@ class PushDeviceToken extends Model 'app_version', 'is_active', 'last_used_at', + 'last_error', + 'last_error_at', ]; protected $casts = [ 'is_active' => 'boolean', 'last_used_at' => 'datetime', + 'last_error_at' => 'datetime', ]; /** @@ -76,4 +79,46 @@ public function scopeForUser($query, int $userId) { return $query->where('user_id', $userId); } + + /** + * Scope: 특정 테넌트의 토큰 (global scope 무시) + */ + public function scopeForTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + /** + * Scope: 에러가 있는 토큰 + */ + public function scopeHasError($query) + { + return $query->whereNotNull('last_error'); + } + + /** + * 에러 정보 기록 + */ + public function recordError(string $errorCode): void + { + $this->update([ + 'last_error' => $errorCode, + 'last_error_at' => now(), + ]); + } + + /** + * 토큰 비활성화 (에러와 함께) + */ + public function deactivate(?string $errorCode = null): void + { + $data = ['is_active' => false]; + + if ($errorCode) { + $data['last_error'] = $errorCode; + $data['last_error_at'] = now(); + } + + $this->update($data); + } } diff --git a/app/Services/Fcm/FcmBatchResult.php b/app/Services/Fcm/FcmBatchResult.php new file mode 100644 index 0000000..eb0e9e5 --- /dev/null +++ b/app/Services/Fcm/FcmBatchResult.php @@ -0,0 +1,112 @@ + */ + private array $responses = []; + + private int $successCount = 0; + + private int $failureCount = 0; + + /** @var array 무효 토큰 목록 */ + private array $invalidTokens = []; + + /** + * 응답 추가 + */ + public function addResponse(string $token, FcmResponse $response): void + { + $this->responses[$token] = $response; + + if ($response->success) { + $this->successCount++; + } else { + $this->failureCount++; + + if ($response->isInvalidToken()) { + $this->invalidTokens[] = $token; + } + } + } + + /** + * 전체 발송 수 + */ + public function getTotal(): int + { + return count($this->responses); + } + + /** + * 성공 수 + */ + public function getSuccessCount(): int + { + return $this->successCount; + } + + /** + * 실패 수 + */ + public function getFailureCount(): int + { + return $this->failureCount; + } + + /** + * 무효 토큰 목록 (비활성화 필요) + * + * @return array + */ + public function getInvalidTokens(): array + { + return $this->invalidTokens; + } + + /** + * 모든 응답 + * + * @return array + */ + public function getResponses(): array + { + return $this->responses; + } + + /** + * 특정 토큰의 응답 + */ + public function getResponse(string $token): ?FcmResponse + { + return $this->responses[$token] ?? null; + } + + /** + * 성공률 (%) + */ + public function getSuccessRate(): float + { + if ($this->getTotal() === 0) { + return 0.0; + } + + return round(($this->successCount / $this->getTotal()) * 100, 2); + } + + /** + * 요약 정보 + */ + public function toSummary(): array + { + return [ + 'total' => $this->getTotal(), + 'success' => $this->successCount, + 'failure' => $this->failureCount, + 'invalid_tokens' => count($this->invalidTokens), + 'success_rate' => $this->getSuccessRate(), + ]; + } +} diff --git a/app/Services/Fcm/FcmException.php b/app/Services/Fcm/FcmException.php index f88ffb0..4939674 100644 --- a/app/Services/Fcm/FcmException.php +++ b/app/Services/Fcm/FcmException.php @@ -24,4 +24,4 @@ public static function fromResponse(int $statusCode, array $response): self return new self($message, $statusCode, $response); } -} \ No newline at end of file +} diff --git a/app/Services/Fcm/FcmResponse.php b/app/Services/Fcm/FcmResponse.php index 881860c..acf5953 100644 --- a/app/Services/Fcm/FcmResponse.php +++ b/app/Services/Fcm/FcmResponse.php @@ -40,6 +40,18 @@ public static function failure(string $error, int $statusCode, array $rawRespons ); } + /** + * FCM 에러 코드 추출 + */ + public function getErrorCode(): ?string + { + if ($this->success) { + return null; + } + + return $this->rawResponse['error']['details'][0]['errorCode'] ?? null; + } + /** * 토큰이 유효하지 않은지 확인 (삭제 필요) */ @@ -56,9 +68,7 @@ public function isInvalidToken(): bool 'NOT_FOUND', ]; - $errorCode = $this->rawResponse['error']['details'][0]['errorCode'] ?? null; - - return in_array($errorCode, $invalidTokenErrors, true); + return in_array($this->getErrorCode(), $invalidTokenErrors, true); } /** @@ -73,4 +83,4 @@ public function toArray(): array 'status_code' => $this->statusCode, ]; } -} \ No newline at end of file +} diff --git a/app/Services/Fcm/FcmSender.php b/app/Services/Fcm/FcmSender.php index 30bd2e3..683b021 100644 --- a/app/Services/Fcm/FcmSender.php +++ b/app/Services/Fcm/FcmSender.php @@ -30,7 +30,7 @@ public function sendToToken( } /** - * 다건 발송 (토큰 배열) + * 다건 발송 (토큰 배열) - 단순 루프 * * @param array $tokens * @return array @@ -51,6 +51,42 @@ public function sendToTokens( return $responses; } + /** + * 대량 발송 (chunk + rate limit 지원) + * + * @param array $tokens + */ + public function sendToMany( + array $tokens, + string $title, + string $body, + string $channelId = 'push_default', + array $data = [], + ?int $chunkSize = null, + ?int $delayMs = null + ): FcmBatchResult { + $chunkSize = $chunkSize ?? config('fcm.batch.chunk_size', 200); + $delayMs = $delayMs ?? config('fcm.batch.delay_ms', 100); + + $result = new FcmBatchResult; + $chunks = array_chunk($tokens, $chunkSize); + $totalChunks = count($chunks); + + foreach ($chunks as $index => $chunk) { + foreach ($chunk as $token) { + $response = $this->sendToToken($token, $title, $body, $channelId, $data); + $result->addResponse($token, $response); + } + + // 마지막 chunk가 아니면 rate limit delay + if ($index < $totalChunks - 1 && $delayMs > 0) { + usleep($delayMs * 1000); + } + } + + return $result; + } + /** * FCM 메시지 빌드 */ @@ -234,4 +270,4 @@ private function logError(array $message, \Exception $e): void 'trace' => $e->getTraceAsString(), ]); } -} \ No newline at end of file +} diff --git a/config/fcm.php b/config/fcm.php index a38c062..4756e2d 100644 --- a/config/fcm.php +++ b/config/fcm.php @@ -53,6 +53,19 @@ 'ttl' => '86400s', // 24시간 ], + /* + |-------------------------------------------------------------------------- + | Batch Settings + |-------------------------------------------------------------------------- + | + | 대량 발송 시 rate limit 관리 설정 + | + */ + 'batch' => [ + 'chunk_size' => env('FCM_BATCH_CHUNK_SIZE', 200), // 한 번에 처리할 토큰 수 + 'delay_ms' => env('FCM_BATCH_DELAY_MS', 100), // chunk 간 딜레이 (ms) + ], + /* |-------------------------------------------------------------------------- | Logging @@ -62,4 +75,4 @@ 'enabled' => env('FCM_LOGGING_ENABLED', true), 'channel' => env('FCM_LOG_CHANNEL', 'stack'), ], -]; \ No newline at end of file +]; diff --git a/database/migrations/2025_12_18_223350_add_last_error_to_push_device_tokens.php b/database/migrations/2025_12_18_223350_add_last_error_to_push_device_tokens.php new file mode 100644 index 0000000..26d7eb7 --- /dev/null +++ b/database/migrations/2025_12_18_223350_add_last_error_to_push_device_tokens.php @@ -0,0 +1,29 @@ +string('last_error', 100)->nullable()->after('is_active')->comment('마지막 FCM 에러 코드'); + $table->timestamp('last_error_at')->nullable()->after('last_error')->comment('마지막 에러 발생 시각'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('push_device_tokens', function (Blueprint $table) { + $table->dropColumn(['last_error', 'last_error_at']); + }); + } +};