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:
27
app/Services/Fcm/FcmException.php
Normal file
27
app/Services/Fcm/FcmException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
app/Services/Fcm/FcmResponse.php
Normal file
76
app/Services/Fcm/FcmResponse.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
237
app/Services/Fcm/FcmSender.php
Normal file
237
app/Services/Fcm/FcmSender.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user