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:
@@ -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'] ?? [],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
114
app/Services/FcmApiService.php
Normal file
114
app/Services/FcmApiService.php
Normal 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 서버 연결에 실패했습니다.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user