diff --git a/.gitignore b/.gitignore index 2e2ead0..507cfbc 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,4 @@ _ide_helper_models.php !**/data/ # 그리고 .gitkeep은 예외로 추적 !**/data/.gitkeep +storage/secrets/ diff --git a/app/Console/Commands/FcmTestCommand.php b/app/Console/Commands/FcmTestCommand.php new file mode 100644 index 0000000..296aa1a --- /dev/null +++ b/app/Console/Commands/FcmTestCommand.php @@ -0,0 +1,134 @@ +option('token'); + + if (empty($token)) { + $this->error('--token 옵션은 필수입니다.'); + $this->line(''); + $this->line('사용법:'); + $this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN'); + $this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN --channel=push_urgent --title="긴급 알림"'); + + return self::FAILURE; + } + + $channel = $this->option('channel'); + $title = $this->option('title'); + $body = $this->option('body'); + $dataJson = $this->option('data'); + + // 추가 데이터 파싱 + $data = []; + if ($dataJson) { + $data = json_decode($dataJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->error('--data 옵션은 유효한 JSON이어야 합니다.'); + + return self::FAILURE; + } + } + + // 설정 확인 + $this->info('FCM 테스트 발송 시작...'); + $this->line(''); + $this->table( + ['항목', '값'], + [ + ['Project ID', config('fcm.project_id') ?: '(미설정)'], + ['Token', substr($token, 0, 30).'...'], + ['Channel', $channel], + ['Title', $title], + ['Body', mb_substr($body, 0, 50).(mb_strlen($body) > 50 ? '...' : '')], + ['Data', $data ? json_encode($data, JSON_UNESCAPED_UNICODE) : '(없음)'], + ] + ); + $this->line(''); + + // Project ID 확인 + if (empty(config('fcm.project_id'))) { + $this->error('FCM_PROJECT_ID가 설정되지 않았습니다.'); + $this->line('.env 파일에 FCM_PROJECT_ID를 설정해주세요.'); + + return self::FAILURE; + } + + // Service Account 파일 확인 + $saPath = config('fcm.service_account_path'); + $fullPath = str_starts_with($saPath, '/') ? $saPath : storage_path($saPath); + + if (! file_exists($fullPath)) { + $this->error("Service Account 파일을 찾을 수 없습니다: {$fullPath}"); + $this->line('.env 파일에 FCM_SA_PATH를 확인해주세요.'); + + return self::FAILURE; + } + + $this->info('Service Account 파일 확인됨: '.basename($fullPath)); + + // FCM 발송 + try { + $sender = new FcmSender; + $response = $sender->sendToToken($token, $title, $body, $channel, $data); + + $this->line(''); + + if ($response->success) { + $this->info('✅ FCM 발송 성공!'); + $this->line("Message ID: {$response->messageId}"); + $this->line("Status Code: {$response->statusCode}"); + } else { + $this->error('❌ FCM 발송 실패'); + $this->line("Error: {$response->error}"); + $this->line("Status Code: {$response->statusCode}"); + + if ($response->isInvalidToken()) { + $this->warn('토큰이 유효하지 않습니다. 디바이스에서 새 토큰을 발급받아야 합니다.'); + } + + $this->line(''); + $this->line('Response:'); + $this->line(json_encode($response->rawResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + return self::FAILURE; + } + + } catch (\Exception $e) { + $this->error('FCM 발송 중 예외 발생: '.$e->getMessage()); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/Services/Fcm/FcmException.php b/app/Services/Fcm/FcmException.php new file mode 100644 index 0000000..f88ffb0 --- /dev/null +++ b/app/Services/Fcm/FcmException.php @@ -0,0 +1,27 @@ +success) { + return false; + } + + // FCM에서 반환하는 무효 토큰 에러 코드들 + $invalidTokenErrors = [ + 'UNREGISTERED', + 'INVALID_ARGUMENT', + 'NOT_FOUND', + ]; + + $errorCode = $this->rawResponse['error']['details'][0]['errorCode'] ?? null; + + return in_array($errorCode, $invalidTokenErrors, true); + } + + /** + * 배열로 변환 + */ + public function toArray(): array + { + return [ + 'success' => $this->success, + 'message_id' => $this->messageId, + 'error' => $this->error, + 'status_code' => $this->statusCode, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Fcm/FcmSender.php b/app/Services/Fcm/FcmSender.php new file mode 100644 index 0000000..30bd2e3 --- /dev/null +++ b/app/Services/Fcm/FcmSender.php @@ -0,0 +1,237 @@ +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; + } + + /** + * 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(), + ]); + } +} \ No newline at end of file diff --git a/app/Services/PushNotificationService.php b/app/Services/PushNotificationService.php index 47b451e..2ca97db 100644 --- a/app/Services/PushNotificationService.php +++ b/app/Services/PushNotificationService.php @@ -4,6 +4,7 @@ use App\Models\PushDeviceToken; use App\Models\PushNotificationSetting; +use App\Services\Fcm\FcmSender; use Illuminate\Support\Facades\Log; class PushNotificationService extends Service @@ -217,7 +218,7 @@ public function sendToUser(int $userId, string $notificationType, array $notific } /** - * FCM 메시지 전송 (실제 구현) + * FCM 메시지 전송 (FCM HTTP v1 API) */ protected function sendFcmMessage( PushDeviceToken $token, @@ -225,18 +226,75 @@ protected function sendFcmMessage( string $sound, string $notificationType ): bool { - // TODO: FCM HTTP v1 API 구현 - // 현재는 로그만 기록 - Log::info('FCM message would be sent', [ - 'token_id' => $token->id, - 'platform' => $token->platform, - 'title' => $notification['title'] ?? '', - 'body' => $notification['body'] ?? '', + // FCM 설정이 없으면 로그만 기록 + if (empty(config('fcm.project_id'))) { + Log::info('FCM message skipped (not configured)', [ + 'token_id' => $token->id, + 'platform' => $token->platform, + 'title' => $notification['title'] ?? '', + 'body' => $notification['body'] ?? '', + 'type' => $notificationType, + ]); + + return true; // 설정이 없어도 실패로 처리하지 않음 + } + + // 알림 유형에 따른 채널 결정 + $channelId = $this->getChannelId($notificationType); + + // 추가 데이터 구성 + $data = array_merge($notification['data'] ?? [], [ + 'notification_type' => $notificationType, 'sound' => $sound, - 'type' => $notificationType, ]); - return true; + try { + $sender = new FcmSender; + $response = $sender->sendToToken( + $token->token, + $notification['title'] ?? '', + $notification['body'] ?? '', + $channelId, + $data + ); + + // 유효하지 않은 토큰이면 비활성화 + if ($response->isInvalidToken()) { + $token->update(['is_active' => false]); + Log::warning('FCM token invalidated', [ + 'token_id' => $token->id, + 'error' => $response->error, + ]); + + return false; + } + + return $response->success; + + } catch (\Exception $e) { + Log::error('FCM send failed', [ + 'token_id' => $token->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * 알림 유형에 따른 채널 ID 결정 + */ + protected function getChannelId(string $notificationType): string + { + // 긴급 알림 유형들 + $urgentTypes = [ + PushNotificationSetting::TYPE_APPROVAL, + PushNotificationSetting::TYPE_ORDER, + ]; + + return in_array($notificationType, $urgentTypes, true) + ? config('fcm.channels.urgent', 'push_urgent') + : config('fcm.channels.default', 'push_default'); } /** diff --git a/composer.json b/composer.json index 067899a..e45077d 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^8.2", "darkaonline/l5-swagger": "^9.0", "doctrine/dbal": "^4.3", + "google/auth": "^1.49", "laravel/framework": "^12.0", "laravel/mcp": "^0.1.1", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index 8dfd509..19a010a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3678e3392030cdc9d6cbbab85da3d350", + "content-hash": "340d586f1c4e3f7bd0728229300967da", "packages": [ { "name": "brick/math", @@ -1042,6 +1042,69 @@ }, "time": "2024-11-01T03:51:45+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -1113,6 +1176,68 @@ ], "time": "2023-10-12T05:21:21+00:00" }, + { + "name": "google/auth", + "version": "v1.49.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "68e3d88cb59a49f713e3db25d4f6bb3cc0b70764" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/68e3d88cb59a49f713e3db25d4f6bb3cc0b70764", + "reference": "68e3d88cb59a49f713e3db25d4f6bb3cc0b70764", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.4.5", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0", + "kelvinmo/simplejwt": "0.7.1", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.49.0" + }, + "time": "2025-11-06T21:27:55+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.3", diff --git a/config/fcm.php b/config/fcm.php new file mode 100644 index 0000000..a38c062 --- /dev/null +++ b/config/fcm.php @@ -0,0 +1,65 @@ + env('FCM_PROJECT_ID'), + + /* + |-------------------------------------------------------------------------- + | Service Account JSON Path + |-------------------------------------------------------------------------- + | + | Firebase Admin SDK 서비스 계정 JSON 파일 경로 + | storage_path() 기준 상대 경로 또는 절대 경로 + | + */ + 'service_account_path' => env('FCM_SA_PATH', 'app/firebase-service-account.json'), + + /* + |-------------------------------------------------------------------------- + | FCM HTTP v1 Endpoint + |-------------------------------------------------------------------------- + */ + 'endpoint' => 'https://fcm.googleapis.com/v1/projects/{project_id}/messages:send', + + /* + |-------------------------------------------------------------------------- + | Android Notification Channels + |-------------------------------------------------------------------------- + | + | 앱에서 정의된 알림 채널 ID 매핑 + | + */ + 'channels' => [ + 'default' => 'push_default', + 'urgent' => 'push_urgent', + ], + + /* + |-------------------------------------------------------------------------- + | Default Settings + |-------------------------------------------------------------------------- + */ + 'defaults' => [ + 'channel_id' => 'push_default', + 'priority' => 'high', + 'ttl' => '86400s', // 24시간 + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + 'logging' => [ + 'enabled' => env('FCM_LOGGING_ENABLED', true), + 'channel' => env('FCM_LOG_CHANNEL', 'stack'), + ], +]; \ No newline at end of file