diff --git a/.env.example b/.env.example index a3659d89..eab80afd 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,11 @@ VITE_APP_NAME="${APP_NAME}" # Google Gemini API (SAM AI 음성 어시스턴트) GEMINI_API_KEY= GEMINI_PROJECT_ID= + +# FCM (Firebase Cloud Messaging) +FCM_PROJECT_ID= +FCM_SA_PATH=secrets/firebase-service-account.json +FCM_BATCH_CHUNK_SIZE=200 +FCM_BATCH_DELAY_MS=100 +FCM_LOGGING_ENABLED=true +FCM_LOG_CHANNEL=stack diff --git a/app/Http/Controllers/FcmController.php b/app/Http/Controllers/FcmController.php new file mode 100644 index 00000000..d0655dd5 --- /dev/null +++ b/app/Http/Controllers/FcmController.php @@ -0,0 +1,337 @@ +get(); + $tokens = $this->getTokensQuery($request)->paginate(20); + $stats = $this->getTokenStats($request->get('tenant_id')); + + return view('fcm.tokens', compact('tenants', 'tokens', 'stats')); + } + + /** + * FCM 토큰 목록 (HTMX partial) + */ + public function tokenList(Request $request): View + { + $tokens = $this->getTokensQuery($request)->paginate(20); + + return view('fcm.partials.token-table', compact('tokens')); + } + + /** + * 토큰 통계 (HTMX partial) + */ + public function tokenStats(Request $request): View + { + $stats = $this->getTokenStats($request->get('tenant_id')); + + return view('fcm.partials.token-stats', compact('stats')); + } + + /** + * 토큰 상태 변경 (활성/비활성) + */ + public function toggleToken(Request $request, int $id): View + { + $token = PushDeviceToken::withoutGlobalScopes()->findOrFail($id); + + $token->update([ + 'is_active' => ! $token->is_active, + ]); + + return view('fcm.partials.token-row', compact('token')); + } + + /** + * 토큰 삭제 + */ + public function deleteToken(int $id): Response + { + $token = PushDeviceToken::withoutGlobalScopes()->findOrFail($id); + $token->delete(); + + return response('', 200) + ->header('HX-Trigger', 'tokenDeleted'); + } + + /** + * FCM 테스트 발송 페이지 + */ + public function send(Request $request): View + { + $tenants = Tenant::orderBy('company_name')->get(); + + return view('fcm.send', compact('tenants')); + } + + /** + * FCM 발송 실행 (HTMX) + */ + public function sendPush(Request $request): View + { + $request->validate([ + 'title' => 'required|string|max:255', + 'body' => 'required|string|max:1000', + 'tenant_id' => 'nullable|integer|exists:tenants,id', + 'user_id' => 'nullable|integer', + 'platform' => 'nullable|string|in:android,ios,web', + 'channel_id' => 'nullable|string|max:50', + 'type' => 'nullable|string|max:50', + 'url' => 'nullable|string|max:500', + 'sound_key' => 'nullable|string|max:50', + ]); + + // 대상 토큰 조회 + $query = PushDeviceToken::withoutGlobalScopes()->active(); + + if ($tenantId = $request->get('tenant_id')) { + $query->forTenant($tenantId); + } + + if ($userId = $request->get('user_id')) { + $query->forUser($userId); + } + + 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'), + 'url' => $request->get('url'), + 'sound_key' => $request->get('sound_key'), + ]), + 'status' => FcmSendLog::STATUS_SENDING, + ]); + + 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()); + + return view('fcm.partials.send-result', [ + 'success' => false, + 'message' => '발송 중 오류 발생: '.$e->getMessage(), + ]); + } + } + + /** + * FCM 발송 이력 페이지 + */ + public function history(Request $request): View + { + $logs = $this->getHistoryQuery($request)->paginate(20); + + return view('fcm.history', compact('logs')); + } + + /** + * FCM 발송 이력 목록 (HTMX partial) + */ + public function historyList(Request $request): View + { + $logs = $this->getHistoryQuery($request)->paginate(20); + + return view('fcm.partials.history-table', compact('logs')); + } + + /** + * 대상 토큰 수 미리보기 (HTMX) + */ + public function previewCount(Request $request): View + { + $query = PushDeviceToken::withoutGlobalScopes()->active(); + + if ($tenantId = $request->get('tenant_id')) { + $query->forTenant($tenantId); + } + + if ($userId = $request->get('user_id')) { + $query->forUser($userId); + } + + if ($platform = $request->get('platform')) { + $query->platform($platform); + } + + $count = $query->count(); + + return view('fcm.partials.preview-count', compact('count')); + } + + /** + * 토큰 쿼리 빌더 + */ + private function getTokensQuery(Request $request) + { + $query = PushDeviceToken::withoutGlobalScopes() + ->with(['user:id,name,email', 'tenant:id,company_name']); + + if ($tenantId = $request->get('tenant_id')) { + $query->where('tenant_id', $tenantId); + } + + if ($platform = $request->get('platform')) { + $query->where('platform', $platform); + } + + if ($request->has('is_active') && $request->get('is_active') !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->boolean('has_error')) { + $query->hasError(); + } + + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('token', 'like', "%{$search}%") + ->orWhere('device_name', 'like', "%{$search}%") + ->orWhereHas('user', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + return $query->orderBy('created_at', 'desc'); + } + + /** + * 토큰 통계 가져오기 + */ + private function getTokenStats(?int $tenantId = null): array + { + $query = PushDeviceToken::withoutGlobalScopes(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $total = (clone $query)->count(); + $active = (clone $query)->where('is_active', true)->count(); + $inactive = (clone $query)->where('is_active', false)->count(); + $hasError = (clone $query)->whereNotNull('last_error')->count(); + + $byPlatform = (clone $query)->selectRaw('platform, count(*) as count') + ->groupBy('platform') + ->pluck('count', 'platform') + ->toArray(); + + return [ + 'total' => $total, + 'active' => $active, + 'inactive' => $inactive, + 'has_error' => $hasError, + 'by_platform' => $byPlatform, + ]; + } + + /** + * 발송 이력 쿼리 빌더 + */ + private function getHistoryQuery(Request $request) + { + $query = FcmSendLog::with(['sender:id,name', 'tenant:id,company_name']); + + if ($tenantId = $request->get('tenant_id')) { + $query->where('tenant_id', $tenantId); + } + + if ($status = $request->get('status')) { + $query->where('status', $status); + } + + if ($from = $request->get('from')) { + $query->whereDate('created_at', '>=', $from); + } + + if ($to = $request->get('to')) { + $query->whereDate('created_at', '<=', $to); + } + + return $query->orderBy('created_at', 'desc'); + } +} diff --git a/app/Models/DevTools/ApiDeprecation.php b/app/Models/DevTools/ApiDeprecation.php index 9cb9d10a..fd9ac677 100644 --- a/app/Models/DevTools/ApiDeprecation.php +++ b/app/Models/DevTools/ApiDeprecation.php @@ -43,8 +43,11 @@ class ApiDeprecation extends Model * 상태 상수 */ public const STATUS_CANDIDATE = 'candidate'; // 삭제 후보 + public const STATUS_SCHEDULED = 'scheduled'; // 삭제 예정 + public const STATUS_DEPRECATED = 'deprecated'; // 폐기됨 (사용 중단) + public const STATUS_REMOVED = 'removed'; // 완전 삭제 /** @@ -109,4 +112,4 @@ public function scopeActive($query) self::STATUS_DEPRECATED, ]); } -} \ No newline at end of file +} diff --git a/app/Models/FcmSendLog.php b/app/Models/FcmSendLog.php new file mode 100644 index 00000000..44f31f09 --- /dev/null +++ b/app/Models/FcmSendLog.php @@ -0,0 +1,135 @@ + 'array', + 'total_count' => 'integer', + 'success_count' => 'integer', + 'failure_count' => 'integer', + 'invalid_token_count' => 'integer', + 'success_rate' => 'decimal:2', + 'completed_at' => 'datetime', + ]; + + /** + * 발송자 (MNG 관리자) + */ + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_id'); + } + + /** + * 테넌트 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 대상 사용자 + */ + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Scope: 특정 발송자의 로그 + */ + public function scopeBySender($query, int $senderId) + { + return $query->where('sender_id', $senderId); + } + + /** + * Scope: 특정 상태 + */ + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * Scope: 최근 N일 + */ + public function scopeRecent($query, int $days = 7) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * 발송 시작 표시 + */ + public function markAsSending(): void + { + $this->update(['status' => self::STATUS_SENDING]); + } + + /** + * 발송 완료 표시 + */ + public function markAsCompleted(array $summary): void + { + $this->update([ + 'status' => self::STATUS_COMPLETED, + 'total_count' => $summary['total'], + 'success_count' => $summary['success'], + 'failure_count' => $summary['failure'], + 'invalid_token_count' => $summary['invalid_tokens'], + 'success_rate' => $summary['success_rate'], + 'completed_at' => now(), + ]); + } + + /** + * 발송 실패 표시 + */ + public function markAsFailed(string $errorMessage): void + { + $this->update([ + 'status' => self::STATUS_FAILED, + 'error_message' => $errorMessage, + 'completed_at' => now(), + ]); + } +} diff --git a/app/Models/PushDeviceToken.php b/app/Models/PushDeviceToken.php new file mode 100644 index 00000000..1e8e5202 --- /dev/null +++ b/app/Models/PushDeviceToken.php @@ -0,0 +1,125 @@ + 'boolean', + 'last_used_at' => 'datetime', + 'last_error_at' => 'datetime', + ]; + + /** + * 플랫폼 상수 + */ + public const PLATFORM_IOS = 'ios'; + + public const PLATFORM_ANDROID = 'android'; + + public const PLATFORM_WEB = 'web'; + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + /** + * Scope: 활성 토큰만 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: 플랫폼별 필터 + */ + public function scopePlatform($query, string $platform) + { + return $query->where('platform', $platform); + } + + /** + * Scope: 특정 사용자의 토큰 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope: 특정 테넌트의 토큰 (global scope 무시) + */ + public function scopeForTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + /** + * Scope: 에러가 있는 토큰 + */ + public function scopeHasError($query) + { + return $query->whereNotNull('last_error'); + } + + /** + * 에러 정보 기록 + */ + public function recordError(string $errorCode): void + { + $this->update([ + 'last_error' => $errorCode, + 'last_error_at' => now(), + ]); + } + + /** + * 토큰 비활성화 (에러와 함께) + */ + public function deactivate(?string $errorCode = null): void + { + $data = ['is_active' => false]; + + if ($errorCode) { + $data['last_error'] = $errorCode; + $data['last_error_at'] = now(); + } + + $this->update($data); + } +} diff --git a/app/Services/ApiExplorer/ApiUsageService.php b/app/Services/ApiExplorer/ApiUsageService.php index 7d840db3..4f4650a2 100644 --- a/app/Services/ApiExplorer/ApiUsageService.php +++ b/app/Services/ApiExplorer/ApiUsageService.php @@ -46,21 +46,21 @@ public function getApiUsageComparison(): array // 사용된 API 통계 $usageStats = $this->getUsageStats()->keyBy(function ($item) { - return $item->endpoint . '|' . $item->method; + return $item->endpoint.'|'.$item->method; }); // 폐기 후보 목록 $deprecations = ApiDeprecation::active() ->get() ->keyBy(function ($item) { - return $item->endpoint . '|' . $item->method; + return $item->endpoint.'|'.$item->method; }); $used = []; $unused = []; foreach ($allEndpoints as $endpoint) { - $key = $endpoint['path'] . '|' . $endpoint['method']; + $key = $endpoint['path'].'|'.$endpoint['method']; $stats = $usageStats->get($key); $deprecation = $deprecations->get($key); @@ -229,13 +229,13 @@ public function getStaleApis(int $days = 30): Collection ->where('created_at', '>=', $cutoffDate) ->distinct() ->get() - ->map(fn ($item) => $item->endpoint . '|' . $item->method) + ->map(fn ($item) => $item->endpoint.'|'.$item->method) ->toArray(); // 전체 사용 통계에서 최근 사용된 것 제외 return $this->getUsageStats() ->filter(function ($item) use ($recentlyUsed) { - $key = $item->endpoint . '|' . $item->method; + $key = $item->endpoint.'|'.$item->method; return ! in_array($key, $recentlyUsed); }); @@ -254,4 +254,4 @@ public function getDailyTrend(int $days = 30): Collection ->orderBy('date') ->get(); } -} \ No newline at end of file +} diff --git a/app/Services/Fcm/FcmBatchResult.php b/app/Services/Fcm/FcmBatchResult.php new file mode 100644 index 00000000..eb0e9e54 --- /dev/null +++ b/app/Services/Fcm/FcmBatchResult.php @@ -0,0 +1,112 @@ + */ + private array $responses = []; + + private int $successCount = 0; + + private int $failureCount = 0; + + /** @var array 무효 토큰 목록 */ + 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 + */ + public function getInvalidTokens(): array + { + return $this->invalidTokens; + } + + /** + * 모든 응답 + * + * @return array + */ + 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(), + ]; + } +} diff --git a/app/Services/Fcm/FcmException.php b/app/Services/Fcm/FcmException.php new file mode 100644 index 00000000..49396744 --- /dev/null +++ b/app/Services/Fcm/FcmException.php @@ -0,0 +1,27 @@ +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, + ]; + } +} diff --git a/app/Services/Fcm/FcmSender.php b/app/Services/Fcm/FcmSender.php new file mode 100644 index 00000000..683b021d --- /dev/null +++ b/app/Services/Fcm/FcmSender.php @@ -0,0 +1,273 @@ +buildMessage($token, $title, $body, $channelId, $data); + + return $this->send($message); + } + + /** + * 다건 발송 (토큰 배열) - 단순 루프 + * + * @param array $tokens + * @return array + */ + 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 $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(), + ]); + } +} diff --git a/composer.json b/composer.json index b40b15ef..dc971500 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "darkaonline/l5-swagger": "^9.0", + "google/auth": "^1.49", "laravel/framework": "^12.0", "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", diff --git a/composer.lock b/composer.lock index 22c9955e..13034492 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c2faf899d8caff5078bf8fea2f6b6691", + "content-hash": "58e14bcea16068801af821062d7f3612", "packages": [ { "name": "brick/math", @@ -666,6 +666,69 @@ ], "time": "2025-03-06T22:45:56+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", "version": "v1.4.0", @@ -737,6 +800,68 @@ ], "time": "2025-12-03T09:33:47+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", "version": "v1.1.3", diff --git a/config/fcm.php b/config/fcm.php new file mode 100644 index 00000000..4756e2dd --- /dev/null +++ b/config/fcm.php @@ -0,0 +1,78 @@ + 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시간 + ], + + /* + |-------------------------------------------------------------------------- + | Batch Settings + |-------------------------------------------------------------------------- + | + | 대량 발송 시 rate limit 관리 설정 + | + */ + 'batch' => [ + 'chunk_size' => env('FCM_BATCH_CHUNK_SIZE', 200), // 한 번에 처리할 토큰 수 + 'delay_ms' => env('FCM_BATCH_DELAY_MS', 100), // chunk 간 딜레이 (ms) + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + 'logging' => [ + 'enabled' => env('FCM_LOGGING_ENABLED', true), + 'channel' => env('FCM_LOG_CHANNEL', 'stack'), + ], +]; diff --git a/database/migrations/2025_12_18_231356_create_fcm_send_logs_table.php b/database/migrations/2025_12_18_231356_create_fcm_send_logs_table.php new file mode 100644 index 00000000..5eecf56d --- /dev/null +++ b/database/migrations/2025_12_18_231356_create_fcm_send_logs_table.php @@ -0,0 +1,49 @@ +id(); + $table->unsignedBigInteger('tenant_id')->nullable()->comment('테넌트 ID (전체 발송 시 null)'); + $table->unsignedBigInteger('user_id')->nullable()->comment('대상 사용자 ID (전체 발송 시 null)'); + $table->unsignedBigInteger('sender_id')->comment('발송자 (MNG 관리자) ID'); + $table->string('title')->comment('알림 제목'); + $table->text('body')->comment('알림 내용'); + $table->string('channel_id', 50)->default('push_default')->comment('알림 채널'); + $table->string('type', 50)->nullable()->comment('알림 타입'); + $table->string('platform', 20)->nullable()->comment('플랫폼 필터 (android, ios, web)'); + $table->json('data')->nullable()->comment('추가 데이터 (JSON)'); + $table->unsignedInteger('total_count')->default(0)->comment('총 발송 수'); + $table->unsignedInteger('success_count')->default(0)->comment('성공 수'); + $table->unsignedInteger('failure_count')->default(0)->comment('실패 수'); + $table->unsignedInteger('invalid_token_count')->default(0)->comment('무효 토큰 수'); + $table->decimal('success_rate', 5, 2)->default(0)->comment('성공률 (%)'); + $table->string('status', 20)->default('pending')->comment('상태 (pending, sending, completed, failed)'); + $table->text('error_message')->nullable()->comment('에러 메시지'); + $table->timestamp('completed_at')->nullable()->comment('완료 시간'); + $table->timestamps(); + + $table->index('tenant_id'); + $table->index('sender_id'); + $table->index('status'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fcm_send_logs'); + } +}; diff --git a/resources/views/fcm/history.blade.php b/resources/views/fcm/history.blade.php new file mode 100644 index 00000000..ae76abc5 --- /dev/null +++ b/resources/views/fcm/history.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('title', 'FCM 발송 이력') + +@section('content') +
+ +
+

