diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index d8c73a2..bba2298 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -11,3 +11,30 @@ VAULT_MASTER_KEY=change-this-to-a-random-string TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= + +# Optional - Offline Push Notifications (notification-service) +# FCM (Firebase Cloud Messaging) — international Android + iOS +# Firebase Console > Project Settings > Service Accounts > Generate new private key +FCM_PROJECT_ID= +FCM_CLIENT_EMAIL= +FCM_PRIVATE_KEY= + +# HMS (Huawei Mobile Services) — Huawei / Honor devices +# AppGallery Connect > Project Settings > General information +HMS_APP_ID= +HMS_APP_SECRET= + +# Xiaomi Push — MIUI / Redmi devices +# Xiaomi Open Platform > Push > AppSecret +MI_APP_SECRET= + +# OPPO Push — OPPO / OnePlus / Realme devices +# OPPO Open Platform > Push > App Key & Master Secret +OPPO_APP_KEY= +OPPO_MASTER_SECRET= + +# vivo Push — vivo devices +# vivo Developer Platform > Push > App ID / Key / Secret +VIVO_APP_ID= +VIVO_APP_KEY= +VIVO_APP_SECRET= diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 3b40d1d..d255a20 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -512,6 +512,23 @@ services: - DB_DATABASE=${POSTGRES_DB:-it0} - NOTIFICATION_SERVICE_PORT=3013 - JWT_SECRET=${JWT_SECRET:-dev-jwt-secret} + - REDIS_URL=redis://redis:6379 + # FCM (Firebase Cloud Messaging) — international Android + iOS + - FCM_PROJECT_ID=${FCM_PROJECT_ID:-} + - FCM_CLIENT_EMAIL=${FCM_CLIENT_EMAIL:-} + - FCM_PRIVATE_KEY=${FCM_PRIVATE_KEY:-} + # HMS (Huawei Mobile Services) — Huawei/Honor devices + - HMS_APP_ID=${HMS_APP_ID:-} + - HMS_APP_SECRET=${HMS_APP_SECRET:-} + # Xiaomi Push — MIUI/Redmi devices + - MI_APP_SECRET=${MI_APP_SECRET:-} + # OPPO Push — OPPO/OnePlus/Realme devices + - OPPO_APP_KEY=${OPPO_APP_KEY:-} + - OPPO_MASTER_SECRET=${OPPO_MASTER_SECRET:-} + # vivo Push — vivo devices + - VIVO_APP_ID=${VIVO_APP_ID:-} + - VIVO_APP_KEY=${VIVO_APP_KEY:-} + - VIVO_APP_SECRET=${VIVO_APP_SECRET:-} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3013/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] interval: 30s diff --git a/it0_app/android/app/build.gradle.kts b/it0_app/android/app/build.gradle.kts index 1e640ee..5b74ef3 100644 --- a/it0_app/android/app/build.gradle.kts +++ b/it0_app/android/app/build.gradle.kts @@ -7,6 +7,10 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + // Google Services (FCM) + id("com.google.gms.google-services") + // Huawei AppGallery Connect (HMS Push) + id("com.huawei.agconnect") } // ============================================ @@ -56,6 +60,18 @@ android { targetSdk = flutter.targetSdkVersion versionCode = autoVersionCode versionName = getAutoVersionName() + + // OEM push credentials injected as BuildConfig fields. + // Set these in local.properties or CI environment variables. + val localProps = Properties().also { p -> + rootProject.file("local.properties").takeIf { it.exists() }?.let { + p.load(FileInputStream(it)) + } + } + buildConfigField("String", "MI_APP_ID", "\"${localProps.getProperty("MI_APP_ID", "")}\"") + buildConfigField("String", "MI_APP_KEY", "\"${localProps.getProperty("MI_APP_KEY", "")}\"") + buildConfigField("String", "OPPO_APP_KEY", "\"${localProps.getProperty("OPPO_APP_KEY", "")}\"") + buildConfigField("String", "OPPO_APP_SECRET", "\"${localProps.getProperty("OPPO_APP_SECRET", "")}\"") } buildTypes { @@ -65,10 +81,21 @@ android { signingConfig = signingConfigs.getByName("debug") } } + + buildFeatures { + buildConfig = true + } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + + // ── OEM Push SDKs ────────────────────────────────────────────────────────── + // AARs placed in android/app/libs/ (downloaded from each OEM's developer portal). + // Xiaomi Push SDK: https://dev.mi.com/distribute/doc/details?pId=1479 + // OPPO HeytapPush SDK: https://open.oppomobile.com/new/developmentDoc/info?id=11212 + // vivo Push SDK: https://dev.vivo.com.cn/documentCenter/doc/332 + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) } flutter { diff --git a/it0_app/android/app/google-services.json b/it0_app/android/app/google-services.json new file mode 100644 index 0000000..0f18be8 --- /dev/null +++ b/it0_app/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "673907592399", + "project_id": "it0-iagent", + "storage_bucket": "it0-iagent.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:673907592399:android:43728c5c81378e633efbba", + "android_client_info": { + "package_name": "com.iagent.it0_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCfEqqlDL4ndfViHS8AX2zztwrsdUYWysw" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/it0_app/android/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar b/it0_app/android/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar new file mode 100644 index 0000000..00d45a4 Binary files /dev/null and b/it0_app/android/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar differ diff --git a/it0_app/android/app/libs/com.heytap.msp_V3.7.1.aar b/it0_app/android/app/libs/com.heytap.msp_V3.7.1.aar new file mode 100644 index 0000000..b887595 Binary files /dev/null and b/it0_app/android/app/libs/com.heytap.msp_V3.7.1.aar differ diff --git a/it0_app/android/app/libs/vpush_clientSdk_v4.1.3.0_513.aar b/it0_app/android/app/libs/vpush_clientSdk_v4.1.3.0_513.aar new file mode 100644 index 0000000..46c393f Binary files /dev/null and b/it0_app/android/app/libs/vpush_clientSdk_v4.1.3.0_513.aar differ diff --git a/it0_app/android/app/src/main/AndroidManifest.xml b/it0_app/android/app/src/main/AndroidManifest.xml index 2ca542c..b6118af 100644 --- a/it0_app/android/app/src/main/AndroidManifest.xml +++ b/it0_app/android/app/src/main/AndroidManifest.xml @@ -41,6 +41,47 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + when (call.method) { "installApk" -> { val apkPath = call.argument("apkPath") if (apkPath != null) { - try { - installApk(apkPath) - result.success(true) - } catch (e: Exception) { - result.error("INSTALL_FAILED", e.message, null) - } + try { installApk(apkPath); result.success(true) } + catch (e: Exception) { result.error("INSTALL_FAILED", e.message, null) } } else { result.error("INVALID_PATH", "APK path is null", null) } @@ -37,7 +52,7 @@ class MainActivity : FlutterActivity() { } } - // 应用市场检测通道 + // ── 应用市场检测通道 ──────────────────────────────────────────────────── MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { @@ -50,39 +65,75 @@ class MainActivity : FlutterActivity() { packageManager.getInstallerPackageName(packageName) } result.success(installer) - } catch (e: Exception) { - result.success(null) - } + } catch (e: Exception) { result.success(null) } } else -> result.notImplemented() } } + + // ── 小米推送通道 ──────────────────────────────────────────────────────── + val miChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MI_CHANNEL) + MiPushChannel.register(miChannel) + miChannel.setMethodCallHandler { call, result -> + when (call.method) { + "getToken" -> { + try { + MiPushClient.registerPush(this, MI_APP_ID, MI_APP_KEY) + result.success(MiPushChannel.getToken(this)) + } catch (e: Exception) { result.error("MI_PUSH_ERROR", e.message, null) } + } + else -> result.notImplemented() + } + } + + // ── OPPO 推送通道 ─────────────────────────────────────────────────────── + val oppoChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, OPPO_CHANNEL) + OppoPushService.register(oppoChannel) + oppoChannel.setMethodCallHandler { call, result -> + when (call.method) { + "getToken" -> { + try { + OppoPushService.init(this, OPPO_APP_KEY, OPPO_APP_SECRET) + result.success(OppoPushService.getToken()) + } catch (e: Exception) { result.error("OPPO_PUSH_ERROR", e.message, null) } + } + else -> result.notImplemented() + } + } + + // ── vivo 推送通道 ─────────────────────────────────────────────────────── + val vivoChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VIVO_CHANNEL) + VivoPushChannel.register(vivoChannel) + vivoChannel.setMethodCallHandler { call, result -> + when (call.method) { + "getToken" -> { + try { + val mgr = OpenClientPushManager.getInstance(this) + mgr.initialize() + result.success(VivoPushChannel.getToken(this)) + } catch (e: Exception) { result.error("VIVO_PUSH_ERROR", e.message, null) } + } + else -> result.notImplemented() + } + } } + // ── APK 安装辅助 ──────────────────────────────────────────────────────────── + private fun installApk(apkPath: String) { val apkFile = File(apkPath) - if (!apkFile.exists()) { - throw Exception("APK file not found: $apkPath") - } - - val intent = Intent(Intent.ACTION_VIEW) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + if (!apkFile.exists()) throw Exception("APK file not found: $apkPath") + val intent = Intent(Intent.ACTION_VIEW).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - FileProvider.getUriForFile( - this, - "${applicationContext.packageName}.fileprovider", - apkFile - ) + FileProvider.getUriForFile(this, "${applicationContext.packageName}.fileprovider", apkFile) } else { Uri.fromFile(apkFile) } intent.setDataAndType(apkUri, "application/vnd.android.package-archive") startActivity(intent) - - // 关闭当前应用,让系统完成安装 finishAffinity() } } diff --git a/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/MiPushReceiver.kt b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/MiPushReceiver.kt new file mode 100644 index 0000000..3923d7e --- /dev/null +++ b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/MiPushReceiver.kt @@ -0,0 +1,66 @@ +package com.iagent.it0_app.push + +import android.content.Context +import com.xiaomi.mipush.sdk.ErrorCode +import com.xiaomi.mipush.sdk.MiPushClient +import com.xiaomi.mipush.sdk.MiPushCommandMessage +import com.xiaomi.mipush.sdk.MiPushMessage +import com.xiaomi.mipush.sdk.PushMessageReceiver +import io.flutter.plugin.common.MethodChannel + +/** + * Xiaomi Push receiver. + * + * Forwards token and message events to Flutter via MethodChannel + * (channel name: "com.iagent.it0_app/mi_push"). + * + * Initialization (called from MainActivity.onCreate or Application): + * MiPushClient.registerPush(context, MI_APP_ID, MI_APP_KEY) + */ +class MiPushReceiver : PushMessageReceiver() { + + override fun onReceiveRegisterResult(context: Context, message: MiPushCommandMessage) { + if (message.command == MiPushClient.COMMAND_REGISTER + && message.resultCode == ErrorCode.SUCCESS.toLong() + ) { + val token = message.commandArguments?.getOrNull(0) ?: return + MiPushChannel.send("onTokenRefresh", token) + } + } + + override fun onReceivePassThroughMessage(context: Context, message: MiPushMessage) { + MiPushChannel.send( + "onMessageReceived", + mapOf("title" to (message.title ?: ""), "body" to (message.content ?: "")) + ) + } + + override fun onNotificationMessageClicked(context: Context, message: MiPushMessage) { + MiPushChannel.send("onNotificationTap", mapOf("title" to (message.title ?: ""))) + } + + override fun onNotificationMessageArrived(context: Context, message: MiPushMessage) { + // System shows notification automatically for notification-type messages. + } +} + +/** + * Singleton bridge between MiPushReceiver (BroadcastReceiver context) and + * the Flutter MethodChannel registered in MainActivity. + */ +object MiPushChannel { + private var channel: MethodChannel? = null + + fun register(channel: MethodChannel) { + this.channel = channel + } + + fun send(method: String, args: Any?) { + channel?.invokeMethod(method, args) + } + + /** Called from MainActivity to return the current regId. */ + fun getToken(context: Context): String? { + return MiPushClient.getRegId(context) + } +} diff --git a/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/OppoPushService.kt b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/OppoPushService.kt new file mode 100644 index 0000000..d9d5d7f --- /dev/null +++ b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/OppoPushService.kt @@ -0,0 +1,61 @@ +package com.iagent.it0_app.push + +import android.content.Context +import com.heytap.msp.push.HeytapPushManager +import com.heytap.msp.push.mode.ICallBackResultService +import io.flutter.plugin.common.MethodChannel + +/** + * OPPO / OnePlus / Realme push integration via HeytapPush SDK. + * + * Initialization (called from MainActivity.onCreate): + * HeytapPushManager.init(context, OPPO_APP_KEY, OPPO_APP_SECRET, true) + * HeytapPushManager.register(context, OPPO_APP_KEY, OPPO_APP_SECRET, OppoPushCallback) + */ +object OppoPushService { + private var channel: MethodChannel? = null + + fun register(channel: MethodChannel) { + this.channel = channel + } + + fun init(context: Context, appKey: String, appSecret: String) { + HeytapPushManager.init(context, appKey, appSecret, true) + HeytapPushManager.register(context, appKey, appSecret, OppoPushCallback) + } + + fun getToken(): String? = HeytapPushManager.getRegisterID() + + val callback = OppoPushCallback +} + +object OppoPushCallback : ICallBackResultService { + override fun onRegister(responseCode: Int, registerID: String?) { + if (responseCode == 0 && !registerID.isNullOrEmpty()) { + OppoPushService.channel?.invokeMethod("onTokenRefresh", registerID) + } + } + + override fun onUnRegister(responseCode: Int) {} + + override fun onGetPushStatus(responseCode: Int, status: Int) {} + + override fun onGetNotificationStatus(responseCode: Int, status: Int) {} + + override fun onSetPushTime(responseCode: Int, pushTime: String?) {} + + override fun onReceiveMessage(message: com.heytap.msp.push.mode.MessagePayload?) { + if (message == null) return + OppoPushService.channel?.invokeMethod( + "onMessageReceived", + mapOf( + "title" to (message.title ?: ""), + "body" to (message.body ?: "") + ) + ) + } + + override fun onNotificationTapped(message: com.heytap.msp.push.mode.MessagePayload?) { + OppoPushService.channel?.invokeMethod("onNotificationTap", null) + } +} diff --git a/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/VivoPushReceiver.kt b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/VivoPushReceiver.kt new file mode 100644 index 0000000..b6dab61 --- /dev/null +++ b/it0_app/android/app/src/main/kotlin/com/iagent/it0_app/push/VivoPushReceiver.kt @@ -0,0 +1,59 @@ +package com.iagent.it0_app.push + +import android.content.Context +import android.content.Intent +import com.vivo.push.sdk.OpenClientPushManager +import com.vivo.push.sdk.OpenIMInterface +import io.flutter.plugin.common.MethodChannel + +/** + * vivo Push receiver. + * + * Implements OpenIMInterface for push callbacks. + * Initialization (called from MainActivity.onCreate): + * OpenClientPushManager.getInstance(context).initialize() + * + * Note: vivo push manifest registration is handled by the SDK via + * + * declared in the SDK's manifest (merged automatically via AAR). + */ +class VivoPushReceiver : OpenIMInterface { + + override fun onReceiveRegId(context: Context?, regId: String?) { + if (!regId.isNullOrEmpty()) { + VivoPushChannel.send("onTokenRefresh", regId) + } + } + + override fun onReceivePassThroughMessage(context: Context?, message: com.vivo.push.model.UPSNotificationMessage?) { + if (message == null) return + VivoPushChannel.send( + "onMessageReceived", + mapOf("title" to (message.title ?: ""), "body" to (message.content ?: "")) + ) + } + + override fun onNotificationMessageClicked(context: Context?, intent: Intent?) { + VivoPushChannel.send("onNotificationTap", null) + } + + override fun onNotificationMessageArrived(context: Context?, message: com.vivo.push.model.UPSNotificationMessage?) { + // system tray handles display + } +} + +object VivoPushChannel { + private var channel: MethodChannel? = null + + fun register(channel: MethodChannel) { + this.channel = channel + } + + fun send(method: String, args: Any?) { + channel?.invokeMethod(method, args) + } + + fun getToken(context: Context): String? { + return OpenClientPushManager.getInstance(context).regId + } +} diff --git a/it0_app/android/build.gradle.kts b/it0_app/android/build.gradle.kts index dbee657..12ebc93 100644 --- a/it0_app/android/build.gradle.kts +++ b/it0_app/android/build.gradle.kts @@ -1,7 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + // Huawei AppGallery Maven + maven { url = uri("https://developer.huawei.com/repo/") } + } + dependencies { + classpath("com.google.gms:google-services:4.4.2") + classpath("com.huawei.agconnect:agcp:1.9.1.303") + } +} + allprojects { repositories { google() mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } } } diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart index f38c341..facc15d 100644 --- a/it0_app/lib/core/config/api_endpoints.dart +++ b/it0_app/lib/core/config/api_endpoints.dart @@ -58,6 +58,9 @@ class ApiEndpoints { static const String adminTheme = '$adminSettings/theme'; static const String adminNotifications = '$adminSettings/notifications'; + // Notifications + static const String notificationDeviceToken = '/api/v1/notifications/device-token'; + // App Update static const String appVersionCheck = '/api/app/version/check'; diff --git a/it0_app/lib/core/services/push_service.dart b/it0_app/lib/core/services/push_service.dart new file mode 100644 index 0000000..370c2d6 --- /dev/null +++ b/it0_app/lib/core/services/push_service.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:huawei_push/huawei_push.dart'; +import 'package:logger/logger.dart'; + +import '../config/api_endpoints.dart'; +import '../network/dio_client.dart'; + +/// Platforms that map to backend PushPlatform enum. +enum PushPlatform { fcm, hms, mi, oppo, vivo } + +/// Top-level handler for FCM background messages (must be a top-level function). +@pragma('vm:entry-point') +Future _fcmBackgroundHandler(RemoteMessage message) async { + // System tray handles background/terminated notification display automatically. +} + +/// PushService — detects device OEM, initialises the correct push SDK, +/// obtains a device token, and registers it with the backend. +/// +/// Lifecycle: +/// PushService().init(deviceId: androidId); // after login +/// PushService().unregister(); // on logout +/// +/// Navigation: +/// PushService.onNotificationTap.listen((_) => router.go('/notifications/inbox')); +class PushService { + static final PushService _instance = PushService._(); + factory PushService() => _instance; + PushService._(); + + final _log = Logger(); + final _localNotifications = FlutterLocalNotificationsPlugin(); + + static const _androidChannelId = 'it0_push_high'; + static const _androidChannelName = 'iAgent 通知'; + + // MethodChannels for OEM SDKs (registered in MainActivity.kt) + static const _miChannel = MethodChannel('com.iagent.it0_app/mi_push'); + static const _oppoChannel = MethodChannel('com.iagent.it0_app/oppo_push'); + static const _vivoChannel = MethodChannel('com.iagent.it0_app/vivo_push'); + + static final _tapController = StreamController.broadcast(); + + /// Emits whenever the user taps a push notification. + static Stream get onNotificationTap => _tapController.stream; + + PushPlatform? _platform; + String? _deviceId; + bool _initialized = false; + + // ── Public API ───────────────────────────────────────────────────────────── + + Future init({required String deviceId}) async { + if (_initialized) return; + _initialized = true; + _deviceId = deviceId; + + await _initLocalNotifications(); + + if (Platform.isAndroid) { + _platform = await _detectPlatform(); + _log.i('[Push] Detected platform: $_platform'); + switch (_platform!) { + case PushPlatform.hms: await _initHms(); + case PushPlatform.mi: await _initMi(); + case PushPlatform.oppo: await _initOppo(); + case PushPlatform.vivo: await _initVivo(); + case PushPlatform.fcm: await _initFcm(); + } + } else if (Platform.isIOS) { + _platform = PushPlatform.fcm; // FCM handles APNs on iOS + await _initFcm(); + } + } + + Future unregister() async { + if (_platform == null || _deviceId == null) return; + try { + await DioClient().dio.delete( + ApiEndpoints.notificationDeviceToken, + data: { + 'platform': _platform!.name.toUpperCase(), + 'deviceId': _deviceId, + }, + ); + } catch (e) { + _log.w('[Push] Unregister failed: $e'); + } + _initialized = false; + } + + // ── OEM Detection ────────────────────────────────────────────────────────── + + Future _detectPlatform() async { + final android = await DeviceInfoPlugin().androidInfo; + final brand = android.brand.toLowerCase(); + final mfr = android.manufacturer.toLowerCase(); + + if (brand.contains('huawei') || brand.contains('honor') || + mfr.contains('huawei') || mfr.contains('honor')) return PushPlatform.hms; + if (brand.contains('xiaomi') || brand.contains('redmi') || + brand.contains('poco') || mfr.contains('xiaomi')) return PushPlatform.mi; + if (brand.contains('oppo') || brand.contains('oneplus') || + brand.contains('realme') || mfr.contains('oppo')) return PushPlatform.oppo; + if (brand.contains('vivo') || mfr.contains('vivo')) return PushPlatform.vivo; + return PushPlatform.fcm; + } + + // ── FCM (international Android + all iOS) ───────────────────────────────── + + Future _initFcm() async { + await Firebase.initializeApp(); + FirebaseMessaging.onBackgroundMessage(_fcmBackgroundHandler); + + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission(alert: true, badge: true, sound: true); + + FirebaseMessaging.onMessage.listen((msg) => _showLocal( + title: msg.notification?.title ?? '', + body: msg.notification?.body ?? '', + )); + FirebaseMessaging.onMessageOpenedApp.listen((_) => _tapController.add(null)); + + final initial = await messaging.getInitialMessage(); + if (initial != null) _tapController.add(null); + + final token = await messaging.getToken(); + if (token != null) await _registerToken(PushPlatform.fcm, token); + messaging.onTokenRefresh.listen((t) => _registerToken(PushPlatform.fcm, t)); + } + + // ── HMS (Huawei / Honor) ────────────────────────────────────────────────── + // + // huawei_push 6.x API: + // HuaweiPush.getToken() — triggers async token delivery via onTokenEvent + // HuaweiPush.onTokenEvent — Stream where event.event == Event.ON_NEW_TOKEN + // HuaweiPush.onMessageReceivedEvent — Stream + + Future _initHms() async { + try { + await HuaweiPush.turnOnPush(); + + // Request token — delivered asynchronously via onTokenEvent + HuaweiPush.onTokenEvent.listen((tokenEvent) { + if (tokenEvent.event == Event.ON_NEW_TOKEN) { + _registerToken(PushPlatform.hms, tokenEvent.result); + } + }); + + // Trigger token request + HuaweiPush.getToken(''); + + // Foreground messages + HuaweiPush.onMessageReceivedEvent.listen((msg) { + _showLocal( + title: msg.notification?.title ?? '', + body: msg.notification?.body ?? '', + ); + }); + + // Background tap + HuaweiPush.onNotificationOpenedApp.listen((_) => _tapController.add(null)); + + } catch (e) { + // HMS init failed on this Huawei device — log and do nothing. + // Do NOT fall back to FCM: Huawei devices without GMS can't use FCM. + _log.e('[Push] HMS init failed: $e'); + } + } + + // ── Xiaomi (MethodChannel → Kotlin MiPushReceiver) ──────────────────────── + + Future _initMi() async { + try { + _miChannel.setMethodCallHandler((call) async { + if (call.method == 'onTokenRefresh') { + await _registerToken(PushPlatform.mi, call.arguments as String); + } else if (call.method == 'onMessageReceived') { + final args = Map.from(call.arguments as Map); + _showLocal(title: args['title'] ?? '', body: args['body'] ?? ''); + } else if (call.method == 'onNotificationTap') { + _tapController.add(null); + } + }); + + final token = await _miChannel.invokeMethod('getToken'); + if (token != null && token.isNotEmpty) { + await _registerToken(PushPlatform.mi, token); + } + } catch (e) { + _log.e('[Push] Xiaomi init failed: $e'); + } + } + + // ── OPPO (MethodChannel → Kotlin OppoPushService) ──────────────────────── + + Future _initOppo() async { + try { + _oppoChannel.setMethodCallHandler((call) async { + if (call.method == 'onTokenRefresh') { + await _registerToken(PushPlatform.oppo, call.arguments as String); + } else if (call.method == 'onMessageReceived') { + final args = Map.from(call.arguments as Map); + _showLocal(title: args['title'] ?? '', body: args['body'] ?? ''); + } else if (call.method == 'onNotificationTap') { + _tapController.add(null); + } + }); + + final token = await _oppoChannel.invokeMethod('getToken'); + if (token != null && token.isNotEmpty) { + await _registerToken(PushPlatform.oppo, token); + } + } catch (e) { + _log.e('[Push] OPPO init failed: $e'); + } + } + + // ── vivo (MethodChannel → Kotlin VivoPushReceiver) ──────────────────────── + + Future _initVivo() async { + try { + _vivoChannel.setMethodCallHandler((call) async { + if (call.method == 'onTokenRefresh') { + await _registerToken(PushPlatform.vivo, call.arguments as String); + } else if (call.method == 'onMessageReceived') { + final args = Map.from(call.arguments as Map); + _showLocal(title: args['title'] ?? '', body: args['body'] ?? ''); + } else if (call.method == 'onNotificationTap') { + _tapController.add(null); + } + }); + + final token = await _vivoChannel.invokeMethod('getToken'); + if (token != null && token.isNotEmpty) { + await _registerToken(PushPlatform.vivo, token); + } + } catch (e) { + _log.e('[Push] vivo init failed: $e'); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + Future _initLocalNotifications() async { + if (Platform.isAndroid) { + await _localNotifications + .resolvePlatformSpecificImplementation() + ?.createNotificationChannel(const AndroidNotificationChannel( + _androidChannelId, + _androidChannelName, + importance: Importance.high, + )); + } + + await _localNotifications.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ), + ), + onDidReceiveNotificationResponse: (_) => _tapController.add(null), + ); + } + + Future _registerToken(PushPlatform platform, String token) async { + _log.i('[Push] Registering token [$platform]'); + try { + await DioClient().dio.post( + ApiEndpoints.notificationDeviceToken, + data: { + 'platform': platform.name.toUpperCase(), + 'token': token, + 'deviceId': _deviceId, + }, + ); + } catch (e) { + _log.w('[Push] Token registration failed: $e'); + } + } + + void _showLocal({required String title, required String body}) { + _localNotifications.show( + DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF, + title, + body, + NotificationDetails( + android: const AndroidNotificationDetails( + _androidChannelId, + _androidChannelName, + importance: Importance.high, + priority: Priority.high, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + ); + } +} diff --git a/it0_app/pubspec.yaml b/it0_app/pubspec.yaml index aa6fc00..d929ad6 100644 --- a/it0_app/pubspec.yaml +++ b/it0_app/pubspec.yaml @@ -53,6 +53,11 @@ dependencies: flutter_local_notifications: ^18.0.1 socket_io_client: ^3.0.2 + # Push notifications (offline) + firebase_core: ^3.6.0 + firebase_messaging: ^15.1.3 + huawei_push: ^6.11.0+300 + # File paths path_provider: ^2.1.0 path: ^1.9.0 diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index 8c9728e..a08a302 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -237,6 +237,11 @@ services: paths: - /api/v1/notifications/segments strip_path: false + # Device push token registration (requires JWT, enforced below) + - name: notification-device-token-routes + paths: + - /api/v1/notifications/device-token + strip_path: false plugins: # ===== Global plugins (apply to ALL routes) ===== @@ -408,6 +413,13 @@ plugins: claims_to_verify: - exp + - name: jwt + route: notification-device-token-routes + config: + key_claim_name: kid + claims_to_verify: + - exp + # ===== Route-specific overrides ===== - name: rate-limiting route: agent-ws diff --git a/packages/services/notification-service/src/application/services/event-trigger.service.ts b/packages/services/notification-service/src/application/services/event-trigger.service.ts index f2e5329..c99661c 100644 --- a/packages/services/notification-service/src/application/services/event-trigger.service.ts +++ b/packages/services/notification-service/src/application/services/event-trigger.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnApplicationBootstrap, OnApplicationShutdown, Logger } from '@nestjs/common'; import Redis from 'ioredis'; import { NotificationRepository } from '../../infrastructure/repositories/notification.repository'; +import { OfflinePushService } from './offline-push.service'; /** * EventTriggerService — Redis Stream consumer for auto-triggered notifications. @@ -29,7 +30,10 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio 'events:alert.fired', ]; - constructor(private readonly notificationRepo: NotificationRepository) {} + constructor( + private readonly notificationRepo: NotificationRepository, + private readonly offlinePush: OfflinePushService, + ) {} onApplicationBootstrap() { const redisUrl = process.env.REDIS_URL || 'redis://redis:6379'; @@ -89,8 +93,8 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio this.logger.debug(`Processing event from ${stream}: tenantId=${event.tenantId}`); switch (stream) { - case 'events:billing.payment_failed': - await this.notificationRepo.create({ + case 'events:billing.payment_failed': { + const n1 = await this.notificationRepo.create({ title: '支付失败提醒', content: `您的账单支付失败,金额 ${event.payload?.amountFormatted ?? ''}。请及时处理,避免服务中断。`, type: 'BILLING', @@ -101,10 +105,12 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio isEnabled: true, channelKey: 'billing', }); + this.offlinePush.sendForNotification({ ...n1, tenantIds: [event.tenantId] }); break; + } - case 'events:billing.quota_warning': - await this.notificationRepo.create({ + case 'events:billing.quota_warning': { + const n2 = await this.notificationRepo.create({ title: '用量配额预警', content: `您的账户 Token 用量已达到 ${event.payload?.usagePercent ?? 80}%,请关注用量或升级套餐。`, type: 'BILLING', @@ -115,12 +121,12 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio isEnabled: true, channelKey: 'billing', }); + this.offlinePush.sendForNotification({ ...n2, tenantIds: [event.tenantId] }); break; + } case 'events:tenant.registered': { - // Welcome notification to new tenant — target ALL initially, - // but for welcome we use SPECIFIC_TENANTS - await this.notificationRepo.create({ + const n3 = await this.notificationRepo.create({ title: '欢迎使用 iAgent!', content: '感谢您注册 iAgent 平台!您现在可以部署 AI 智能体来管理您的服务器集群。如需帮助,请查看文档或联系支持团队。', type: 'ANNOUNCEMENT', @@ -130,13 +136,14 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio requiresForceRead: false, isEnabled: true, }); + this.offlinePush.sendForNotification({ ...n3, tenantIds: [event.tenantId] }); break; } case 'events:alert.fired': { const severity = event.payload?.severity ?? 'warning'; const priority = severity === 'critical' || severity === 'fatal' ? 'URGENT' : 'HIGH'; - await this.notificationRepo.create({ + const n4 = await this.notificationRepo.create({ title: `运维告警:${severity.toUpperCase()}`, content: event.payload?.message ?? '服务器异常告警,请及时处理。', type: 'SYSTEM', @@ -147,6 +154,7 @@ export class EventTriggerService implements OnApplicationBootstrap, OnApplicatio isEnabled: true, channelKey: 'ops', }); + this.offlinePush.sendForNotification({ ...n4, tenantIds: [event.tenantId] }); break; } diff --git a/packages/services/notification-service/src/application/services/offline-push.service.ts b/packages/services/notification-service/src/application/services/offline-push.service.ts new file mode 100644 index 0000000..c08ea74 --- /dev/null +++ b/packages/services/notification-service/src/application/services/offline-push.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DevicePushTokenRepository } from '../../infrastructure/repositories/device-push-token.repository'; +import { DevicePushToken } from '../../domain/entities/device-push-token.entity'; +import { PushProvider, PushMessage } from './push-providers/push-provider.interface'; +import { FcmProvider } from './push-providers/fcm.provider'; +import { HmsProvider } from './push-providers/hms.provider'; +import { XiaomiProvider } from './push-providers/xiaomi.provider'; +import { OppoProvider } from './push-providers/oppo.provider'; +import { VivoProvider } from './push-providers/vivo.provider'; + +export interface NotificationForPush { + id: string; + title: string; + content: string; + type: string; + targetType: string; + linkUrl?: string | null; + publishedAt?: Date | null; + tenantIds?: string[]; + userIds?: string[]; + targetTagIds?: string[] | null; + targetTagLogic?: 'ANY' | 'ALL'; + targetPlans?: string[] | null; + targetStatuses?: string[] | null; + targetSegment?: string | null; +} + +/** + * OfflinePushService — resolves device tokens for a notification and fans out + * push messages to all matching devices via the appropriate platform provider. + * + * Fire-and-forget: callers do NOT await — errors are logged internally. + * Invalid tokens returned by providers are automatically deleted from DB. + */ +@Injectable() +export class OfflinePushService { + private readonly logger = new Logger(OfflinePushService.name); + private readonly providers: Map; + + constructor( + private readonly tokenRepo: DevicePushTokenRepository, + private readonly fcm: FcmProvider, + private readonly hms: HmsProvider, + private readonly xiaomi: XiaomiProvider, + private readonly oppo: OppoProvider, + private readonly vivo: VivoProvider, + ) { + this.providers = new Map([ + ['FCM', fcm], + ['HMS', hms], + ['MI', xiaomi], + ['OPPO', oppo], + ['VIVO', vivo], + ]); + } + + sendForNotification(notification: NotificationForPush): void { + this._send(notification).catch((err) => { + this.logger.error(`Offline push failed for ${notification.id}: ${err.message}`); + }); + } + + private async _send(notification: NotificationForPush): Promise { + // Skip future-scheduled notifications + if (notification.publishedAt && notification.publishedAt > new Date()) return; + + const tokens = await this.resolveTokens(notification); + if (!tokens.length) return; + + // Push body: 500 chars is safe for all providers + const message: PushMessage = { + title: notification.title, + body: notification.content.slice(0, 500), + data: { + notificationId: notification.id, + type: notification.type, + ...(notification.linkUrl ? { linkUrl: notification.linkUrl } : {}), + }, + }; + + // Group by platform (token strings only) + const byPlatform = new Map(); + for (const t of tokens) { + if (!byPlatform.has(t.platform)) byPlatform.set(t.platform, []); + byPlatform.get(t.platform)!.push(t.token); + } + + // Fan-out to all platforms concurrently + await Promise.all( + Array.from(byPlatform.entries()).map(async ([platform, platformTokens]) => { + const provider = this.providers.get(platform); + if (!provider || !provider.isConfigured()) { + this.logger.debug(`Provider ${platform} not configured, skipping`); + return; + } + try { + const { invalidTokens } = await provider.sendBatch(platformTokens, message); + if (invalidTokens.length) { + await this.tokenRepo.deleteByTokens(invalidTokens); + this.logger.log(`Cleaned ${invalidTokens.length} invalid ${platform} tokens`); + } + } catch (err: any) { + this.logger.warn(`Provider ${platform} sendBatch threw: ${err.message}`); + } + }), + ); + + this.logger.log(`Push sent for notification ${notification.id} → ${tokens.length} devices`); + } + + private async resolveTokens(n: NotificationForPush): Promise { + switch (n.targetType) { + case 'ALL': return this.tokenRepo.findAll(); + case 'SPECIFIC_TENANTS': return n.tenantIds?.length ? this.tokenRepo.findByTenantIds(n.tenantIds) : []; + case 'SPECIFIC_USERS': return n.userIds?.length ? this.tokenRepo.findByUserIds(n.userIds) : []; + case 'BY_TENANT_TAG': return n.targetTagIds?.length ? this.tokenRepo.findByTags(n.targetTagIds, n.targetTagLogic ?? 'ANY') : []; + case 'BY_PLAN': return n.targetPlans?.length ? this.tokenRepo.findByPlans(n.targetPlans) : []; + case 'BY_TENANT_STATUS': return n.targetStatuses?.length ? this.tokenRepo.findByTenantStatuses(n.targetStatuses) : []; + case 'BY_SEGMENT': return n.targetSegment ? this.tokenRepo.findBySegment(n.targetSegment) : []; + default: return []; + } + } +} diff --git a/packages/services/notification-service/src/application/services/push-providers/fcm.provider.ts b/packages/services/notification-service/src/application/services/push-providers/fcm.provider.ts new file mode 100644 index 0000000..0496e01 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/fcm.provider.ts @@ -0,0 +1,112 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; +import { PushProvider, PushMessage, BatchSendResult } from './push-provider.interface'; + +/** + * Firebase Cloud Messaging v1 API provider. + * + * Required env vars: + * FCM_PROJECT_ID — Firebase project id + * FCM_CLIENT_EMAIL — service account email + * FCM_PRIVATE_KEY — service account private key (PEM, newlines as \n) + * + * FCM v1 API is single-send only. We use chunked concurrency (max 20 parallel) + * to avoid overwhelming the connection pool. + */ +@Injectable() +export class FcmProvider implements PushProvider { + readonly platform = 'FCM'; + private readonly logger = new Logger(FcmProvider.name); + private readonly MAX_CONCURRENT = 20; + + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + isConfigured(): boolean { + return !!(process.env.FCM_PROJECT_ID && process.env.FCM_CLIENT_EMAIL && process.env.FCM_PRIVATE_KEY); + } + + async sendBatch(tokens: string[], message: PushMessage): Promise { + const invalidTokens: string[] = []; + const accessToken = await this.getAccessToken(); + + // Process in chunks of MAX_CONCURRENT to limit parallelism + for (let i = 0; i < tokens.length; i += this.MAX_CONCURRENT) { + const chunk = tokens.slice(i, i + this.MAX_CONCURRENT); + const results = await Promise.all(chunk.map(t => this._send(t, message, accessToken))); + for (const r of results) { + if (r) invalidTokens.push(r); + } + } + + return { invalidTokens }; + } + + /** Returns token string if invalid, null on success, logs on transient error. */ + private async _send(token: string, message: PushMessage, accessToken: string): Promise { + const projectId = process.env.FCM_PROJECT_ID!; + try { + const res = await fetch( + `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: { + token, + notification: { title: message.title, body: message.body }, + data: message.data ?? {}, + android: { priority: 'high' }, + apns: { payload: { aps: { sound: 'default' } } }, + }, + }), + }, + ); + + if (res.ok) return null; + const text = await res.text(); + if (res.status === 404 || text.includes('UNREGISTERED') || text.includes('INVALID_ARGUMENT')) { + return token; // invalid token + } + this.logger.warn(`FCM transient error [${res.status}]: ${text.slice(0, 120)}`); + return null; + } catch (e: any) { + this.logger.warn(`FCM send exception: ${e.message}`); + return null; + } + } + + /** Get OAuth2 access token via service-account JWT, cache for ~55 min. */ + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt) return this.accessToken; + + const clientEmail = process.env.FCM_CLIENT_EMAIL!; + const privateKey = process.env.FCM_PRIVATE_KEY!.replace(/\\n/g, '\n'); + const now = Math.floor(Date.now() / 1000); + + const assertion = jwt.sign( + { iss: clientEmail, scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 }, + privateKey, + { algorithm: 'RS256' }, + ); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }), + }); + + if (!res.ok) throw new Error(`FCM OAuth failed: ${res.status} ${await res.text()}`); + const data: any = await res.json(); + this.accessToken = data.access_token; + this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000; + return this.accessToken!; + } +} diff --git a/packages/services/notification-service/src/application/services/push-providers/hms.provider.ts b/packages/services/notification-service/src/application/services/push-providers/hms.provider.ts new file mode 100644 index 0000000..fa5ac10 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/hms.provider.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushProvider, PushMessage, BatchSendResult } from './push-provider.interface'; + +/** + * Huawei Mobile Services (HMS) Push Kit provider. + * + * Required env vars: + * HMS_APP_ID — from AppGallery Connect + * HMS_APP_SECRET — client secret from AppGallery Connect + * + * HMS API natively accepts an array of tokens (up to 1000 per request). + */ +@Injectable() +export class HmsProvider implements PushProvider { + readonly platform = 'HMS'; + private readonly logger = new Logger(HmsProvider.name); + private readonly CHUNK_SIZE = 1000; + + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + isConfigured(): boolean { + return !!(process.env.HMS_APP_ID && process.env.HMS_APP_SECRET); + } + + async sendBatch(tokens: string[], message: PushMessage): Promise { + const invalidTokens: string[] = []; + const accessToken = await this.getAccessToken(); + const appId = process.env.HMS_APP_ID!; + + for (let i = 0; i < tokens.length; i += this.CHUNK_SIZE) { + const chunk = tokens.slice(i, i + this.CHUNK_SIZE); + try { + const res = await fetch( + `https://push-api.cloud.huawei.com/v1/${appId}/messages:send`, + { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: { + token: chunk, + notification: { title: message.title, body: message.body }, + data: JSON.stringify(message.data ?? {}), + android: { + urgency: 'HIGH', + notification: { + title: message.title, + body: message.body, + click_action: { type: 3 }, + }, + }, + }, + }), + }, + ); + + if (!res.ok) { + this.logger.warn(`HMS send HTTP error: ${res.status}`); + continue; + } + + const data: any = await res.json(); + // 80000000 = success + // 80100016 = invalid token — but HMS batch doesn't pinpoint which one; mark all as suspect + if (data.code === '80100016') { + invalidTokens.push(...chunk); + } else if (data.code !== '80000000') { + this.logger.warn(`HMS send error: code=${data.code} msg=${data.msg}`); + } + } catch (e: any) { + this.logger.warn(`HMS send exception: ${e.message}`); + } + } + + return { invalidTokens }; + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt) return this.accessToken; + + const res = await fetch('https://oauth-login.cloud.huawei.com/oauth2/v3/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.HMS_APP_ID!, + client_secret: process.env.HMS_APP_SECRET!, + }), + }); + + if (!res.ok) throw new Error(`HMS OAuth failed: ${res.status} ${await res.text()}`); + const data: any = await res.json(); + this.accessToken = data.access_token; + this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000; + return this.accessToken!; + } +} diff --git a/packages/services/notification-service/src/application/services/push-providers/oppo.provider.ts b/packages/services/notification-service/src/application/services/push-providers/oppo.provider.ts new file mode 100644 index 0000000..3943d27 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/oppo.provider.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { PushProvider, PushMessage, BatchSendResult } from './push-provider.interface'; + +/** + * OPPO Push server-side provider. + * + * Required env vars: + * OPPO_APP_KEY — from OPPO Push Platform + * OPPO_MASTER_SECRET — master secret + * + * OPPO unicast API is single-token only; we use chunked concurrency (max 10 parallel). + * Auth token refresh is guarded by a mutex to prevent concurrent races. + */ +@Injectable() +export class OppoProvider implements PushProvider { + readonly platform = 'OPPO'; + private readonly logger = new Logger(OppoProvider.name); + private readonly MAX_CONCURRENT = 10; + + private authToken: string | null = null; + private tokenExpiresAt = 0; + private refreshLock: Promise | null = null; + + isConfigured(): boolean { + return !!(process.env.OPPO_APP_KEY && process.env.OPPO_MASTER_SECRET); + } + + async sendBatch(tokens: string[], message: PushMessage): Promise { + const invalidTokens: string[] = []; + const authToken = await this.getAuthToken(); + + for (let i = 0; i < tokens.length; i += this.MAX_CONCURRENT) { + const chunk = tokens.slice(i, i + this.MAX_CONCURRENT); + const results = await Promise.all(chunk.map(t => this._sendOne(t, message, authToken))); + for (const r of results) if (r) invalidTokens.push(r); + } + + return { invalidTokens }; + } + + /** Returns token string if invalid, null otherwise. */ + private async _sendOne(token: string, message: PushMessage, authToken: string): Promise { + try { + const res = await fetch('https://api.push.oppomobile.com/server/v1/message/notification/unicast', { + method: 'POST', + headers: { auth_token: authToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + target_type: 2, + target_value: token, + notification: { + title: message.title, + content: message.body, + click_action_type: 0, + action_parameters: message.data ?? {}, + }, + }), + }); + + if (!res.ok) { this.logger.warn(`OPPO HTTP ${res.status}`); return null; } + const data: any = await res.json(); + if (data.code === 0) return null; + if (data.code === 11) return token; // unregistered + this.logger.warn(`OPPO error code=${data.code}`); + return null; + } catch (e: any) { + this.logger.warn(`OPPO exception: ${e.message}`); + return null; + } + } + + private async getAuthToken(): Promise { + if (this.authToken && Date.now() < this.tokenExpiresAt) return this.authToken; + if (this.refreshLock) return this.refreshLock; + this.refreshLock = this._doRefresh().finally(() => { this.refreshLock = null; }); + return this.refreshLock; + } + + private async _doRefresh(): Promise { + const appKey = process.env.OPPO_APP_KEY!; + const masterSecret = process.env.OPPO_MASTER_SECRET!; + const timestamp = Date.now().toString(); + const sign = crypto.createHash('sha256').update(appKey + timestamp + masterSecret).digest('hex'); + + const res = await fetch('https://api.push.oppomobile.com/server/v1/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ app_key: appKey, sign, timestamp }), + }); + + if (!res.ok) throw new Error(`OPPO auth failed: ${res.status}`); + const data: any = await res.json(); + if (data.code !== 0) throw new Error(`OPPO auth error: code=${data.code}`); + this.authToken = data.data.auth_token; + this.tokenExpiresAt = Date.now() + 23 * 60 * 60 * 1000; + return this.authToken!; + } +} diff --git a/packages/services/notification-service/src/application/services/push-providers/push-provider.interface.ts b/packages/services/notification-service/src/application/services/push-providers/push-provider.interface.ts new file mode 100644 index 0000000..65a6803 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/push-provider.interface.ts @@ -0,0 +1,21 @@ +export interface PushMessage { + title: string; + body: string; + data?: Record; +} + +export interface BatchSendResult { + /** Tokens that are invalid/unregistered — caller should delete these from DB. */ + invalidTokens: string[]; +} + +export interface PushProvider { + readonly platform: string; + isConfigured(): boolean; + /** + * Send to a batch of device tokens. + * Returns list of invalid tokens for the caller to clean up. + * Must NOT throw — handle errors internally. + */ + sendBatch(tokens: string[], message: PushMessage): Promise; +} diff --git a/packages/services/notification-service/src/application/services/push-providers/vivo.provider.ts b/packages/services/notification-service/src/application/services/push-providers/vivo.provider.ts new file mode 100644 index 0000000..df24de9 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/vivo.provider.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { PushProvider, PushMessage, BatchSendResult } from './push-provider.interface'; + +/** + * vivo Push server-side provider. + * + * Required env vars: + * VIVO_APP_ID — from vivo Push Platform + * VIVO_APP_KEY — app key + * VIVO_APP_SECRET — app secret + * + * Uses /message/pushToList batch endpoint (up to 1000 tokens per request). + * Auth token refresh is guarded by a mutex. + * Docs: https://dev.vivo.com.cn/documentCenter/doc/332 + */ +@Injectable() +export class VivoProvider implements PushProvider { + readonly platform = 'VIVO'; + private readonly logger = new Logger(VivoProvider.name); + private readonly CHUNK_SIZE = 1000; + + private authToken: string | null = null; + private tokenExpiresAt = 0; + private refreshLock: Promise | null = null; + + isConfigured(): boolean { + return !!(process.env.VIVO_APP_ID && process.env.VIVO_APP_KEY && process.env.VIVO_APP_SECRET); + } + + async sendBatch(tokens: string[], message: PushMessage): Promise { + const invalidTokens: string[] = []; + const authToken = await this.getAuthToken(); + + for (let i = 0; i < tokens.length; i += this.CHUNK_SIZE) { + const chunk = tokens.slice(i, i + this.CHUNK_SIZE); + try { + const res = await fetch('https://api-push.vivo.com.cn/message/pushToList', { + method: 'POST', + headers: { 'auth-token': authToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + regIds: chunk.join(','), + notifyType: 4, + title: message.title, + content: message.body, + skipType: 1, + extra: { clientCustomMap: JSON.stringify(message.data ?? {}) }, + }), + }); + + if (!res.ok) { this.logger.warn(`vivo HTTP ${res.status}`); continue; } + const data: any = await res.json(); + if (data.result !== 0) { + this.logger.warn(`vivo batch error: result=${data.result}`); + // result 10070 = all tokens invalid + if (data.result === 10070) invalidTokens.push(...chunk); + // invalidMap contains individual invalid tokens + if (data.invalidMap) { + const bad = Object.keys(data.invalidMap); + invalidTokens.push(...bad); + } + } + } catch (e: any) { + this.logger.warn(`vivo send exception: ${e.message}`); + } + } + + return { invalidTokens }; + } + + private async getAuthToken(): Promise { + if (this.authToken && Date.now() < this.tokenExpiresAt) return this.authToken; + if (this.refreshLock) return this.refreshLock; + this.refreshLock = this._doRefresh().finally(() => { this.refreshLock = null; }); + return this.refreshLock; + } + + private async _doRefresh(): Promise { + const appId = process.env.VIVO_APP_ID!; + const appKey = process.env.VIVO_APP_KEY!; + const appSecret = process.env.VIVO_APP_SECRET!; + const timestamp = Date.now().toString(); + const sign = crypto.createHash('md5') + .update(appId + appKey + timestamp + appSecret) + .digest('hex'); + + const res = await fetch('https://api-push.vivo.com.cn/api/v2/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId, appKey, sign, timestamp }), + }); + + if (!res.ok) throw new Error(`vivo auth failed: ${res.status}`); + const data: any = await res.json(); + if (data.result !== 0) throw new Error(`vivo auth error: result=${data.result}`); + this.authToken = data.authToken; + this.tokenExpiresAt = Date.now() + 23 * 60 * 60 * 1000; + return this.authToken!; + } +} diff --git a/packages/services/notification-service/src/application/services/push-providers/xiaomi.provider.ts b/packages/services/notification-service/src/application/services/push-providers/xiaomi.provider.ts new file mode 100644 index 0000000..cc6adc1 --- /dev/null +++ b/packages/services/notification-service/src/application/services/push-providers/xiaomi.provider.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushProvider, PushMessage, BatchSendResult } from './push-provider.interface'; + +/** + * Xiaomi Push (Mi Push) server-side provider. + * + * Required env vars: + * MI_APP_SECRET — AppSecret from Xiaomi Open Platform + * + * Uses the batch endpoint /v3/message/regids (up to 1000 tokens per request). + * Docs: https://dev.mi.com/distribute/doc/details?pId=1547 + */ +@Injectable() +export class XiaomiProvider implements PushProvider { + readonly platform = 'MI'; + private readonly logger = new Logger(XiaomiProvider.name); + private readonly CHUNK_SIZE = 1000; + + isConfigured(): boolean { + return !!process.env.MI_APP_SECRET; + } + + async sendBatch(tokens: string[], message: PushMessage): Promise { + const invalidTokens: string[] = []; + const appSecret = process.env.MI_APP_SECRET!; + + for (let i = 0; i < tokens.length; i += this.CHUNK_SIZE) { + const chunk = tokens.slice(i, i + this.CHUNK_SIZE); + try { + const params = new URLSearchParams({ + registration_id: chunk.join(','), + title: message.title, + description: message.body, + payload: JSON.stringify(message.data ?? {}), + notify_type: '1', + notify_id: String(Math.floor(Date.now() / 1000) % 2147483647), + }); + + const res = await fetch('https://api.xmpush.xiaomi.com/v3/message/regids', { + method: 'POST', + headers: { + Authorization: `key=${appSecret}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + }); + + if (!res.ok) { + this.logger.warn(`Xiaomi batch HTTP error: ${res.status}`); + continue; + } + + const data: any = await res.json(); + if (data.code !== 0) { + this.logger.warn(`Xiaomi batch error: code=${data.code} reason=${data.reason}`); + // code 1 = invalid registration ids in batch + if (data.code === 1 && data.data?.bad_regids) { + const bad: string[] = data.data.bad_regids.split(',').map((s: string) => s.trim()); + invalidTokens.push(...bad.filter(Boolean)); + } + } + } catch (e: any) { + this.logger.warn(`Xiaomi send exception: ${e.message}`); + } + } + + return { invalidTokens }; + } +} diff --git a/packages/services/notification-service/src/domain/entities/device-push-token.entity.ts b/packages/services/notification-service/src/domain/entities/device-push-token.entity.ts new file mode 100644 index 0000000..be23119 --- /dev/null +++ b/packages/services/notification-service/src/domain/entities/device-push-token.entity.ts @@ -0,0 +1,13 @@ +export type PushPlatform = 'FCM' | 'HMS' | 'MI' | 'OPPO' | 'VIVO'; + +export interface DevicePushToken { + id: string; + userId: string; + tenantId: string; + platform: PushPlatform; + token: string; + deviceId: string; + appVersion?: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/services/notification-service/src/infrastructure/repositories/device-push-token.repository.ts b/packages/services/notification-service/src/infrastructure/repositories/device-push-token.repository.ts new file mode 100644 index 0000000..01ddf82 --- /dev/null +++ b/packages/services/notification-service/src/infrastructure/repositories/device-push-token.repository.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { DevicePushToken, PushPlatform } from '../../domain/entities/device-push-token.entity'; + +@Injectable() +export class DevicePushTokenRepository { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + /** Upsert a device token. Called when app starts / token refreshes. */ + async upsert(dto: { + userId: string; + tenantId: string; + platform: PushPlatform; + token: string; + deviceId: string; + appVersion?: string; + }): Promise { + await this.ds.query( + `INSERT INTO public.device_push_tokens + (user_id, tenant_id, platform, token, device_id, app_version, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id, platform, device_id) DO UPDATE SET + token = EXCLUDED.token, + tenant_id = EXCLUDED.tenant_id, + app_version = EXCLUDED.app_version, + updated_at = NOW()`, + [dto.userId, dto.tenantId, dto.platform, dto.token, dto.deviceId, dto.appVersion ?? null], + ); + } + + /** Remove a device token. Called on logout or when push permission revoked. */ + async delete(userId: string, platform: PushPlatform, deviceId: string): Promise { + await this.ds.query( + `DELETE FROM public.device_push_tokens WHERE user_id = $1 AND platform = $2 AND device_id = $3`, + [userId, platform, deviceId], + ); + } + + /** Remove all tokens for a user (e.g., account deletion). */ + async deleteAllForUser(userId: string): Promise { + await this.ds.query(`DELETE FROM public.device_push_tokens WHERE user_id = $1`, [userId]); + } + + /** Remove tokens by raw token value — used to clean up invalid/expired tokens reported by providers. */ + async deleteByTokens(tokens: string[]): Promise { + if (!tokens.length) return; + await this.ds.query(`DELETE FROM public.device_push_tokens WHERE token = ANY($1)`, [tokens]); + } + + // ── Targeting queries (used by OfflinePushService) ───────────────────────── + + async findAll(): Promise { + const rows = await this.ds.query(`SELECT * FROM public.device_push_tokens`); + return rows.map(this.mapRow); + } + + async findByUserIds(userIds: string[]): Promise { + if (!userIds.length) return []; + const rows = await this.ds.query( + `SELECT * FROM public.device_push_tokens WHERE user_id = ANY($1)`, + [userIds], + ); + return rows.map(this.mapRow); + } + + async findByTenantIds(tenantIds: string[]): Promise { + if (!tenantIds.length) return []; + const rows = await this.ds.query( + `SELECT * FROM public.device_push_tokens WHERE tenant_id = ANY($1)`, + [tenantIds], + ); + return rows.map(this.mapRow); + } + + async findByTenantStatuses(statuses: string[]): Promise { + if (!statuses.length) return []; + const rows = await this.ds.query( + `SELECT dpt.* + FROM public.device_push_tokens dpt + JOIN public.tenants t ON t.id = dpt.tenant_id + WHERE t.status = ANY($1)`, + [statuses], + ); + return rows.map(this.mapRow); + } + + async findByPlans(plans: string[]): Promise { + if (!plans.length) return []; + const rows = await this.ds.query( + `SELECT dpt.* + FROM public.device_push_tokens dpt + JOIN public.billing_subscriptions bs ON bs.tenant_id = dpt.tenant_id + WHERE bs.plan_id = ANY($1) AND bs.status = 'active'`, + [plans], + ); + return rows.map(this.mapRow); + } + + async findByTags(tagIds: string[], logic: 'ANY' | 'ALL'): Promise { + if (!tagIds.length) return []; + if (logic === 'ANY') { + const rows = await this.ds.query( + `SELECT DISTINCT dpt.* + FROM public.device_push_tokens dpt + JOIN public.tenant_tag_assignments ta ON ta.tenant_id = dpt.tenant_id + WHERE ta.tag_id = ANY($1)`, + [tagIds], + ); + return rows.map(this.mapRow); + } else { + // ALL: tenant must have every tag + const rows = await this.ds.query( + `SELECT dpt.* + FROM public.device_push_tokens dpt + WHERE ( + SELECT COUNT(*) FROM public.tenant_tag_assignments ta + WHERE ta.tenant_id = dpt.tenant_id AND ta.tag_id = ANY($1) + ) = $2`, + [tagIds, tagIds.length], + ); + return rows.map(this.mapRow); + } + } + + async findBySegment(segmentKey: string): Promise { + const rows = await this.ds.query( + `SELECT dpt.* + FROM public.device_push_tokens dpt + JOIN public.notification_segment_members sm ON sm.tenant_id = dpt.tenant_id + WHERE sm.segment_key = $1`, + [segmentKey], + ); + return rows.map(this.mapRow); + } + + private mapRow(r: any): DevicePushToken { + return { + id: r.id, + userId: r.user_id, + tenantId: r.tenant_id, + platform: r.platform, + token: r.token, + deviceId: r.device_id, + appVersion: r.app_version ?? undefined, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + } +} diff --git a/packages/services/notification-service/src/interfaces/rest/controllers/device-push-token.controller.ts b/packages/services/notification-service/src/interfaces/rest/controllers/device-push-token.controller.ts new file mode 100644 index 0000000..196f883 --- /dev/null +++ b/packages/services/notification-service/src/interfaces/rest/controllers/device-push-token.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Post, + Delete, + Body, + Headers, + UnauthorizedException, + BadRequestException, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { DevicePushTokenRepository } from '../../../infrastructure/repositories/device-push-token.repository'; +import { PushPlatform } from '../../../domain/entities/device-push-token.entity'; +import * as jwt from 'jsonwebtoken'; + +const VALID_PLATFORMS: PushPlatform[] = ['FCM', 'HMS', 'MI', 'OPPO', 'VIVO']; + +/** + * Device push token registration endpoints. + * Kong enforces JWT — we just extract userId/tenantId from the token. + * + * POST /api/v1/notifications/device-token — register / refresh token + * DELETE /api/v1/notifications/device-token — unregister (logout / permission revoked) + */ +@Controller('api/v1/notifications/device-token') +export class DevicePushTokenController { + constructor(private readonly repo: DevicePushTokenRepository) {} + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + async register( + @Headers('authorization') auth: string, + @Body() body: any, + ) { + const { userId, tenantId } = this.extractUser(auth); + + const platform: PushPlatform = (body.platform ?? '').toUpperCase(); + if (!VALID_PLATFORMS.includes(platform)) { + throw new BadRequestException(`Invalid platform: ${body.platform}. Must be one of ${VALID_PLATFORMS.join(', ')}`); + } + + const token: string = body.token; + const deviceId: string = body.deviceId; + if (!token || !deviceId) { + throw new BadRequestException('token and deviceId are required'); + } + + await this.repo.upsert({ + userId, + tenantId, + platform, + token, + deviceId, + appVersion: body.appVersion, + }); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + async unregister( + @Headers('authorization') auth: string, + @Body() body: any, + ) { + const { userId } = this.extractUser(auth); + + const platform: PushPlatform = (body.platform ?? '').toUpperCase(); + const deviceId: string = body.deviceId; + + if (!VALID_PLATFORMS.includes(platform) || !deviceId) return; + await this.repo.delete(userId, platform, deviceId); + } + + private extractUser(auth: string): { userId: string; tenantId: string } { + if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException(); + const token = auth.slice(7); + const secret = process.env.JWT_SECRET || 'dev-secret'; + try { + const payload = jwt.verify(token, secret) as any; + return { userId: payload.sub, tenantId: payload.tenantId }; + } catch { + throw new UnauthorizedException('Invalid JWT'); + } + } +} diff --git a/packages/services/notification-service/src/interfaces/rest/controllers/notification-admin.controller.ts b/packages/services/notification-service/src/interfaces/rest/controllers/notification-admin.controller.ts index 309fbaf..cdc12c9 100644 --- a/packages/services/notification-service/src/interfaces/rest/controllers/notification-admin.controller.ts +++ b/packages/services/notification-service/src/interfaces/rest/controllers/notification-admin.controller.ts @@ -16,6 +16,7 @@ import { HttpStatus, } from '@nestjs/common'; import { NotificationRepository, CreateNotificationDto } from '../../../infrastructure/repositories/notification.repository'; +import { OfflinePushService } from '../../../application/services/offline-push.service'; import * as jwt from 'jsonwebtoken'; /** @@ -24,7 +25,10 @@ import * as jwt from 'jsonwebtoken'; */ @Controller('api/v1/notifications/admin') export class NotificationAdminController { - constructor(private readonly repo: NotificationRepository) {} + constructor( + private readonly repo: NotificationRepository, + private readonly offlinePush: OfflinePushService, + ) {} /** POST /api/v1/notifications/admin — create notification */ @Post() @@ -33,7 +37,7 @@ export class NotificationAdminController { @Body() body: any, ) { const admin = this.requireAdmin(auth); - return this.repo.create({ + const notification = await this.repo.create({ title: body.title, content: body.content, type: body.type ?? 'ANNOUNCEMENT', @@ -55,6 +59,26 @@ export class NotificationAdminController { expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, createdBy: admin.userId, } as CreateNotificationDto); + + // Fire-and-forget offline push to device tokens + this.offlinePush.sendForNotification({ + id: notification.id, + title: notification.title, + content: notification.content, + type: notification.type, + targetType: notification.targetType, + linkUrl: notification.linkUrl, + publishedAt: notification.publishedAt, + tenantIds: body.tenantIds, + userIds: body.userIds, + targetTagIds: notification.targetTagIds, + targetTagLogic: notification.targetTagLogic, + targetPlans: notification.targetPlans, + targetStatuses: notification.targetStatuses, + targetSegment: notification.targetSegment, + }); + + return notification; } /** GET /api/v1/notifications/admin — list all notifications */ diff --git a/packages/services/notification-service/src/notification.module.ts b/packages/services/notification-service/src/notification.module.ts index 5568fa8..c4699ad 100644 --- a/packages/services/notification-service/src/notification.module.ts +++ b/packages/services/notification-service/src/notification.module.ts @@ -13,9 +13,16 @@ import { NotificationRepository } from './infrastructure/repositories/notificati import { ChannelRepository } from './infrastructure/repositories/channel.repository'; import { CampaignRepository } from './infrastructure/repositories/campaign.repository'; import { SegmentRepository } from './infrastructure/repositories/segment.repository'; +import { DevicePushTokenRepository } from './infrastructure/repositories/device-push-token.repository'; // Application Services import { EventTriggerService } from './application/services/event-trigger.service'; +import { OfflinePushService } from './application/services/offline-push.service'; +import { FcmProvider } from './application/services/push-providers/fcm.provider'; +import { HmsProvider } from './application/services/push-providers/hms.provider'; +import { XiaomiProvider } from './application/services/push-providers/xiaomi.provider'; +import { OppoProvider } from './application/services/push-providers/oppo.provider'; +import { VivoProvider } from './application/services/push-providers/vivo.provider'; // Controllers import { NotificationAdminController } from './interfaces/rest/controllers/notification-admin.controller'; @@ -23,6 +30,7 @@ import { NotificationUserController } from './interfaces/rest/controllers/notifi import { NotificationChannelController } from './interfaces/rest/controllers/notification-channel.controller'; import { CampaignAdminController } from './interfaces/rest/controllers/campaign-admin.controller'; import { SegmentAdminController } from './interfaces/rest/controllers/segment-admin.controller'; +import { DevicePushTokenController } from './interfaces/rest/controllers/device-push-token.controller'; @Module({ imports: [ @@ -36,12 +44,20 @@ import { SegmentAdminController } from './interfaces/rest/controllers/segment-ad NotificationChannelController, CampaignAdminController, SegmentAdminController, + DevicePushTokenController, ], providers: [ NotificationRepository, ChannelRepository, CampaignRepository, SegmentRepository, + DevicePushTokenRepository, + FcmProvider, + HmsProvider, + XiaomiProvider, + OppoProvider, + VivoProvider, + OfflinePushService, EventTriggerService, ], }) diff --git a/packages/shared/database/src/migrations/014-add-device-push-tokens.sql b/packages/shared/database/src/migrations/014-add-device-push-tokens.sql new file mode 100644 index 0000000..48ee201 --- /dev/null +++ b/packages/shared/database/src/migrations/014-add-device-push-tokens.sql @@ -0,0 +1,19 @@ +-- Migration 014: Device Push Tokens for offline push notifications +-- Supports FCM (international), HMS (Huawei), MI (Xiaomi), OPPO, VIVO + +CREATE TABLE IF NOT EXISTS public.device_push_tokens ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + tenant_id VARCHAR(255) NOT NULL, + platform VARCHAR(10) NOT NULL CHECK (platform IN ('FCM','HMS','MI','OPPO','VIVO')), + token TEXT NOT NULL, + device_id VARCHAR(255) NOT NULL, -- unique per device (Android ID or equivalent) + app_version VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- one row per (user, platform, device) + UNIQUE (user_id, platform, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_dpt_user_id ON public.device_push_tokens (user_id); +CREATE INDEX IF NOT EXISTS idx_dpt_tenant_id ON public.device_push_tokens (tenant_id);