tenantId(); $userId = $this->apiUserId(); // 동일 토큰이 있으면 업데이트, 없으면 생성 $token = PushDeviceToken::withoutGlobalScopes() ->where('token', $data['token']) ->first(); if ($token) { // 기존 토큰 업데이트 (다른 사용자의 토큰이면 이전 것은 비활성화) if ($token->user_id !== $userId || $token->tenant_id !== $tenantId) { $token->update([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'platform' => $data['platform'], 'device_name' => $data['device_name'] ?? null, 'app_version' => $data['app_version'] ?? null, 'is_active' => true, 'last_used_at' => now(), 'deleted_at' => null, ]); } else { $token->update([ 'platform' => $data['platform'], 'device_name' => $data['device_name'] ?? null, 'app_version' => $data['app_version'] ?? null, 'is_active' => true, 'last_used_at' => now(), ]); } } else { // 새 토큰 생성 $token = PushDeviceToken::create([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'token' => $data['token'], 'platform' => $data['platform'], 'device_name' => $data['device_name'] ?? null, 'app_version' => $data['app_version'] ?? null, 'is_active' => true, 'last_used_at' => now(), ]); } // 사용자 기본 알림 설정 초기화 (없는 경우) $this->initializeDefaultSettings($tenantId, $userId); Log::info('[PushNotificationService] FCM token registered', [ 'user_id' => $userId, 'platform' => $data['platform'], 'token_id' => $token->id, ]); return $token; } /** * FCM 토큰 비활성화 */ public function unregisterToken(string $tokenValue): bool { $token = PushDeviceToken::withoutGlobalScopes() ->where('token', $tokenValue) ->first(); if ($token) { $token->update(['is_active' => false]); Log::info('[PushNotificationService] FCM token unregistered', [ 'token_id' => $token->id, ]); return true; } return false; } /** * 사용자의 활성 토큰 목록 조회 */ public function getUserTokens(?int $userId = null): array { $userId = $userId ?? $this->apiUserId(); return PushDeviceToken::forUser($userId) ->active() ->get() ->toArray(); } // ============================================================ // 알림 설정 관리 // ============================================================ /** * 알림 설정 조회 */ public function getSettings(?int $userId = null): array { $tenantId = $this->tenantId(); $userId = $userId ?? $this->apiUserId(); $settings = PushNotificationSetting::where('tenant_id', $tenantId) ->forUser($userId) ->get() ->keyBy('notification_type'); // 모든 알림 유형에 대한 설정 반환 (없으면 기본값) $result = []; foreach (PushNotificationSetting::getAllTypes() as $type) { if ($settings->has($type)) { $result[$type] = $settings->get($type)->toArray(); } else { $result[$type] = [ 'notification_type' => $type, 'is_enabled' => true, 'sound' => $this->getDefaultSound($type), 'vibrate' => true, 'show_preview' => true, ]; } } return $result; } /** * 알림 설정 업데이트 */ public function updateSettings(array $settings): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $updated = []; foreach ($settings as $setting) { $record = PushNotificationSetting::updateOrCreate( [ 'tenant_id' => $tenantId, 'user_id' => $userId, 'notification_type' => $setting['notification_type'], ], [ 'is_enabled' => $setting['is_enabled'], 'sound' => $setting['sound'] ?? $this->getDefaultSound($setting['notification_type']), 'vibrate' => $setting['vibrate'] ?? true, 'show_preview' => $setting['show_preview'] ?? true, ] ); $updated[] = $record->toArray(); } return $updated; } /** * 기본 알림 설정 초기화 */ protected function initializeDefaultSettings(int $tenantId, int $userId): void { foreach (PushNotificationSetting::getAllTypes() as $type) { PushNotificationSetting::firstOrCreate( [ 'tenant_id' => $tenantId, 'user_id' => $userId, 'notification_type' => $type, ], [ 'is_enabled' => true, 'sound' => $this->getDefaultSound($type), 'vibrate' => true, 'show_preview' => true, ] ); } } /** * 알림 유형별 기본 알림음 */ protected function getDefaultSound(string $type): string { return match ($type) { PushNotificationSetting::TYPE_DEPOSIT => PushNotificationSetting::SOUND_DEPOSIT, PushNotificationSetting::TYPE_WITHDRAWAL => PushNotificationSetting::SOUND_WITHDRAWAL, PushNotificationSetting::TYPE_ORDER => PushNotificationSetting::SOUND_ORDER, PushNotificationSetting::TYPE_APPROVAL => PushNotificationSetting::SOUND_APPROVAL, default => PushNotificationSetting::SOUND_DEFAULT, }; } // ============================================================ // 비즈니스 이벤트 기반 푸시 발송 // ============================================================ /** * 비즈니스 이벤트에 따른 푸시 발송 * * @param string $event 이벤트 타입 (new_client, payment, sales_order 등) * @param string $title 알림 제목 * @param string $body 알림 내용 * @param array $data 추가 데이터 (딥링크, ID 등) * @param int|null $tenantId 특정 테넌트 (null이면 현재 컨텍스트) * @param int|null $userId 특정 사용자에게만 발송 (null이면 테넌트 전체) */ public function sendByEvent( string $event, string $title, string $body, array $data = [], ?int $tenantId = null, ?int $userId = null ): void { $tenantId = $tenantId ?? $this->tenantIdOrNull(); if (! $tenantId) { Log::warning('[PushNotificationService] No tenant_id provided'); return; } $channelId = $this->getChannelForEvent($event); // 해당 테넌트의 활성 토큰 조회 (global scope 무시) $query = PushDeviceToken::withoutGlobalScopes() ->forTenant($tenantId) ->active(); // 특정 사용자에게만 발송 if ($userId) { $query->forUser($userId); } $tokens = $query->pluck('token')->toArray(); if (empty($tokens)) { Log::info('[PushNotificationService] No active tokens found', [ 'tenant_id' => $tenantId, 'user_id' => $userId, 'event' => $event, ]); return; } // 이벤트 타입을 data에 추가 $data['event'] = $event; try { $result = $this->fcmSender->sendToMany( $tokens, $title, $body, $channelId, $data ); Log::info('[PushNotificationService] Push sent', [ 'tenant_id' => $tenantId, 'event' => $event, 'channel_id' => $channelId, 'token_count' => count($tokens), 'success_count' => $result->getSuccessCount(), 'failure_count' => $result->getFailureCount(), ]); } catch (\Exception $e) { Log::error('[PushNotificationService] Push failed', [ 'tenant_id' => $tenantId, 'event' => $event, 'error' => $e->getMessage(), ]); } } /** * 신규 거래처 등록 알림 */ public function notifyNewClient(int $clientId, string $clientName, ?int $tenantId = null): void { $this->sendByEvent( 'new_client', '신규 거래처 등록', "새로운 거래처 '{$clientName}'이(가) 등록되었습니다.", ['client_id' => $clientId, 'type' => 'client'], $tenantId ); } /** * 결제 알림 */ public function notifyPayment(int $paymentId, string $message, ?int $tenantId = null, ?int $userId = null): void { $this->sendByEvent( 'payment', '결제 알림', $message, ['payment_id' => $paymentId, 'type' => 'payment'], $tenantId, $userId ); } /** * 수주 알림 */ public function notifySalesOrder(int $orderId, string $message, ?int $tenantId = null): void { $this->sendByEvent( 'sales_order', '수주 알림', $message, ['order_id' => $orderId, 'type' => 'sales_order'], $tenantId ); } /** * 발주 알림 */ public function notifyPurchaseOrder(int $orderId, string $message, ?int $tenantId = null): void { $this->sendByEvent( 'purchase_order', '발주 알림', $message, ['order_id' => $orderId, 'type' => 'purchase_order'], $tenantId ); } /** * 계약 알림 */ public function notifyContract(int $contractId, string $message, ?int $tenantId = null): void { $this->sendByEvent( 'contract', '계약 알림', $message, ['contract_id' => $contractId, 'type' => 'contract'], $tenantId ); } /** * 일반 알림 */ public function notifyGeneral(string $title, string $body, array $data = [], ?int $tenantId = null, ?int $userId = null): void { $this->sendByEvent( 'general', $title, $body, $data, $tenantId, $userId ); } /** * 이벤트 타입에 따른 채널 ID 반환 */ private function getChannelForEvent(string $event): string { return match ($event) { 'new_client' => 'push_urgent', 'payment' => 'push_payment', 'sales_order' => 'push_sales_order', 'purchase_order' => 'push_purchase_order', 'contract' => 'push_contract', default => 'push_default', }; } }