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