- push_urgent → push_vendor_register (거래처등록) - push_payment → push_approval_request (결재요청) - push_income 신규 추가 (입금) - config/fcm.php에 전체 7개 채널 등록 (기존 2개→7개) - 서비스 파일 하드코딩을 config() 참조로 전환 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
290 lines
8.3 KiB
PHP
290 lines
8.3 KiB
PHP
<?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;
|
|
}
|
|
|
|
/**
|
|
* 대량 발송 (chunk + rate limit 지원)
|
|
*
|
|
* @param array<string> $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' => $this->getSoundForChannel($channelId),
|
|
],
|
|
],
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 채널 ID에 따른 사운드 파일명 반환
|
|
*/
|
|
private function getSoundForChannel(string $channelId): string
|
|
{
|
|
return match ($channelId) {
|
|
'push_vendor_register' => 'push_vendor_register',
|
|
'push_approval_request' => 'push_approval_request',
|
|
'push_income' => 'push_income',
|
|
'push_sales_order' => 'push_sales_order',
|
|
'push_purchase_order' => 'push_purchase_order',
|
|
'push_contract' => 'push_contract',
|
|
default => 'push_default',
|
|
};
|
|
}
|
|
}
|