diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index bf44e33..87484f3 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use App\Services\PushNotificationService; class ClientService extends Service { @@ -132,7 +133,13 @@ public function store(array $data) $data['tenant_id'] = $tenantId; $data['is_active'] = $data['is_active'] ?? true; - return Client::create($data); + $client = Client::create($data); + + // 신규 거래처 등록 푸시 발송 + app(PushNotificationService::class) + ->notifyNewClient($client->id, $client->name, $tenantId); + + return $client; } /** diff --git a/app/Services/Fcm/FcmSender.php b/app/Services/Fcm/FcmSender.php index 683b021..852f576 100644 --- a/app/Services/Fcm/FcmSender.php +++ b/app/Services/Fcm/FcmSender.php @@ -109,7 +109,7 @@ private function buildMessage( 'ttl' => config('fcm.defaults.ttl', '86400s'), 'notification' => [ 'channel_id' => $channelId, - 'sound' => 'default', + 'sound' => $this->getSoundForChannel($channelId), ], ], 'apns' => [ @@ -270,4 +270,19 @@ private function logError(array $message, \Exception $e): void 'trace' => $e->getTraceAsString(), ]); } + + /** + * 채널 ID에 따른 사운드 파일명 반환 + */ + private function getSoundForChannel(string $channelId): string + { + return match ($channelId) { + 'push_payment' => 'push_payment', + 'push_sales_order' => 'push_sales_order', + 'push_purchase_order' => 'push_purchase_order', + 'push_contract' => 'push_contract', + 'push_urgent' => 'push_urgent', + default => 'push_default', + }; + } } diff --git a/app/Services/PushNotificationService.php b/app/Services/PushNotificationService.php index 2ca97db..cbd841f 100644 --- a/app/Services/PushNotificationService.php +++ b/app/Services/PushNotificationService.php @@ -3,333 +3,196 @@ namespace App\Services; use App\Models\PushDeviceToken; -use App\Models\PushNotificationSetting; use App\Services\Fcm\FcmSender; use Illuminate\Support\Facades\Log; +/** + * 비즈니스 이벤트 기반 푸시 알림 서비스 + */ class PushNotificationService extends Service { + public function __construct( + private readonly FcmSender $fcmSender + ) {} + /** - * FCM 토큰 등록/갱신 + * 비즈니스 이벤트에 따른 푸시 발송 + * + * @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 registerToken(array $data): PushDeviceToken - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); + public function sendByEvent( + string $event, + string $title, + string $body, + array $data = [], + ?int $tenantId = null, + ?int $userId = null + ): void { + $tenantId = $tenantId ?? $this->tenantIdOrNull(); - // 동일 토큰이 있으면 업데이트, 없으면 생성 - $token = PushDeviceToken::withoutGlobalScopes() - ->where('token', $data['token']) - ->first(); + if (! $tenantId) { + Log::warning('[PushNotificationService] No tenant_id provided'); - 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([ + 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, - '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('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('FCM token unregistered', [ - 'token_id' => $token->id, + 'event' => $event, ]); - return true; + return; } - 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; - } - - /** - * 특정 사용자에게 푸시 알림 전송 (FCM HTTP v1 API) - */ - public function sendToUser(int $userId, string $notificationType, array $notification): bool - { - $tenantId = $this->tenantIdOrNull() ?? 0; - - // 사용자 알림 설정 확인 - $setting = PushNotificationSetting::where('tenant_id', $tenantId) - ->forUser($userId) - ->ofType($notificationType) - ->first(); - - // 알림이 비활성화된 경우 전송 안함 - if ($setting && ! $setting->is_enabled) { - Log::info('Push notification skipped (disabled)', [ - 'user_id' => $userId, - 'type' => $notificationType, - ]); - - return false; - } - - // 사용자의 활성 토큰 조회 - $tokens = PushDeviceToken::withoutGlobalScopes() - ->forUser($userId) - ->active() - ->get(); - - if ($tokens->isEmpty()) { - Log::info('Push notification skipped (no tokens)', [ - 'user_id' => $userId, - ]); - - return false; - } - - // 알림음 결정 - $sound = $setting?->sound ?? $this->getDefaultSound($notificationType); - - $successCount = 0; - foreach ($tokens as $token) { - $result = $this->sendFcmMessage($token, $notification, $sound, $notificationType); - if ($result) { - $successCount++; - } - } - - return $successCount > 0; - } - - /** - * FCM 메시지 전송 (FCM HTTP v1 API) - */ - protected function sendFcmMessage( - PushDeviceToken $token, - array $notification, - string $sound, - string $notificationType - ): bool { - // FCM 설정이 없으면 로그만 기록 - if (empty(config('fcm.project_id'))) { - Log::info('FCM message skipped (not configured)', [ - 'token_id' => $token->id, - 'platform' => $token->platform, - 'title' => $notification['title'] ?? '', - 'body' => $notification['body'] ?? '', - 'type' => $notificationType, - ]); - - return true; // 설정이 없어도 실패로 처리하지 않음 - } - - // 알림 유형에 따른 채널 결정 - $channelId = $this->getChannelId($notificationType); - - // 추가 데이터 구성 - $data = array_merge($notification['data'] ?? [], [ - 'notification_type' => $notificationType, - 'sound' => $sound, - ]); + // 이벤트 타입을 data에 추가 + $data['event'] = $event; try { - $sender = new FcmSender; - $response = $sender->sendToToken( - $token->token, - $notification['title'] ?? '', - $notification['body'] ?? '', + $result = $this->fcmSender->sendToMany( + $tokens, + $title, + $body, $channelId, $data ); - // 유효하지 않은 토큰이면 비활성화 - if ($response->isInvalidToken()) { - $token->update(['is_active' => false]); - Log::warning('FCM token invalidated', [ - 'token_id' => $token->id, - 'error' => $response->error, - ]); - - return false; - } - - return $response->success; - - } catch (\Exception $e) { - Log::error('FCM send failed', [ - 'token_id' => $token->id, - 'error' => $e->getMessage(), + 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(), ]); - return false; + } catch (\Exception $e) { + Log::error('[PushNotificationService] Push failed', [ + 'tenant_id' => $tenantId, + 'event' => $event, + 'error' => $e->getMessage(), + ]); } } /** - * 알림 유형에 따른 채널 ID 결정 + * 신규 거래처 등록 알림 */ - protected function getChannelId(string $notificationType): string + public function notifyNewClient(int $clientId, string $clientName, ?int $tenantId = null): void { - // 긴급 알림 유형들 - $urgentTypes = [ - PushNotificationSetting::TYPE_APPROVAL, - PushNotificationSetting::TYPE_ORDER, - ]; - - return in_array($notificationType, $urgentTypes, true) - ? config('fcm.channels.urgent', 'push_urgent') - : config('fcm.channels.default', 'push_default'); + $this->sendByEvent( + 'new_client', + '신규 거래처 등록', + "새로운 거래처 '{$clientName}'이(가) 등록되었습니다.", + ['client_id' => $clientId, 'type' => 'client'], + $tenantId + ); } /** - * 기본 알림 설정 초기화 + * 결제 알림 */ - protected function initializeDefaultSettings(int $tenantId, int $userId): void + public function notifyPayment(int $paymentId, string $message, ?int $tenantId = null, ?int $userId = null): 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, - ] - ); - } + $this->sendByEvent( + 'payment', + '결제 알림', + $message, + ['payment_id' => $paymentId, 'type' => 'payment'], + $tenantId, + $userId + ); } /** - * 알림 유형별 기본 알림음 + * 수주 알림 */ - protected function getDefaultSound(string $type): string + public function notifySalesOrder(int $orderId, string $message, ?int $tenantId = null): void { - 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, + $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', }; } }