feat(push): add offline push notification system (FCM + HMS + Mi + OPPO + vivo)

## Backend (notification-service)
- Add `device_push_tokens` table (migration 014) — stores per-user tokens per
  platform (FCM/HMS/MI/OPPO/VIVO) with UNIQUE constraint on (user_id, platform, device_id)
- Add `DevicePushTokenRepository` with upsert/delete/targeting queries
  (by userId, tenantId, plan, tags, segment)
- Add push provider interface with `sendBatch(tokens, message): BatchSendResult`
  returning `invalidTokens[]` for automatic DB cleanup
- Add FCM provider — OAuth2 via RS256 JWT, chunked concurrency (max 20 parallel),
  detects UNREGISTERED/404 as invalid tokens
- Add HMS provider — native batch API (1000 tokens/chunk), OAuth2 token cache
  with 5-min buffer, detects code 80100016
- Add Xiaomi provider — `/v3/message/regids` batch endpoint (1000/chunk),
  parses `bad_regids` field
- Add OPPO provider — single-send with Promise-based mutex to prevent concurrent
  auth token refresh races
- Add vivo provider — `/message/pushToList` batch endpoint, mutex same as OPPO,
  parses `invalidMap`
- Add `OfflinePushService` — groups tokens by platform, sends concurrently,
  auto-deletes invalid tokens; fire-and-forget trigger after notification creation
- Add `DevicePushTokenController` — POST/DELETE `/api/v1/notifications/device-token`
- Wire offline push into `NotificationAdminController` and `EventTriggerService`
- Add Kong route for device-token endpoint (JWT required)
- Add all push provider env vars to docker-compose notification-service

## Flutter (it0_app)
- Add `PushService` singleton — detects OEM (Huawei/Xiaomi/OPPO/vivo/FCM),
  initialises correct push SDK, registers token with backend
  - FCM: full init with background handler, foreground local notifications,
    tap stream, iOS APNs support
  - HMS: `HuaweiPush` async token via `onTokenEvent`, no FCM fallback on failure
    (Huawei without GMS cannot use FCM)
  - Mi/OPPO/vivo: MethodChannel bridge to Kotlin receivers; handler set before
    `getToken()` call to avoid race
  - `_initialized` guard prevents double-init on hot-restart
  - `static Stream<void> onNotificationTap` for router navigation
- Add Kotlin OEM bridge classes: `MiPushReceiver`, `OppoPushService`,
  `VivoPushReceiver` — forward token/message/tap events to Flutter via MethodChannel
- Update `MainActivity` — register all three OEM MethodChannels; OEM credentials
  injected from `BuildConfig` (read from `local.properties` at build time)
- Update `build.gradle.kts` — add Google Services + HMS AgConnect plugins,
  BuildConfig fields for OEM credentials, `fileTree("libs")` for OEM AARs
- Update `android/build.gradle.kts` — add buildscript classpath for GMS + HMS,
  Huawei Maven repo
- Update `AndroidManifest.xml` — HMS service, Xiaomi receiver + services,
  vivo receiver; OPPO handled via AAR manifest merge
- Add OEM SDK AARs to `android/app/libs/`:
  MiPush 7.9.2, HeytapPush 3.7.1, vivo Push 4.1.3
- Add `google-services.json` (Firebase project: it0-iagent, package: com.iagent.it0_app)
- Add `firebase_core ^3.6.0`, `firebase_messaging ^15.1.3`, `huawei_push ^6.11.0+300`
  to pubspec.yaml
- Add `ApiEndpoints.notificationDeviceToken` endpoint constant

## Ops
- Add FCM_PROJECT_ID, FCM_CLIENT_EMAIL, FCM_PRIVATE_KEY (+ HMS/Mi/OPPO/vivo placeholders)
  to `.env.example` with comments pointing to each OEM's developer portal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-10 02:42:34 -07:00
parent 0e4159c2fd
commit 3bc35bad64
31 changed files with 1694 additions and 36 deletions

View File

@ -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=

View File

@ -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

View File

@ -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 {

View File

@ -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"
}

Binary file not shown.

View File

