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:
parent
0e4159c2fd
commit
3bc35bad64
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue