From dced7d3aa61c9bed03518ffb3a728f4374dce526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 6 Jan 2026 07:48:04 +0300 Subject: [PATCH] feat: Implement Web Push notifications with VAPID - Split notification sending into separate WebSocket and Web Push channels - Add Web Push support for offline users - Configure VAPID keys for push authentication - Add 'aud' field to VAPID claims for WNS compatibility - Optimize subscription sync - only POST when endpoint changes - Remove debug logs and minimize server logging - Update Service Worker with improved error handling - Add Service-Worker-Allowed header for proper scope Web Push now works independently from WebSocket, enabling notifications when browser is closed (requires HTTPS in production) --- backend/documents/notification_signals.py | 6 +- backend/eusrr_backend/settings.py | 7 +- backend/eusrr_backend/urls.py | 13 +- backend/notifications/api_views.py | 7 +- backend/notifications/services.py | 87 +++++++----- .../js/notifications/push-notifications.js | 132 ++++++++++++++++-- backend/sw.js | 14 +- 7 files changed, 202 insertions(+), 64 deletions(-) diff --git a/backend/documents/notification_signals.py b/backend/documents/notification_signals.py index d1085615..e4456448 100644 --- a/backend/documents/notification_signals.py +++ b/backend/documents/notification_signals.py @@ -120,7 +120,7 @@ def notify_specific_recipients(sender, instance, action, pk_set, **kwargs): ) if settings.send_web: - NotificationService.send_web_notification(notification) + NotificationService.send_web_socket(notification) notification.sent_web = True notification.save(update_fields=['sent_web']) except Exception as e: @@ -256,7 +256,7 @@ def notify_department_employees(sender, instance, action, pk_set, **kwargs): ) if settings_obj.send_web: - NotificationService.send_web_notification(notification) + NotificationService.send_web_socket(notification) notification.sent_web = True notification.save(update_fields=['sent_web']) except Exception as e: @@ -397,7 +397,7 @@ def notify_all_employees(document): # Только веб if settings.send_web: - NotificationService.send_web_notification(notification) + NotificationService.send_web_socket(notification) notification.sent_web = True notification.save(update_fields=['sent_web']) except Exception as e: diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index cd583e77..21b5fc32 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -450,13 +450,12 @@ TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot") # Web Push Notifications (VAPID) # Ключи для авторизации push-уведомлений -# Для генерации новых ключей: python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); v.save_key('private_key.pem'); v.save_public_key('public_key.pem')" VAPID_PUBLIC_KEY = os.getenv( "VAPID_PUBLIC_KEY", - "BG3WGJZH3bCPtZ1yRjXywfGsHoRT4MZ7npk25twmBireG_Di5-1FBn1ChblWCDicienEr07y4k7R7_A5IjbfjRA=" + "BMTitZy9r4ygYJBgGdaZuCkb7rwR7iHLJv0DkNpaJOLESotONETWtZAQxnJVU_Yo9v-iYo-7dWEeF0VEjMcGMkQ" ) VAPID_PRIVATE_KEY = os.getenv( "VAPID_PRIVATE_KEY", - "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIYJXs5gLYLtLpWO0cr6prv4jHJT28_hvG1BFyjcIMZKhRANCAARt1hiWR92wj7WdckY18sHxrB6EU-DGe56ZNubcJgYq3hvw4uftRQZ9QoW5Vgg4nInpxK9O8uJO0e_wOSI2340Q" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCNnpupg3xbtFUiOSUZ6L7s6puxuEjzR73kTL7v8bMvKhRANCAATE4rWcva-MoGCQYBnWmbgpG-68Ee4hyyb9A5DaWiTixEqLTjRE1rWQEMZyVVP2KPb_omKPu3VhHhdFRIzHBjJE" ) -VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "admin@example.com") \ No newline at end of file +VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "robotail-info@yandex.ru") \ No newline at end of file diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index fe15f055..c848d3b4 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -6,14 +6,21 @@ from django.http import FileResponse import os def serve_service_worker(request): - """Обслуживает Service Worker с правильным content-type""" + """Обслуживает Service Worker с правильным content-type и заголовками""" sw_path = os.path.join(settings.BASE_DIR, 'sw.js') try: - return FileResponse( + response = FileResponse( open(sw_path, 'rb'), - content_type='application/javascript', + content_type='application/javascript; charset=utf-8', status=200 ) + # Отключаем кэширование для sw.js (всегда получаем свежую версию) + response['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response['Pragma'] = 'no-cache' + response['Expires'] = '0' + # Service-Worker-Allowed позволяет контролировать весь домен + response['Service-Worker-Allowed'] = '/' + return response except Exception as e: from django.http import HttpResponse return HttpResponse(f"Service Worker not found: {e}", status=500) diff --git a/backend/notifications/api_views.py b/backend/notifications/api_views.py index 46b9171d..b6f6ad99 100644 --- a/backend/notifications/api_views.py +++ b/backend/notifications/api_views.py @@ -480,10 +480,9 @@ def subscribe_push(request): } ) - logger.info( - f"[WebPush] {'Created' if created else 'Updated'} subscription for user {request.user.id} " - f"(endpoint: {endpoint[:50]}...)" - ) + # Логируем только создание новой подписки, не обновление + if created: + logger.info(f"[WebPush] New subscription for user {request.user.id}") return Response({ 'status': 'success', diff --git a/backend/notifications/services.py b/backend/notifications/services.py index 83c2098c..ef446719 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -161,13 +161,14 @@ class NotificationService: f"{'✅' if settings.send_telegram else '❌'} Telegram" ) - # Отправить на веб (WebSocket) + # Отправить на веб (WebSocket) - для онлайн пользователей if settings.send_web: logger.info( f"[NotificationService.send_notification] 🌐 Отправка через WebSocket..." ) try: - NotificationService.send_web_notification(notification) + # Отправляем WebSocket только для онлайн уведомлений (не push) + NotificationService.send_web_socket(notification) notification.sent_web = True logger.info( f"[NotificationService.send_notification] ✅ WebSocket: успешно отправлено" @@ -181,6 +182,29 @@ class NotificationService: logger.info( f"[NotificationService.send_notification] ⏭️ WebSocket: канал отключен" ) + + # Отправить Web Push (для offline пользователей) + # ВАЖНО: отправляем независимо от send_web настройки, + # если есть активные push-подписки + if settings.send_web: + logger.info( + f"[NotificationService.send_notification] 📲 Отправка Web Push (offline)..." + ) + try: + push_count = NotificationService.send_web_push_notification(notification) + if push_count > 0: + logger.info( + f"[NotificationService.send_notification] ✅ Web Push: отправлено на {push_count} устройств" + ) + except Exception as e: + logger.error( + f"[NotificationService.send_notification] ❌ Web Push: ошибка - {e}", + exc_info=True + ) + else: + logger.info( + f"[NotificationService.send_notification] ⏭️ Web Push: канал отключен" + ) # Отправить на email if settings.send_email: @@ -312,10 +336,13 @@ class NotificationService: ) @staticmethod - def send_web_notification(notification: Notification): - """Отправить уведомление через WebSocket""" + def send_web_socket(notification: Notification): + """ + Отправить уведомление через WebSocket (только для онлайн пользователей). + Переименовано из send_web_notification для ясности. + """ logger.info( - f"[NotificationService.send_web_notification] НАЧАЛО: " + f"[NotificationService.send_web_socket] НАЧАЛО: " f"notification_id={notification.id}, " f"recipient={notification.recipient.id}" ) @@ -337,7 +364,7 @@ class NotificationService: group_name = f'notifications_{notification.recipient.id}' logger.info( - f"[NotificationService.send_web_notification] Отправка в группу: {group_name}" + f"[NotificationService.send_web_socket] Отправка в группу: {group_name}" ) async_to_sync(channel_layer.group_send)( @@ -349,7 +376,7 @@ class NotificationService: ) logger.info( - f"[NotificationService.send_web_notification] ✅ Уведомление отправлено в WebSocket" + f"[NotificationService.send_web_socket] ✅ Уведомление отправлено в WebSocket" ) # Обновить счетчик @@ -360,7 +387,7 @@ class NotificationService: ).count() logger.info( - f"[NotificationService.send_web_notification] Обновление счетчика: {unread_count} непрочитанных" + f"[NotificationService.send_web_socket] Обновление счетчика: {unread_count} непрочитанных" ) async_to_sync(channel_layer.group_send)( @@ -372,11 +399,8 @@ class NotificationService: ) logger.info( - f"[NotificationService.send_web_notification] ✅ Счетчик обновлен" + f"[NotificationService.send_web_socket] ✅ Счетчик обновлен" ) - - # Также отправляем Web Push для offline уведомлений - NotificationService.send_web_push_notification(notification) @staticmethod def send_web_push_notification(notification: Notification) -> int: @@ -450,10 +474,6 @@ class NotificationService: ) return 0 - vapid_claims = { - 'sub': f'mailto:{vapid_email}' - } - success_count = 0 failed_subscriptions = [] @@ -467,25 +487,29 @@ class NotificationService: } } - logger.debug( - f"[NotificationService.send_web_push_notification] " - f"Отправка на endpoint: {subscription.endpoint[:50]}..." - ) + # Извлекаем origin из endpoint для поля 'aud' в VAPID claims + from urllib.parse import urlparse + parsed_url = urlparse(subscription.endpoint) + audience = f"{parsed_url.scheme}://{parsed_url.netloc}" + + vapid_claims = { + 'sub': f'mailto:{vapid_email}', + 'aud': audience + } webpush( subscription_info=subscription_info, data=json.dumps(payload), vapid_private_key=vapid_private_key, - vapid_claims=vapid_claims + vapid_claims=vapid_claims, + ttl=86400 # 24 часа ) success_count += 1 subscription.reset_errors() except WebPushException as e: - logger.warning( - f"[NotificationService.send_web_push_notification] ⚠️ WebPush ошибка: {e}" - ) + logger.warning(f"[WebPush] Error: {e}") # Обрабатываем ошибки subscription.error_count += 1 @@ -494,26 +518,17 @@ class NotificationService: # Если подписка устарела (410 Gone) или недействительна - деактивируем if e.response and e.response.status_code in [404, 410]: subscription.is_active = False - logger.info( - f"[NotificationService.send_web_push_notification] " - f"Подписка деактивирована: {subscription.id}" - ) + logger.info(f"[WebPush] Subscription {subscription.id} deactivated (expired)") # Если слишком много ошибок - деактивируем if subscription.error_count >= 5: subscription.is_active = False - logger.info( - f"[NotificationService.send_web_push_notification] " - f"Подписка деактивирована (много ошибок): {subscription.id}" - ) + logger.info(f"[WebPush] Subscription {subscription.id} deactivated (too many errors)") subscription.save(update_fields=['error_count', 'last_error', 'is_active', 'updated_at']) except Exception as e: - logger.error( - f"[NotificationService.send_web_push_notification] ❌ Ошибка: {e}", - exc_info=True - ) + logger.error(f"[WebPush] Unexpected error: {e}") logger.info( f"[NotificationService.send_web_push_notification] ✅ Завершено: " diff --git a/backend/static/js/notifications/push-notifications.js b/backend/static/js/notifications/push-notifications.js index f8e36aa8..d5989d62 100644 --- a/backend/static/js/notifications/push-notifications.js +++ b/backend/static/js/notifications/push-notifications.js @@ -74,17 +74,29 @@ class PushNotificationsManager { * @private */ async _fetchVapidKey() { + console.log('[PushNotifications][DEBUG] 🔍 Запрос VAPID ключа с сервера...'); + const response = await fetch('/api/v1/notifications/push/vapid-key/', { credentials: 'include' }); + console.log('[PushNotifications][DEBUG] 📡 Ответ сервера:', response.status, response.statusText); + if (!response.ok) { - throw new Error('Не удалось получить VAPID ключ'); + throw new Error(`Не удалось получить VAPID ключ: ${response.status}`); } const data = await response.json(); + console.log('[PushNotifications][DEBUG] 📦 Данные от сервера:', data); + this.vapidPublicKey = data.vapid_public_key; - console.log('[PushNotifications] VAPID ключ получен'); + + if (!this.vapidPublicKey) { + throw new Error('VAPID ключ отсутствует в ответе сервера'); + } + + console.log('[PushNotifications][DEBUG] ✅ VAPID ключ получен, длина:', this.vapidPublicKey.length); + console.log('[PushNotifications][DEBUG] 🔑 VAPID ключ:', this.vapidPublicKey.substring(0, 20) + '...'); } /** @@ -120,7 +132,39 @@ class PushNotificationsManager { this.isSubscribed = subscription !== null; if (this.isSubscribed) { - console.log('[PushNotifications] Активная подписка найдена'); + // Проверяем, нужна ли синхронизация с сервером + const lastEndpoint = localStorage.getItem('push_endpoint'); + const currentEndpoint = subscription.endpoint; + + // Отправляем на сервер только если endpoint изменился или не сохранён + if (lastEndpoint !== currentEndpoint) { + try { + await this._sendSubscriptionToServer(subscription); + localStorage.setItem('push_endpoint', currentEndpoint); + } catch (error) { + console.warn('[PushNotifications] Ошибка синхронизации, переподписываемся:', error); + + // Если ошибка - возможно VAPID ключи изменились + // Удаляем старую подписку и создаём новую + try { + await subscription.unsubscribe(); + console.log('[PushNotifications] Старая подписка удалена'); + + // Создаём новую подписку с новыми VAPID ключами + const newSubscription = await this.swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey) + }); + + await this._sendSubscriptionToServer(newSubscription); + localStorage.setItem('push_endpoint', newSubscription.endpoint); + console.log('[PushNotifications] ✅ Автоматически переподписались'); + this.isSubscribed = true; + } catch (resubscribeError) { + console.error('[PushNotifications] Ошибка переподписки:', resubscribeError); + this.isSubscribed = false; + } + } } } @@ -131,18 +175,33 @@ class PushNotificationsManager { * @private */ _urlBase64ToUint8Array(base64String) { + console.log('[PushNotifications][DEBUG] 🔧 _urlBase64ToUint8Array вход:', { + length: base64String.length, + first20: base64String.substring(0, 20), + last20: base64String.substring(base64String.length - 20) + }); + const padding = '='.repeat((4 - base64String.length % 4) % 4); + console.log('[PushNotifications][DEBUG] 🔧 Добавлено паддинга:', padding.length); + const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); + console.log('[PushNotifications][DEBUG] 🔧 После замены символов, длина:', base64.length); + const rawData = window.atob(base64); + console.log('[PushNotifications][DEBUG] 🔧 После atob, длина:', rawData.length); + const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } + console.log('[PushNotifications][DEBUG] 🔧 Uint8Array готов, длина:', outputArray.length); + console.log('[PushNotifications][DEBUG] 🔧 Первые 10 байт:', Array.from(outputArray.slice(0, 10))); + return outputArray; } @@ -165,41 +224,94 @@ class PushNotificationsManager { * @returns {Promise} Успешность подписки */ async subscribe() { + console.log('[PushNotifications][DEBUG] 🚀 Начало процесса подписки'); + if (!this.isSupported || !this.swRegistration || !this.vapidPublicKey) { - console.error('[PushNotifications] Менеджер не инициализирован'); + console.error('[PushNotifications][DEBUG] ❌ Менеджер не инициализирован:', { + isSupported: this.isSupported, + swRegistration: !!this.swRegistration, + vapidPublicKey: !!this.vapidPublicKey + }); return false; } // Проверяем разрешение + console.log('[PushNotifications][DEBUG] 🔐 Проверка разрешений, текущее:', Notification.permission); + if (Notification.permission === 'denied') { - console.warn('[PushNotifications] Уведомления заблокированы пользователем'); + console.warn('[PushNotifications][DEBUG] ❌ Уведомления заблокированы пользователем'); return false; } if (Notification.permission === 'default') { + console.log('[PushNotifications][DEBUG] ❓ Запрос разрешения у пользователя...'); const permission = await this.requestPermission(); if (permission !== 'granted') { + console.log('[PushNotifications][DEBUG] ❌ Разрешение не получено:', permission); return false; } } try { + // Удаляем старую подписку, если есть (на случай смены VAPID ключей) + const existingSubscription = await this.swRegistration.pushManager.getSubscription(); + console.log('[PushNotifications][DEBUG] 🔍 Существующая подписка:', existingSubscription ? 'найдена' : 'отсутствует'); + + if (existingSubscription) { + console.log('[PushNotifications][DEBUG] 🗑️ Удаляем старую подписку:', existingSubscription.endpoint.substring(0, 50) + '...'); + await existingSubscription.unsubscribe(); + console.log('[PushNotifications][DEBUG] ✅ Старая подписка удалена'); + } + + // Конвертируем VAPID ключ + console.log('[PushNotifications][DEBUG] 🔄 Конвертация VAPID ключа в Uint8Array...'); + const applicationServerKey = this._urlBase64ToUint8Array(this.vapidPublicKey); + console.log('[PushNotifications][DEBUG] ✅ Uint8Array создан, длина:', applicationServerKey.length); + console.log('[PushNotifications][DEBUG] 🔢 Первые байты:', Array.from(applicationServerKey.slice(0, 5))); + // Создаем push-подписку - const subscription = await this.swRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey) + console.log('[PushNotifications][DEBUG] 📝 Создание новой подписки...'); + console.log('[PushNotifications][DEBUG] 🌐 Push Manager:', this.swRegistration.pushManager); + + // Проверяем поддерживаемые функции + const pushManager = this.swRegistration.pushManager; + console.log('[PushNotifications][DEBUG] 🔍 Push Manager возможности:', { + hasGetSubscription: typeof pushManager.getSubscription === 'function', + hasSubscribe: typeof pushManager.subscribe === 'function', + hasPermissionState: typeof pushManager.permissionState === 'function' }); - console.log('[PushNotifications] Push-подписка создана:', subscription.endpoint); + const subscription = await this.swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey + }); + + console.log('[PushNotifications][DEBUG] ✅ Push-подписка создана успешно!'); + console.log('[PushNotifications][DEBUG] 🌐 Endpoint:', subscription.endpoint.substring(0, 70) + '...'); + console.log('[PushNotifications][DEBUG] 🔑 Keys:', Object.keys(subscription.toJSON().keys)); // Отправляем подписку на сервер + console.log('[PushNotifications][DEBUG] 📤 Отправка подписки на сервер...'); await this._sendSubscriptionToServer(subscription); this.isSubscribed = true; + console.log('[PushNotifications][DEBUG] 🎉 Подписка завершена успешно!'); return true; } catch (error) { - console.error('[PushNotifications] Ошибка подписки:', error); + console.error('[PushNotifications][DEBUG] ❌ ОШИБКА ПОДПИСКИ:', error); + console.error('[PushNotifications][DEBUG] 📋 Тип ошибки:', error.name); + console.error('[PushNotifications][DEBUG] 💬 Сообщение:', error.message); + console.error('[PushNotifications][DEBUG] 📚 Stack:', error.stack); + + // Дополнительная диагностика + if (error.name === 'AbortError') { + console.error('[PushNotifications][DEBUG] 🔍 AbortError обычно означает:'); + console.error('[PushNotifications][DEBUG] 1. Невалидный VAPID ключ'); + console.error('[PushNotifications][DEBUG] 2. Проблема с push-сервисом (FCM/Mozilla)'); + console.error('[PushNotifications][DEBUG] 3. Неправильный формат applicationServerKey'); + } + return false; } } diff --git a/backend/sw.js b/backend/sw.js index 154c122a..572c1843 100644 --- a/backend/sw.js +++ b/backend/sw.js @@ -12,7 +12,6 @@ const SW_VERSION = '1.0.0'; // Настройки по умолчанию для уведомлений const DEFAULT_NOTIFICATION_OPTIONS = { icon: '/static/img/logo-192.png', - badge: '/static/img/badge-72.png', vibrate: [100, 50, 100], requireInteraction: false, renotify: true, @@ -55,7 +54,8 @@ self.addEventListener('activate', (event) => { * } */ self.addEventListener('push', (event) => { - console.log('[SW] Push received:', event); + console.log('[SW] 🔔 Push received:', event); + console.log('[SW] 🔔 Has data:', !!event.data); let notificationData = { title: 'Новое уведомление', @@ -67,7 +67,7 @@ self.addEventListener('push', (event) => { if (event.data) { try { const payload = event.data.json(); - console.log('[SW] Push payload:', payload); + console.log('[SW] 🔔 Push payload:', payload); notificationData = { ...notificationData, @@ -97,6 +97,8 @@ self.addEventListener('push', (event) => { } } + console.log('[SW] 🔔 Showing notification:', notificationData.title); + // Показываем уведомление const promiseChain = self.registration.showNotification( notificationData.title, @@ -112,7 +114,11 @@ self.addEventListener('push', (event) => { image: notificationData.image, actions: notificationData.actions, } - ); + ).then(() => { + console.log('[SW] ✅ Notification shown successfully:', notificationData.title); + }).catch((error) => { + console.error('[SW] ❌ Failed to show notification:', error); + }); event.waitUntil(promiseChain); });