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(); } }