From 75be618f988be692a911955c9c1bc18f50401ac9 Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 23 Dec 2025 12:45:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[fcm]=20Admin=20FCM=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20MNG=EC=97=90=EC=84=9C=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=A1=9C=20FCM=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminFcmController, AdminFcmService 추가 - FormRequest 검증 (AdminFcmSendRequest 등) - Swagger 문서 추가 (AdminFcmApi.php) - ApiKeyMiddleware: admin/fcm/* 화이트리스트 추가 - FCM 에러 메시지 i18n 추가 --- .../Api/V1/Admin/FcmController.php | 117 ++++ app/Http/Middleware/ApiKeyMiddleware.php | 13 +- app/Http/Requests/Admin/FcmHistoryRequest.php | 24 + .../Requests/Admin/FcmTokenListRequest.php | 25 + app/Http/Requests/Admin/SendFcmRequest.php | 37 ++ app/Models/FcmSendLog.php | 134 +++++ app/Services/AdminFcmService.php | 264 ++++++++ app/Swagger/v1/AdminFcmApi.php | 562 ++++++++++++++++++ lang/ko/error.php | 7 + routes/api.php | 11 + 10 files changed, 1192 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/FcmController.php create mode 100644 app/Http/Requests/Admin/FcmHistoryRequest.php create mode 100644 app/Http/Requests/Admin/FcmTokenListRequest.php create mode 100644 app/Http/Requests/Admin/SendFcmRequest.php create mode 100644 app/Models/FcmSendLog.php create mode 100644 app/Services/AdminFcmService.php create mode 100644 app/Swagger/v1/AdminFcmApi.php diff --git a/app/Http/Controllers/Api/V1/Admin/FcmController.php b/app/Http/Controllers/Api/V1/Admin/FcmController.php new file mode 100644 index 0000000..c7c0505 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/FcmController.php @@ -0,0 +1,117 @@ +header('X-Sender-Id'); + + $result = $this->service->send( + $request->validated(), + $senderId ? (int) $senderId : null + ); + + if (! $result['success']) { + return ApiResponse::error($result['message'], 422, $result); + } + + return $result['data']; + }, __('message.fcm.sent')); + } + + /** + * 대상 토큰 수 미리보기 + */ + public function previewCount(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $count = $this->service->previewCount($request->only([ + 'tenant_id', + 'user_id', + 'platform', + ])); + + return ['count' => $count]; + }); + } + + /** + * 토큰 목록 조회 + */ + public function tokens(FcmTokenListRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->getTokens( + $request->validated(), + $request->integer('per_page', 20) + ); + }); + } + + /** + * 토큰 통계 + */ + public function tokenStats(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->getTokenStats( + $request->integer('tenant_id') + ); + }); + } + + /** + * 토큰 상태 토글 + */ + public function toggleToken(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->toggleToken($id); + }, __('message.updated')); + } + + /** + * 토큰 삭제 + */ + public function deleteToken(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $this->service->deleteToken($id); + + return ['deleted' => true]; + }, __('message.deleted')); + } + + /** + * 발송 이력 조회 + */ + public function history(FcmHistoryRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->getHistory( + $request->validated(), + $request->integer('per_page', 20) + ); + }); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 920190a..3b4f110 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -122,13 +122,22 @@ public function handle(Request $request, Closure $next) 'api/v1/refresh', 'api/v1/debug-apikey', 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) - // 추가적으로 허용하고 싶은 라우트 + 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) ]; // 현재 라우트 확인 (경로 또는 이름) $currentRoute = $request->route()?->uri() ?? $request->path(); - if (! in_array($currentRoute, $allowWithoutAuth)) { + // 화이트리스트 패턴 매칭 (와일드카드 지원) + $isAllowedWithoutAuth = false; + foreach ($allowWithoutAuth as $pattern) { + if ($pattern === $currentRoute || fnmatch($pattern, $currentRoute)) { + $isAllowedWithoutAuth = true; + break; + } + } + + if (! $isAllowedWithoutAuth) { // 인증정보(api_user, tenant_id) 없으면 튕김 if (! app()->bound('api_user')) { throw new AuthenticationException('회원정보 정보 없음'); diff --git a/app/Http/Requests/Admin/FcmHistoryRequest.php b/app/Http/Requests/Admin/FcmHistoryRequest.php new file mode 100644 index 0000000..4118ba3 --- /dev/null +++ b/app/Http/Requests/Admin/FcmHistoryRequest.php @@ -0,0 +1,24 @@ + 'nullable|integer|exists:tenants,id', + 'status' => 'nullable|string|in:pending,sending,completed,failed', + 'from' => 'nullable|date', + 'to' => 'nullable|date|after_or_equal:from', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Admin/FcmTokenListRequest.php b/app/Http/Requests/Admin/FcmTokenListRequest.php new file mode 100644 index 0000000..b3ea5e8 --- /dev/null +++ b/app/Http/Requests/Admin/FcmTokenListRequest.php @@ -0,0 +1,25 @@ + 'nullable|integer|exists:tenants,id', + 'platform' => 'nullable|string|in:android,ios,web', + 'is_active' => 'nullable|boolean', + 'has_error' => 'nullable|boolean', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Admin/SendFcmRequest.php b/app/Http/Requests/Admin/SendFcmRequest.php new file mode 100644 index 0000000..a430ad6 --- /dev/null +++ b/app/Http/Requests/Admin/SendFcmRequest.php @@ -0,0 +1,37 @@ + 'required|string|max:255', + 'body' => 'required|string|max:1000', + 'tenant_id' => 'nullable|integer|exists:tenants,id', + 'user_id' => 'nullable|integer|exists:users,id', + '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', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => __('validation.required', ['attribute' => '제목']), + 'body.required' => __('validation.required', ['attribute' => '내용']), + 'platform.in' => __('validation.in', ['attribute' => '플랫폼']), + ]; + } +} \ No newline at end of file diff --git a/app/Models/FcmSendLog.php b/app/Models/FcmSendLog.php new file mode 100644 index 0000000..4b63695 --- /dev/null +++ b/app/Models/FcmSendLog.php @@ -0,0 +1,134 @@ + '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(), + ]); + } +} \ No newline at end of file diff --git a/app/Services/AdminFcmService.php b/app/Services/AdminFcmService.php new file mode 100644 index 0000000..54a3d51 --- /dev/null +++ b/app/Services/AdminFcmService.php @@ -0,0 +1,264 @@ +active(); + + if (! empty($data['tenant_id'])) { + $query->forTenant($data['tenant_id']); + } + + if (! empty($data['user_id'])) { + $query->forUser($data['user_id']); + } + + if (! empty($data['platform'])) { + $query->platform($data['platform']); + } + + $tokens = $query->pluck('token')->toArray(); + $tokenCount = count($tokens); + + if ($tokenCount === 0) { + return [ + 'success' => false, + 'message' => __('error.fcm.no_tokens'), + 'data' => null, + ]; + } + + // 발송 로그 생성 + $sendLog = FcmSendLog::create([ + 'tenant_id' => $data['tenant_id'] ?? null, + 'user_id' => $data['user_id'] ?? null, + 'sender_id' => $senderId, + 'title' => $data['title'], + 'body' => $data['body'], + 'channel_id' => $data['channel_id'] ?? 'push_default', + 'type' => $data['type'] ?? null, + 'platform' => $data['platform'] ?? null, + 'data' => array_filter([ + 'type' => $data['type'] ?? null, + 'url' => $data['url'] ?? null, + 'sound_key' => $data['sound_key'] ?? null, + ]), + 'status' => FcmSendLog::STATUS_SENDING, + ]); + + try { + // FCM 발송 + $fcmData = array_filter([ + 'type' => $data['type'] ?? null, + 'url' => $data['url'] ?? null, + 'sound_key' => $data['sound_key'] ?? null, + ]); + + $result = $this->fcmSender->sendToMany( + $tokens, + $data['title'], + $data['body'], + $data['channel_id'] ?? 'push_default', + $fcmData + ); + + // 무효 토큰 비활성화 + $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 [ + 'success' => true, + 'message' => __('message.fcm.sent'), + 'data' => $summary, + 'log_id' => $sendLog->id, + ]; + + } catch (\Exception $e) { + $sendLog->markAsFailed($e->getMessage()); + + return [ + 'success' => false, + 'message' => __('error.fcm.send_failed'), + 'error' => $e->getMessage(), + 'log_id' => $sendLog->id, + ]; + } + } + + /** + * 대상 토큰 수 미리보기 + */ + public function previewCount(array $filters): int + { + $query = PushDeviceToken::withoutGlobalScopes()->active(); + + if (! empty($filters['tenant_id'])) { + $query->forTenant($filters['tenant_id']); + } + + if (! empty($filters['user_id'])) { + $query->forUser($filters['user_id']); + } + + if (! empty($filters['platform'])) { + $query->platform($filters['platform']); + } + + return $query->count(); + } + + /** + * 토큰 목록 조회 (관리자용) + */ + public function getTokens(array $filters, int $perPage = 20): array + { + $query = PushDeviceToken::withoutGlobalScopes() + ->with(['user:id,name,email', 'tenant:id,company_name']); + + if (! empty($filters['tenant_id'])) { + $query->where('tenant_id', $filters['tenant_id']); + } + + if (! empty($filters['platform'])) { + $query->where('platform', $filters['platform']); + } + + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + if (! empty($filters['has_error'])) { + $query->hasError(); + } + + if (! empty($filters['search'])) { + $search = $filters['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') + ->paginate($perPage) + ->toArray(); + } + + /** + * 토큰 통계 + */ + public 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, + ]; + } + + /** + * 토큰 상태 토글 + */ + public function toggleToken(int $tokenId): PushDeviceToken + { + $token = PushDeviceToken::withoutGlobalScopes()->findOrFail($tokenId); + $token->update(['is_active' => ! $token->is_active]); + + return $token; + } + + /** + * 토큰 삭제 + */ + public function deleteToken(int $tokenId): bool + { + $token = PushDeviceToken::withoutGlobalScopes()->findOrFail($tokenId); + + return $token->delete(); + } + + /** + * 발송 이력 조회 + */ + public function getHistory(array $filters, int $perPage = 20): array + { + $query = FcmSendLog::with(['sender:id,name', 'tenant:id,company_name']); + + if (! empty($filters['tenant_id'])) { + $query->where('tenant_id', $filters['tenant_id']); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['from'])) { + $query->whereDate('created_at', '>=', $filters['from']); + } + + if (! empty($filters['to'])) { + $query->whereDate('created_at', '<=', $filters['to']); + } + + return $query->orderBy('created_at', 'desc') + ->paginate($perPage) + ->toArray(); + } +} \ No newline at end of file diff --git a/app/Swagger/v1/AdminFcmApi.php b/app/Swagger/v1/AdminFcmApi.php new file mode 100644 index 0000000..5dace36 --- /dev/null +++ b/app/Swagger/v1/AdminFcmApi.php @@ -0,0 +1,562 @@ + '테넌트 생성에 실패했습니다.', 'invalid_business_number' => '유효하지 않은 사업자등록번호입니다.', ], + + // FCM 푸시 알림 관련 + 'fcm' => [ + 'no_tokens' => '발송 대상 토큰이 없습니다.', + 'send_failed' => 'FCM 발송 중 오류가 발생했습니다.', + 'token_not_found' => 'FCM 토큰을 찾을 수 없습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index d0441ca..c2f2ab5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -647,6 +647,17 @@ Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록 }); + // Admin FCM API (MNG 관리자용 FCM 발송) - API Key 인증만 사용 + Route::prefix('admin/fcm')->group(function () { + Route::post('/send', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'send'])->name('v1.admin.fcm.send'); // FCM 발송 + Route::get('/preview-count', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'previewCount'])->name('v1.admin.fcm.preview'); // 대상 토큰 수 미리보기 + Route::get('/tokens', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'tokens'])->name('v1.admin.fcm.tokens'); // 토큰 목록 + Route::get('/tokens/stats', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'tokenStats'])->name('v1.admin.fcm.tokens.stats'); // 토큰 통계 + Route::patch('/tokens/{id}/toggle', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'toggleToken'])->name('v1.admin.fcm.tokens.toggle'); // 토큰 상태 토글 + Route::delete('/tokens/{id}', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'deleteToken'])->name('v1.admin.fcm.tokens.delete'); // 토큰 삭제 + Route::get('/history', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'history'])->name('v1.admin.fcm.history'); // 발송 이력 + }); + // 회원 프로필(테넌트 기준) Route::prefix('profiles')->group(function () { Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)