buildMessage($token, $title, $body, $channelId, $data); return $this->send($message); } /** * 다건 발송 (토큰 배열) - 단순 루프 * * @param array $tokens * @return array */ public function sendToTokens( array $tokens, string $title, string $body, string $channelId = 'push_default', array $data = [] ): array { $responses = []; foreach ($tokens as $token) { $responses[] = $this->sendToToken($token, $title, $body, $channelId, $data); } 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 메시지 빌드 */ private function buildMessage( string $token, string $title, string $body, string $channelId, array $data ): array { $message = [ 'message' => [ 'token' => $token, 'notification' => [ 'title' => $title, 'body' => $body, ], 'android' => [ 'priority' => config('fcm.defaults.priority', 'high'), 'ttl' => config('fcm.defaults.ttl', '86400s'), 'notification' => [ 'channel_id' => $channelId, 'sound' => 'default', ], ], 'apns' => [ 'payload' => [ 'aps' => [ 'sound' => 'default', 'badge' => 1, ], ], ], ], ]; // 커스텀 데이터 추가 if (! empty($data)) { // FCM data는 모두 string이어야 함 $stringData = array_map(fn ($v) => is_string($v) ? $v : json_encode($v), $data); $message['message']['data'] = $stringData; } return $message; } /** * FCM HTTP v1 API로 메시지 발송 */ private function send(array $message): FcmResponse { $projectId = config('fcm.project_id'); if (empty($projectId)) { return FcmResponse::failure('FCM_PROJECT_ID not configured', 0, $message); } $endpoint = str_replace('{project_id}', $projectId, config('fcm.endpoint')); try { $accessToken = $this->getAccessToken(); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$accessToken, 'Content-Type' => 'application/json', ])->post($endpoint, $message); $statusCode = $response->status(); $responseBody = $response->json() ?? []; $this->logRequest($message, $statusCode, $responseBody); if ($response->successful()) { return FcmResponse::success($responseBody['name'] ?? null, $statusCode, $responseBody); } $errorMessage = $responseBody['error']['message'] ?? 'Unknown FCM error'; return FcmResponse::failure($errorMessage, $statusCode, $responseBody); } catch (\Exception $e) { $this->logError($message, $e); return FcmResponse::failure($e->getMessage(), 0, []); } } /** * OAuth2 Access Token 발급 (캐싱) */ private function getAccessToken(): string { // 토큰이 유효하면 재사용 if ($this->accessToken && $this->tokenExpiresAt && time() < $this->tokenExpiresAt - 60) { return $this->accessToken; } $saPath = $this->getServiceAccountPath(); if (! file_exists($saPath)) { throw new FcmException("Service account file not found: {$saPath}"); } $credentials = new ServiceAccountCredentials( self::FCM_SCOPE, json_decode(file_get_contents($saPath), true) ); $token = $credentials->fetchAuthToken(); if (empty($token['access_token'])) { throw new FcmException('Failed to fetch FCM access token'); } $this->accessToken = $token['access_token']; $this->tokenExpiresAt = time() + ($token['expires_in'] ?? 3600); return $this->accessToken; } /** * Service Account JSON 파일 경로 */ private function getServiceAccountPath(): string { $path = config('fcm.service_account_path'); // 절대 경로인 경우 if (str_starts_with($path, '/')) { return $path; } // 상대 경로인 경우 storage_path 기준 return storage_path($path); } /** * 요청/응답 로깅 */ private function logRequest(array $message, int $statusCode, array $response): void { if (! config('fcm.logging.enabled', true)) { return; } $channel = config('fcm.logging.channel', 'stack'); $token = $message['message']['token'] ?? 'unknown'; $maskedToken = substr($token, 0, 20).'...'; if ($statusCode >= 200 && $statusCode < 300) { Log::channel($channel)->info('FCM message sent', [ 'token' => $maskedToken, 'status' => $statusCode, 'message_name' => $response['name'] ?? null, ]); } else { Log::channel($channel)->warning('FCM message failed', [ 'token' => $maskedToken, 'status' => $statusCode, 'error' => $response['error'] ?? $response, ]); } } /** * 에러 로깅 */ private function logError(array $message, \Exception $e): void { if (! config('fcm.logging.enabled', true)) { return; } $channel = config('fcm.logging.channel', 'stack'); $token = $message['message']['token'] ?? 'unknown'; $maskedToken = substr($token, 0, 20).'...'; Log::channel($channel)->error('FCM send exception', [ 'token' => $maskedToken, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); } }