@ -41,6 +41,47 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- HMS Push: handled by huawei_push Flutter plugin -->
<service
android:name="com.huawei.hms.flutter.push.receiver.HmsMessageService"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT"/>
</intent-filter>
</service>
<!-- FCM: handled automatically by firebase_messaging plugin -->
<!-- Xiaomi Push receiver -->
<receiver
android:name="com.iagent.it0_app.push.MiPushReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.xiaomi.mipush.RECEIVE_MESSAGE"/>
<action android:name="com.xiaomi.mipush.MESSAGE_ARRIVED"/>
<action android:name="com.xiaomi.mipush.ERROR"/>
</intent-filter>
</receiver>
<service
android:name="com.xiaomi.push.service.XMJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<service
android:name="com.xiaomi.push.service.XMPushService"
android:exported="false"
android:process=":pushservice"/>
<!-- vivo Push receiver -->
<receiver
android:name="com.iagent.it0_app.push.VivoPushReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.vivo.pushclient.action.RECEIVE"/>
</intent-filter>
</receiver>
<!-- OPPO Push: SDK auto-registers its own receiver via AAR manifest merge -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -4,31 +4,46 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import com.iagent.it0_app.push.MiPushChannel
import com.iagent.it0_app.push.OppoPushService
import com.iagent.it0_app.push.VivoPushChannel
import com.xiaomi.mipush.sdk.MiPushClient
import com.heytap.msp.push.HeytapPushManager
import com.vivo.push.sdk.OpenClientPushManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
// ─── OEM push credentials ────────────────────────────────────────────────────
// Replace with your actual values (or read from BuildConfig / flavor config).
private const val MI_APP_ID = BuildConfig.MI_APP_ID
private const val MI_APP_KEY = BuildConfig.MI_APP_KEY
private const val OPPO_APP_KEY = BuildConfig.OPPO_APP_KEY
private const val OPPO_APP_SECRET = BuildConfig.OPPO_APP_SECRET
// vivo credentials are embedded in the SDK's manifest/assets (no runtime param needed).
// ─────────────────────────────────────────────────────────────────────────────
class MainActivity : FlutterActivity() {
private val INSTALLER_CHANNEL = "com.iagent.it0_app/apk_installer"
private val MARKET_CHANNEL = "com.iagent.it0_app/app_market"
private val MARKET_CHANNEL = "com.iagent.it0_app/app_market"
private val MI_CHANNEL = "com.iagent.it0_app/mi_push"
private val OPPO_CHANNEL = "com.iagent.it0_app/oppo_push"
private val VIVO_CHANNEL = "com.iagent.it0_app/vivo_push"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// APK 安装器通道
// ── APK 安装器通道 ──────────────────────────────────────────────────────
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"installApk" -> {
val apkPath = call.argument<String>("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()
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
* <service android:name="com.vivo.push.sdk.service.CommandClientService">
* 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
}
}

View File

@ -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/") }
}
}

View File

@ -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';

View File

@ -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<void> _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<void>.broadcast();
/// Emits whenever the user taps a push notification.
static Stream<void> get onNotificationTap => _tapController.stream;
PushPlatform? _platform;
String? _deviceId;
bool _initialized = false;
// Public API
Future<void> 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<void> 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<PushPlatform> _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<void> _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<TokenEvent> where event.event == Event.ON_NEW_TOKEN
// HuaweiPush.onMessageReceivedEvent Stream<RemoteMessage>
Future<void> _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<void> _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<String, dynamic>.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<String>('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<void> _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<String, dynamic>.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<String>('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<void> _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<String, dynamic>.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<String>('getToken');
if (token != null && token.isNotEmpty) {
await _registerToken(PushPlatform.vivo, token);
}
} catch (e) {
_log.e('[Push] vivo init failed: $e');
}
}
// Helpers
Future<void> _initLocalNotifications() async {
if (Platform.isAndroid) {
await _localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.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<void> _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,
),
),
);
}
}

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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<string, PushProvider>;
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<void> {
// 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<string, string[]>();
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<DevicePushToken[]> {
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 [];
}
}
}

View File

@ -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<BatchSendResult> {
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<string | null> {
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<string> {
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!;
}
}

View File

@ -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<BatchSendResult> {
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<string> {
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!;
}
}

View File

@ -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<string> | null = null;
isConfigured(): boolean {
return !!(process.env.OPPO_APP_KEY && process.env.OPPO_MASTER_SECRET);
}
async sendBatch(tokens: string[], message: PushMessage): Promise<BatchSendResult> {
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<string | null> {
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<string> {
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<string> {
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!;
}
}

View File

@ -0,0 +1,21 @@
export interface PushMessage {
title: string;
body: string;
data?: Record<string, string>;
}
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<BatchSendResult>;
}

View File

@ -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<string> | 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<BatchSendResult> {
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<string> {
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<string> {
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!;
}
}

View File

@ -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<BatchSendResult> {
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 };
}
}

View File

@ -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;
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<DevicePushToken[]> {
const rows = await this.ds.query(`SELECT * FROM public.device_push_tokens`);
return rows.map(this.mapRow);
}
async findByUserIds(userIds: string[]): Promise<DevicePushToken[]> {
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<DevicePushToken[]> {
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<DevicePushToken[]> {
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<DevicePushToken[]> {
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<DevicePushToken[]> {
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<DevicePushToken[]> {
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,
};
}
}

View File

@ -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');
}
}
}

View File

@ -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 */

View File

@ -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,
],
})

View File

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