FCM 발송 이력

+ +
+ + +
+
+
+ +
+ +
+ + +
+ + ~ + +
+ + +
+
+
+ + +
+
+ @include('fcm.partials.history-table', ['logs' => $logs]) +
+
+
+@endsection diff --git a/resources/views/fcm/partials/history-table.blade.php b/resources/views/fcm/partials/history-table.blade.php new file mode 100644 index 00000000..3330b470 --- /dev/null +++ b/resources/views/fcm/partials/history-table.blade.php @@ -0,0 +1,76 @@ +
+ + + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + + @empty + + + + @endforelse + +
ID발송자대상제목결과상태발송일시
{{ $log->id }}{{ $log->sender?->name ?? '-' }} + {{ $log->tenant?->company_name ?? '전체' }} + @if($log->platform) + ({{ $log->platform }}) + @endif + +
{{ $log->title }}
+
{{ $log->body }}
+
+
+ {{ $log->success_count }}/{{ $log->total_count }} + ({{ $log->success_rate }}%) +
+ @if($log->invalid_token_count > 0) +
무효: {{ $log->invalid_token_count }}
+ @endif +
+ + @switch($log->status) + @case('pending') 대기 @break + @case('sending') 발송 중 @break + @case('completed') 완료 @break + @case('failed') 실패 @break + @default {{ $log->status }} + @endswitch + + @if($log->error_message) +
{{ $log->error_message }}
+ @endif +
+ {{ $log->created_at?->format('Y-m-d H:i') ?? '-' }} +
+ 발송 이력이 없습니다. +
+ + @if($logs->hasPages()) +
+ {{ $logs->withQueryString()->links() }} +
+ @endif +
diff --git a/resources/views/fcm/partials/preview-count.blade.php b/resources/views/fcm/partials/preview-count.blade.php new file mode 100644 index 00000000..cc2bae12 --- /dev/null +++ b/resources/views/fcm/partials/preview-count.blade.php @@ -0,0 +1 @@ +{{ number_format($count) }}개 토큰 diff --git a/resources/views/fcm/partials/send-result.blade.php b/resources/views/fcm/partials/send-result.blade.php new file mode 100644 index 00000000..71bbcb3c --- /dev/null +++ b/resources/views/fcm/partials/send-result.blade.php @@ -0,0 +1,40 @@ +
+
+ @if($success) + + + + @else + + + + @endif + {{ $message }} +
+ + @if($success && isset($data)) +
+
+
전체
+
{{ $data['total'] }}
+
+
+
성공
+
{{ $data['success'] }}
+
+
+
실패
+
{{ $data['failure'] }}
+
+
+
성공률
+
{{ $data['success_rate'] }}%
+
+
+ @if($data['invalid_tokens'] > 0) +
+ 무효 토큰 {{ $data['invalid_tokens'] }}개가 비활성화되었습니다. +
+ @endif + @endif +
diff --git a/resources/views/fcm/partials/token-row.blade.php b/resources/views/fcm/partials/token-row.blade.php new file mode 100644 index 00000000..9b95bedd --- /dev/null +++ b/resources/views/fcm/partials/token-row.blade.php @@ -0,0 +1,50 @@ + + +
{{ $token->user?->name ?? '-' }}
+
{{ $token->user?->email ?? '-' }}
+ + {{ $token->tenant?->company_name ?? '-' }} + + + {{ $token->platform }} + + + {{ $token->device_name ?? '-' }} + +
+ {{ Str::limit($token->token, 30) }} +
+ + + @if($token->is_active) + 활성 + @else + 비활성 + @endif + @if($token->last_error) +
{{ $token->last_error }}
+ @endif + + + {{ $token->created_at?->format('Y-m-d H:i') ?? '-' }} + + + + + + diff --git a/resources/views/fcm/partials/token-stats.blade.php b/resources/views/fcm/partials/token-stats.blade.php new file mode 100644 index 00000000..c231b9a8 --- /dev/null +++ b/resources/views/fcm/partials/token-stats.blade.php @@ -0,0 +1,26 @@ +
+
+
전체 토큰
+
{{ $stats['total'] }}
+
+
+
활성
+
{{ $stats['active'] }}
+
+
+
비활성
+
{{ $stats['inactive'] }}
+
+
+
에러
+
{{ $stats['has_error'] }}
+
+
+
플랫폼
+
+ Android: {{ $stats['by_platform']['android'] ?? 0 }} + iOS: {{ $stats['by_platform']['ios'] ?? 0 }} + Web: {{ $stats['by_platform']['web'] ?? 0 }} +
+
+
diff --git a/resources/views/fcm/partials/token-table.blade.php b/resources/views/fcm/partials/token-table.blade.php new file mode 100644 index 00000000..2e3905af --- /dev/null +++ b/resources/views/fcm/partials/token-table.blade.php @@ -0,0 +1,33 @@ +
+ + + + + + + + + + + + + + + @forelse($tokens as $token) + @include('fcm.partials.token-row', ['token' => $token]) + @empty + + + + @endforelse + +
사용자테넌트플랫폼기기명토큰상태등록일작업
+ 등록된 토큰이 없습니다. +
+ + @if($tokens->hasPages()) +
+ {{ $tokens->withQueryString()->links() }} +
+ @endif +
diff --git a/resources/views/fcm/send.blade.php b/resources/views/fcm/send.blade.php new file mode 100644 index 00000000..01120a00 --- /dev/null +++ b/resources/views/fcm/send.blade.php @@ -0,0 +1,167 @@ +@extends('layouts.app') + +@section('title', 'FCM 테스트 발송') + +@section('content') +
+ +
+

