feat: [fcm] Admin FCM API 추가 - MNG에서 API 호출로 FCM 발송

- AdminFcmController, AdminFcmService 추가
- FormRequest 검증 (AdminFcmSendRequest 등)
- Swagger 문서 추가 (AdminFcmApi.php)
- ApiKeyMiddleware: admin/fcm/* 화이트리스트 추가
- FCM 에러 메시지 i18n 추가
This commit is contained in:
2025-12-23 12:42:58 +09:00
parent 03cf96d4bb
commit f5ec9d502c
8 changed files with 146 additions and 586 deletions

View File

@@ -5,7 +5,7 @@
use App\Models\FcmSendLog; use App\Models\FcmSendLog;
use App\Models\PushDeviceToken; use App\Models\PushDeviceToken;
use App\Models\Tenants\Tenant; use App\Models\Tenants\Tenant;
use App\Services\Fcm\FcmSender; use App\Services\FcmApiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
@@ -13,7 +13,7 @@
class FcmController extends Controller class FcmController extends Controller
{ {
public function __construct( public function __construct(
private readonly FcmSender $fcmSender private readonly FcmApiService $fcmApiService
) {} ) {}
/** /**
@@ -86,6 +86,8 @@ public function send(Request $request): View
/** /**
* FCM 발송 실행 (HTMX) * FCM 발송 실행 (HTMX)
*
* API 서버를 통해 FCM을 발송합니다.
*/ */
public function sendPush(Request $request): View public function sendPush(Request $request): View
{ {
@@ -101,100 +103,34 @@ public function sendPush(Request $request): View
'sound_key' => 'nullable|string|max:50', 'sound_key' => 'nullable|string|max:50',
]); ]);
// 대상 토큰 조회 // API 서버로 발송 요청
$query = PushDeviceToken::withoutGlobalScopes()->active(); $result = $this->fcmApiService->send(
array_filter([
if ($tenantId = $request->get('tenant_id')) { 'title' => $request->get('title'),
$query->forTenant($tenantId); 'body' => $request->get('body'),
} 'tenant_id' => $request->get('tenant_id'),
'user_id' => $request->get('user_id'),
if ($userId = $request->get('user_id')) { 'platform' => $request->get('platform'),
$query->forUser($userId); 'channel_id' => $request->get('channel_id'),
}
if ($platform = $request->get('platform')) {
$query->platform($platform);
}
$tokens = $query->pluck('token')->toArray();
$tokenCount = count($tokens);
if ($tokenCount === 0) {
return view('fcm.partials.send-result', [
'success' => false,
'message' => '발송 대상 토큰이 없습니다.',
]);
}
// 발송 로그 생성
$sendLog = FcmSendLog::create([
'tenant_id' => $request->get('tenant_id'),
'user_id' => $request->get('user_id'),
'sender_id' => auth()->id(),
'title' => $request->get('title'),
'body' => $request->get('body'),
'channel_id' => $request->get('channel_id', 'push_default'),
'type' => $request->get('type'),
'platform' => $request->get('platform'),
'data' => array_filter([
'type' => $request->get('type'), 'type' => $request->get('type'),
'url' => $request->get('url'), 'url' => $request->get('url'),
'sound_key' => $request->get('sound_key'), 'sound_key' => $request->get('sound_key'),
]), ]),
'status' => FcmSendLog::STATUS_SENDING, auth()->id()
]); );
try {
// FCM 발송
$data = array_filter([
'type' => $request->get('type'),
'url' => $request->get('url'),
'sound_key' => $request->get('sound_key'),
]);
$result = $this->fcmSender->sendToMany(
$tokens,
$request->get('title'),
$request->get('body'),
$request->get('channel_id', 'push_default'),
$data
);
// 무효 토큰 비활성화
$invalidTokens = $result->getInvalidTokens();
if (count($invalidTokens) > 0) {
foreach ($invalidTokens as $token) {
$response = $result->getResponse($token);
$errorCode = $response?->getErrorCode();
PushDeviceToken::withoutGlobalScopes()
->where('token', $token)
->update([
'is_active' => false,
'last_error' => $errorCode,
'last_error_at' => now(),
]);
}
}
// 발송 로그 업데이트
$summary = $result->toSummary();
$sendLog->markAsCompleted($summary);
return view('fcm.partials.send-result', [
'success' => true,
'message' => '발송 완료',
'data' => $summary,
]);
} catch (\Exception $e) {
$sendLog->markAsFailed($e->getMessage());
if (! $result['success']) {
return view('fcm.partials.send-result', [ return view('fcm.partials.send-result', [
'success' => false, 'success' => false,
'message' => '발송 중 오류 발생: '.$e->getMessage(), 'message' => $result['message'] ?? 'FCM 발송에 실패했습니다.',
]); ]);
} }
return view('fcm.partials.send-result', [
'success' => true,
'message' => $result['message'] ?? '발송 완료',
'data' => $result['data'] ?? [],
]);
} }
/** /**

View File

@@ -1,112 +0,0 @@
<?php
namespace App\Services\Fcm;
class FcmBatchResult
{
/** @var array<string, FcmResponse> */
private array $responses = [];
private int $successCount = 0;
private int $failureCount = 0;
/** @var array<string> 무효 토큰 목록 */
private array $invalidTokens = [];
/**
* 응답 추가
*/
public function addResponse(string $token, FcmResponse $response): void
{
$this->responses[$token] = $response;
if ($response->success) {
$this->successCount++;
} else {
$this->failureCount++;
if ($response->isInvalidToken()) {
$this->invalidTokens[] = $token;
}
}
}
/**
* 전체 발송 수
*/
public function getTotal(): int
{
return count($this->responses);
}
/**
* 성공 수
*/
public function getSuccessCount(): int
{
return $this->successCount;
}
/**
* 실패 수
*/
public function getFailureCount(): int
{
return $this->failureCount;
}
/**
* 무효 토큰 목록 (비활성화 필요)
*
* @return array<string>
*/
public function getInvalidTokens(): array
{
return $this->invalidTokens;
}
/**
* 모든 응답
*
* @return array<string, FcmResponse>
*/
public function getResponses(): array
{
return $this->responses;
}
/**
* 특정 토큰의 응답
*/
public function getResponse(string $token): ?FcmResponse
{
return $this->responses[$token] ?? null;
}
/**
* 성공률 (%)
*/
public function getSuccessRate(): float
{
if ($this->getTotal() === 0) {
return 0.0;
}
return round(($this->successCount / $this->getTotal()) * 100, 2);
}
/**
* 요약 정보
*/
public function toSummary(): array
{
return [
'total' => $this->getTotal(),
'success' => $this->successCount,
'failure' => $this->failureCount,
'invalid_tokens' => count($this->invalidTokens),
'success_rate' => $this->getSuccessRate(),
];
}
}

