feat: FCM HTTP v1 푸시 알림 발송 기능 구현

- google/auth 패키지 추가 (OAuth2 Service Account 인증)
- FcmSender: FCM HTTP v1 API 발송 서비스
- FcmResponse: 응답 DTO (성공/실패, 토큰 유효성 체크)
- FcmException: FCM 전용 예외 클래스
- fcm:test artisan 명령어 (테스트 발송)
- PushNotificationService에 FcmSender 연동
- config/fcm.php 설정 파일 추가
- 알림 유형별 채널 분리 (push_default, push_urgent)
This commit is contained in:
2025-12-18 22:06:26 +09:00
parent da7165a79f
commit 6e36d179a6
9 changed files with 735 additions and 11 deletions

1
.gitignore vendored
View File

@@ -153,3 +153,4 @@ _ide_helper_models.php
!**/data/ !**/data/
# 그리고 .gitkeep은 예외로 추적 # 그리고 .gitkeep은 예외로 추적
!**/data/.gitkeep !**/data/.gitkeep
storage/secrets/

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Console\Commands;
use App\Services\Fcm\FcmSender;
use Illuminate\Console\Command;
class FcmTestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fcm:test
{--token= : FCM 디바이스 토큰 (필수)}
{--channel=push_default : 알림 채널 ID (push_default, push_urgent)}
{--title=테스트 알림 : 알림 제목}
{--body=FCM 테스트 메시지입니다. : 알림 내용}
{--data= : 추가 데이터 (JSON 형식)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'FCM 푸시 알림 테스트 발송';
/**
* Execute the console command.
*/
public function handle(): int
{
$token = $this->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;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\Fcm;
use Exception;
class FcmException extends Exception
{
public function __construct(
string $message,
public readonly int $httpStatusCode = 0,
public readonly array $response = [],
?\Throwable $previous = null
) {
parent::__construct($message, 0, $previous);
}
/**
* HTTP 응답에서 예외 생성
*/
public static function fromResponse(int $statusCode, array $response): self
{
$message = $response['error']['message'] ?? 'Unknown FCM error';
return new self($message, $statusCode, $response);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services\Fcm;
class FcmResponse
{
public function __construct(
public readonly bool $success,
public readonly ?string $messageId,
public readonly ?string $error,
public readonly int $statusCode,
public readonly array $rawResponse
) {}
/**
* 성공 응답 생성
*/
public static function success(?string $messageId, int $statusCode, array $rawResponse): self
{
return new self(
success: true,
messageId: $messageId,
error: null,
statusCode: $statusCode,
rawResponse: $rawResponse
);
}
/**
* 실패 응답 생성
*/
public static function failure(string $error, int $statusCode, array $rawResponse): self
{
return new self(
success: false,
messageId: null,
error: $error,
statusCode: $statusCode,
rawResponse: $rawResponse
);
}
/**
* 토큰이 유효하지 않은지 확인 (삭제 필요)
*/
public function isInvalidToken(): bool
{
if ($this->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,
];
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Services\Fcm;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FcmSender
{
private const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
private ?string $accessToken = null;
private ?int $tokenExpiresAt = null;
/**
* 단일 토큰에 푸시 발송
*/
public function sendToToken(
string $token,
string $title,
string $body,
string $channelId = 'push_default',
array $data = []
): FcmResponse {
$message = $this->buildMessage($token, $title, $body, $channelId, $data);
return $this->send($message);
}
/**
* 다건 발송 (토큰 배열)
*
* @param array<string> $tokens
* @return array<FcmResponse>
*/
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(),
]);
}
}

View File

@@ -4,6 +4,7 @@
use App\Models\PushDeviceToken; use App\Models\PushDeviceToken;
use App\Models\PushNotificationSetting; use App\Models\PushNotificationSetting;
use App\Services\Fcm\FcmSender;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class PushNotificationService extends Service 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( protected function sendFcmMessage(
PushDeviceToken $token, PushDeviceToken $token,
@@ -225,18 +226,75 @@ protected function sendFcmMessage(
string $sound, string $sound,
string $notificationType string $notificationType
): bool { ): bool {
// TODO: FCM HTTP v1 API 구현 // FCM 설정이 없으면 로그만 기록
// 현재는 로그만 기록 if (empty(config('fcm.project_id'))) {
Log::info('FCM message would be sent', [ Log::info('FCM message skipped (not configured)', [
'token_id' => $token->id, 'token_id' => $token->id,
'platform' => $token->platform, 'platform' => $token->platform,
'title' => $notification['title'] ?? '', 'title' => $notification['title'] ?? '',
'body' => $notification['body'] ?? '', 'body' => $notification['body'] ?? '',
'sound' => $sound,
'type' => $notificationType, 'type' => $notificationType,
]); ]);
return true; return true; // 설정이 없어도 실패로 처리하지 않음
}
// 알림 유형에 따른 채널 결정
$channelId = $this->getChannelId($notificationType);
// 추가 데이터 구성
$data = array_merge($notification['data'] ?? [], [
'notification_type' => $notificationType,
'sound' => $sound,
]);
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');
} }
/** /**

View File

@@ -9,6 +9,7 @@
"php": "^8.2", "php": "^8.2",
"darkaonline/l5-swagger": "^9.0", "darkaonline/l5-swagger": "^9.0",
"doctrine/dbal": "^4.3", "doctrine/dbal": "^4.3",
"google/auth": "^1.49",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/mcp": "^0.1.1", "laravel/mcp": "^0.1.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",

127
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3678e3392030cdc9d6cbbab85da3d350", "content-hash": "340d586f1c4e3f7bd0728229300967da",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1042,6 +1042,69 @@
}, },
"time": "2024-11-01T03:51:45+00:00" "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", "name": "fruitcake/php-cors",
"version": "v1.3.0", "version": "v1.3.0",
@@ -1113,6 +1176,68 @@
], ],
"time": "2023-10-12T05:21:21+00:00" "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", "name": "graham-campbell/result-type",
"version": "v1.1.3", "version": "v1.1.3",

65
config/fcm.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| FCM Project ID
|--------------------------------------------------------------------------
|
| Firebase 프로젝트 ID (Firebase Console에서 확인)
|
*/
'project_id' => 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'),
],
];