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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -153,3 +153,4 @@ _ide_helper_models.php
|
|||||||
!**/data/
|
!**/data/
|
||||||
# 그리고 .gitkeep은 예외로 추적
|
# 그리고 .gitkeep은 예외로 추적
|
||||||
!**/data/.gitkeep
|
!**/data/.gitkeep
|
||||||
|
storage/secrets/
|
||||||
|
|||||||
134
app/Console/Commands/FcmTestCommand.php
Normal file
134
app/Console/Commands/FcmTestCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] ?? '',
|
||||||
|
'type' => $notificationType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true; // 설정이 없어도 실패로 처리하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 유형에 따른 채널 결정
|
||||||
|
$channelId = $this->getChannelId($notificationType);
|
||||||
|
|
||||||
|
// 추가 데이터 구성
|
||||||
|
$data = array_merge($notification['data'] ?? [], [
|
||||||
|
'notification_type' => $notificationType,
|
||||||
'sound' => $sound,
|
'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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
127
composer.lock
generated
@@ -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
65
config/fcm.php
Normal 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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user