View File

@@ -1,27 +0,0 @@
<?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

@@ -1,86 +0,0 @@
<?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
);
}
/**
* FCM 에러 코드 추출
*/
public function getErrorCode(): ?string
{
if ($this->success) {
return null;
}
return $this->rawResponse['error']['details'][0]['errorCode'] ?? null;
}
/**
* 토큰이 유효하지 않은지 확인 (삭제 필요)
*/
public function isInvalidToken(): bool
{
if ($this->success) {
return false;
}
// FCM에서 반환하는 무효 토큰 에러 코드들
$invalidTokenErrors = [
'UNREGISTERED',
'INVALID_ARGUMENT',
'NOT_FOUND',
];
return in_array($this->getErrorCode(), $invalidTokenErrors, true);
}
/**
* 배열로 변환
*/
public function toArray(): array
{
return [
'success' => $this->success,
'message_id' => $this->messageId,
'error' => $this->error,
'status_code' => $this->statusCode,
];
}
}

View File

@@ -1,273 +0,0 @@
<?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' => '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

@@ -0,0 +1,114 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* FCM 발송 API 서비스
*
* MNG에서 API 서버의 FCM 발송 엔드포인트를 호출합니다.
*/
class FcmApiService
{
private string $baseUrl;
private string $apiKey;
public function __construct()
{
$this->baseUrl = rtrim(config('services.api.base_url'), '/');
$this->apiKey = config('services.api.key');
}
/**
* FCM 푸시 발송
*
* @param array $data 발송 데이터
* @param int|null $senderId 발송자 ID (MNG 관리자 ID)
* @return array{success: bool, data?: array, message?: string}
*/
public function send(array $data, ?int $senderId = null): array
{
try {
$response = Http::timeout(30)
->withHeaders([
'X-API-KEY' => $this->apiKey,
'X-Sender-Id' => $senderId,
'Accept' => 'application/json',
])
->post("{$this->baseUrl}/api/v1/admin/fcm/send", $data);
if ($response->successful()) {
return [
'success' => true,
'data' => $response->json('data'),
'message' => $response->json('message'),
];
}
Log::warning('[FcmApiService] Send failed', [
'status' => $response->status(),
'response' => $response->json(),
]);
return [
'success' => false,
'message' => $response->json('message') ?? 'FCM 발송에 실패했습니다.',
];
} catch (\Exception $e) {
Log::error('[FcmApiService] Send exception', [
'exception' => $e->getMessage(),
]);
return [
'success' => false,
'message' => 'API 서버 연결에 실패했습니다: '.$e->getMessage(),
];
}
}
/**
* 대상 토큰 수 미리보기
*
* @param array $filters 필터 조건
* @return array{success: bool, count?: int, message?: string}
*/
public function previewCount(array $filters): array
{
try {
$response = Http::timeout(10)
->withHeaders([
'X-API-KEY' => $this->apiKey,
'Accept' => 'application/json',
])
->get("{$this->baseUrl}/api/v1/admin/fcm/preview-count", $filters);
if ($response->successful()) {
return [
'success' => true,
'count' => $response->json('data.count', 0),
];
}
return [
'success' => false,
'count' => 0,
'message' => $response->json('message') ?? '미리보기 조회에 실패했습니다.',
];
} catch (\Exception $e) {
Log::error('[FcmApiService] Preview count exception', [
'exception' => $e->getMessage(),
]);
return [
'success' => false,
'count' => 0,
'message' => 'API 서버 연결에 실패했습니다.',
];
}
}
}

View File

@@ -33,8 +33,15 @@
} }
async function bootstrap() { async function bootstrap() {
// Capacitor 네이티브 환경(ios, android)에서만 실행
const platform = window.Capacitor?.getPlatform?.();
if (platform !== 'ios' && platform !== 'android') {
console.log('[FCM] Not running in native app (platform:', platform || 'web', ')');
return;
}
if (!window.Capacitor?.Plugins?.PushNotifications) { if (!window.Capacitor?.Plugins?.PushNotifications) {
console.log('[FCM] Not running in Capacitor'); console.log('[FCM] PushNotifications plugin not available');
return; return;
} }

View File

@@ -126,6 +126,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<label class="block text-sm font-medium text-gray-700 mb-1">사운드 </label> <label class="block text-sm font-medium text-gray-700 mb-1">사운드 </label>
<input type="text" <input type="text"
name="sound_key" name="sound_key"
value="default"
placeholder="예: default, alarm" placeholder="예: default, alarm"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div> </div>