FCM 테스트 발송

+ +
+ +
+ +
+

발송 설정

+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ 고급 설정 +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ 대상: + 계산 중... + +
+ +
+
+
+ + +
+

발송 결과

+
+
+ 발송 버튼을 클릭하면 결과가 여기에 표시됩니다. +
+
+
+
+
+@endsection diff --git a/resources/views/fcm/tokens.blade.php b/resources/views/fcm/tokens.blade.php new file mode 100644 index 00000000..755642c6 --- /dev/null +++ b/resources/views/fcm/tokens.blade.php @@ -0,0 +1,97 @@ +@extends('layouts.app') + +@section('title', 'FCM 토큰 관리') + +@section('content') +
+ +
+

FCM 토큰 관리

+ +
+ + +
+ @include('fcm.partials.token-stats', ['stats' => $stats]) +
+ + +
+
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+ @include('fcm.partials.token-table', ['tokens' => $tokens]) +
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 5c6cc1f0..ecd61eed 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\DepartmentController; use App\Http\Controllers\DevTools\ApiExplorerController; use App\Http\Controllers\DevTools\FlowTesterController; +use App\Http\Controllers\FcmController; use App\Http\Controllers\ItemFieldController; use App\Http\Controllers\Lab\AIController; use App\Http\Controllers\Lab\ManagementController; @@ -260,6 +261,29 @@ }); }); + /* + |-------------------------------------------------------------------------- + | FCM 관리 Routes + |-------------------------------------------------------------------------- + */ + Route::prefix('fcm')->name('fcm.')->group(function () { + // 토큰 관리 + Route::get('/tokens', [FcmController::class, 'tokens'])->name('tokens'); + Route::get('/tokens/list', [FcmController::class, 'tokenList'])->name('tokens.list'); + Route::get('/tokens/stats', [FcmController::class, 'tokenStats'])->name('tokens.stats'); + Route::post('/tokens/{id}/toggle', [FcmController::class, 'toggleToken'])->name('tokens.toggle'); + Route::delete('/tokens/{id}', [FcmController::class, 'deleteToken'])->name('tokens.delete'); + + // 테스트 발송 + Route::get('/send', [FcmController::class, 'send'])->name('send'); + Route::post('/send', [FcmController::class, 'sendPush'])->name('send.push'); + Route::get('/preview-count', [FcmController::class, 'previewCount'])->name('preview-count'); + + // 발송 이력 + Route::get('/history', [FcmController::class, 'history'])->name('history'); + Route::get('/history/list', [FcmController::class, 'historyList'])->name('history.list'); + }); + /* |-------------------------------------------------------------------------- | 개발 도구 Routes