瀏覽代碼

feat: initialize android libs platform workspace

徐勤民 11 小時之前
當前提交
6e44428e8a
共有 100 個文件被更改,包括 5184 次插入0 次删除
  1. 14 0
      .gitignore
  2. 11 0
      AndroidLibs/.gitignore
  3. 57 0
      AndroidLibs/README.md
  4. 9 0
      AndroidLibs/build.gradle.kts
  5. 39 0
      AndroidLibs/commonsdk-compose/build.gradle.kts
  6. 1 0
      AndroidLibs/commonsdk-compose/consumer-rules.pro
  7. 3 0
      AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml
  8. 58 0
      AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt
  9. 29 0
      AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt
  10. 34 0
      AndroidLibs/commonsdk-core/build.gradle.kts
  11. 1 0
      AndroidLibs/commonsdk-core/consumer-rules.pro
  12. 24 0
      AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml
  13. 39 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt
  14. 131 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt
  15. 83 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt
  16. 22 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt
  17. 10 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt
  18. 86 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt
  19. 245 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt
  20. 20 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt
  21. 128 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt
  22. 188 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt
  23. 69 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt
  24. 20 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt
  25. 47 0
      AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt
  26. 16 0
      AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml
  27. 50 0
      AndroidLibs/docs/architecture.md
  28. 7 0
      AndroidLibs/gradle.properties
  29. 12 0
      AndroidLibs/gradle/gradle-daemon-jvm.properties
  30. 71 0
      AndroidLibs/gradle/libs.versions.toml
  31. 47 0
      AndroidLibs/gradle/publishing.gradle.kts
  32. 二進制
      AndroidLibs/gradle/wrapper/gradle-wrapper.jar
  33. 8 0
      AndroidLibs/gradle/wrapper/gradle-wrapper.properties
  34. 164 0
      AndroidLibs/gradlew
  35. 79 0
      AndroidLibs/gradlew.bat
  36. 47 0
      AndroidLibs/lib-szyx/build.gradle.kts
  37. 1 0
      AndroidLibs/lib-szyx/consumer-rules.pro
  38. 12 0
      AndroidLibs/lib-szyx/src/main/AndroidManifest.xml
  39. 30 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt
  40. 14 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt
  41. 35 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt
  42. 51 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt
  43. 107 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt
  44. 37 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt
  45. 119 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt
  46. 17 0
      AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt
  47. 4 0
      AndroidLibs/lib-szyx/src/main/res/values/strings.xml
  48. 63 0
      AndroidLibs/plugins/plugin-ui/build.gradle.kts
  49. 1 0
      AndroidLibs/plugins/plugin-ui/proguard-rules.pro
  50. 24 0
      AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml
  51. 114 0
      AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt
  52. 12 0
      AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt
  53. 4 0
      AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml
  54. 65 0
      AndroidLibs/sample-app/build.gradle.kts
  55. 1 0
      AndroidLibs/sample-app/proguard-rules.pro
  56. 27 0
      AndroidLibs/sample-app/src/main/AndroidManifest.xml
  57. 519 0
      AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt
  58. 17 0
      AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt
  59. 19 0
      AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt
  60. 48 0
      AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt
  61. 4 0
      AndroidLibs/sample-app/src/main/res/values/strings.xml
  62. 25 0
      AndroidLibs/settings.gradle.kts
  63. 31 0
      frontend/README.md
  64. 12 0
      frontend/admin-platform/index.html
  65. 22 0
      frontend/admin-platform/package.json
  66. 14 0
      frontend/admin-platform/src/App.vue
  67. 59 0
      frontend/admin-platform/src/api/client.ts
  68. 7 0
      frontend/admin-platform/src/main.ts
  69. 7 0
      frontend/admin-platform/src/router/index.ts
  70. 145 0
      frontend/admin-platform/src/styles.css
  71. 94 0
      frontend/admin-platform/src/views/AccountManagementView.vue
  72. 1 0
      frontend/admin-platform/src/vite-env.d.ts
  73. 17 0
      frontend/admin-platform/tsconfig.app.json
  74. 6 0
      frontend/admin-platform/tsconfig.json
  75. 9 0
      frontend/admin-platform/vite.config.ts
  76. 12 0
      frontend/ops-platform/index.html
  77. 22 0
      frontend/ops-platform/package.json
  78. 22 0
      frontend/ops-platform/src/App.vue
  79. 135 0
      frontend/ops-platform/src/api/client.ts
  80. 7 0
      frontend/ops-platform/src/main.ts
  81. 12 0
      frontend/ops-platform/src/router/index.ts
  82. 221 0
      frontend/ops-platform/src/styles.css
  83. 66 0
      frontend/ops-platform/src/views/RegisterView.vue
  84. 293 0
      frontend/ops-platform/src/views/VersionManagementView.vue
  85. 1 0
      frontend/ops-platform/src/vite-env.d.ts
  86. 17 0
      frontend/ops-platform/tsconfig.app.json
  87. 6 0
      frontend/ops-platform/tsconfig.json
  88. 9 0
      frontend/ops-platform/vite.config.ts
  89. 36 0
      server/README.md
  90. 33 0
      server/pom.xml
  91. 12 0
      server/settings.xml
  92. 72 0
      server/version-management-service/pom.xml
  93. 14 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java
  94. 195 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java
  95. 32 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java
  96. 16 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java
  97. 56 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java
  98. 32 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java
  99. 57 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java
  100. 143 0
      server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+.DS_Store
+.idea/
+.m2/
+.gradle/
+.kotlin/
+build/
+target/
+node_modules/
+dist/
+coverage/
+*.iml
+*.log
+AndroidLibs/.gradle-home/
+AndroidLibs/local.properties

+ 11 - 0
AndroidLibs/.gitignore

@@ -0,0 +1,11 @@
+.gradle/
+.gradle-home/
+.idea/
+.kotlin/
+local.properties
+build/
+*/build/
+captures/
+*.iml
+*.apk
+*.aab

+ 57 - 0
AndroidLibs/README.md

@@ -0,0 +1,57 @@
+# AndroidLibs
+
+一个面向开源的 Android 插件化项目基线,包含宿主 App、业务插件以及可复用的基础 SDK。
+
+## 模块结构
+
+- `commonsdk-core`: SDK 核心,承载网络、共享缓存、插件管理、App 更新、设备信息与时间工具。
+- `commonsdk-compose`: Compose 扩展组件。
+- `lib-szyx`: 项目专属 SDK,承载真实登录接口、签名、业务 Header 与会话管理。
+- `sample-app`: 示例宿主应用。
+- `plugins/plugin-ui`: UI 演示插件,可独立运行,也可被宿主拉起。
+- `docs`: 方案文档。
+
+## 技术基线
+
+- JDK 21
+- AGP 9.1.0
+- Kotlin 2.3.10
+- Compose BOM 2026.03.00
+
+## Nexus
+
+- 依赖拉取仓库:`https://nexus.xuqinmin.com/repository/android/`
+- Snapshot 上传:`https://nexus.xuqinmin.com/repository/android-snapshot/`
+- Release 上传:`https://nexus.xuqinmin.com/repository/android-hosted/`
+
+发布账号请放入本地 `local.properties` 或环境变量,不要提交到仓库。
+
+## 发布配置
+
+建议在 `local.properties` 中提供:
+
+```properties
+nexus.username=your-username
+nexus.password=your-password
+```
+
+然后执行:
+
+```bash
+./gradlew publish
+```
+
+## 当前实现重点
+
+- `sample-app` 与 `plugin-ui` 共享 `commonsdk-core / commonsdk-compose / lib-szyx`
+- 登录接口和签名逻辑参考 `LibsDemo` 中现有实现
+- `commonsdk-core` 提供:
+  - `HttpManager / RetrofitManager`
+  - `SharedCacheManager / SharedCacheProvider`
+  - `PluginPackageManager`
+  - `AppUpdater`
+- `lib-szyx` 提供:
+  - `SzyxSDK`
+  - `AuthApi / AuthRepository`
+  - `BusinessHeaderInterceptor`
+  - `SzyxLoginActivity`

+ 9 - 0
AndroidLibs/build.gradle.kts

@@ -0,0 +1,9 @@
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.android.library) apply false
+    alias(libs.plugins.kotlin.compose) apply false
+    alias(libs.plugins.kotlin.serialization) apply false
+}
+
+group = "com.xuqm"
+version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT")

+ 39 - 0
AndroidLibs/commonsdk-compose/build.gradle.kts

@@ -0,0 +1,39 @@
+plugins {
+    alias(libs.plugins.android.library)
+    alias(libs.plugins.kotlin.compose)
+}
+
+apply(from = rootProject.file("gradle/publishing.gradle.kts"))
+
+android {
+    namespace = "com.xuqm.sdk.compose"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        minSdk = libs.versions.minSdk.get().toInt()
+        consumerProguardFiles("consumer-rules.pro")
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
+    }
+
+    buildFeatures {
+        compose = true
+    }
+}
+
+kotlin {
+    jvmToolchain(21)
+}
+
+dependencies {
+    api(project(":commonsdk-core"))
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.bundles.compose)
+
+    debugImplementation(libs.bundles.compose.debug)
+}

+ 1 - 0
AndroidLibs/commonsdk-compose/consumer-rules.pro

@@ -0,0 +1 @@
+

+ 3 - 0
AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml

@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest />
+

+ 58 - 0
AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt

@@ -0,0 +1,58 @@
+package com.xuqm.sdk.compose.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun AccordionGroup(
+    title: String,
+    modifier: Modifier = Modifier,
+    initiallyExpanded: Boolean = false,
+    content: @Composable () -> Unit,
+) {
+    var expanded by remember { mutableStateOf(initiallyExpanded) }
+
+    Card(modifier = modifier.fillMaxWidth()) {
+        Column {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clickable { expanded = !expanded }
+                    .padding(horizontal = 16.dp, vertical = 14.dp),
+                horizontalArrangement = Arrangement.SpaceBetween,
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Text(title, style = MaterialTheme.typography.titleMedium)
+                Icon(
+                    imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
+                    contentDescription = null,
+                )
+            }
+            AnimatedVisibility(expanded) {
+                Column(modifier = Modifier.padding(16.dp)) {
+                    content()
+                }
+            }
+        }
+    }
+}

+ 29 - 0
AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt

@@ -0,0 +1,29 @@
+package com.xuqm.sdk.compose.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun FeatureCard(
+    title: String,
+    description: String,
+    modifier: Modifier = Modifier,
+) {
+    Card(modifier = modifier.fillMaxWidth()) {
+        Column(
+            modifier = Modifier.padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Text(title, style = MaterialTheme.typography.titleMedium)
+            Text(description, style = MaterialTheme.typography.bodyMedium)
+        }
+    }
+}

+ 34 - 0
AndroidLibs/commonsdk-core/build.gradle.kts

@@ -0,0 +1,34 @@
+plugins {
+    alias(libs.plugins.android.library)
+}
+
+apply(from = rootProject.file("gradle/publishing.gradle.kts"))
+
+android {
+    namespace = "com.xuqm.sdk.core"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        minSdk = libs.versions.minSdk.get().toInt()
+        consumerProguardFiles("consumer-rules.pro")
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
+    }
+}
+
+kotlin {
+    jvmToolchain(21)
+}
+
+dependencies {
+    api(libs.androidx.core.ktx)
+    api(libs.bundles.network)
+    api(libs.kotlinx.serialization.json)
+    implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.androidx.datastore.preferences)
+
+    testImplementation(libs.junit4)
+}

+ 1 - 0
AndroidLibs/commonsdk-core/consumer-rules.pro

@@ -0,0 +1 @@
+

+ 24 - 0
AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application>
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${coreFileProviderAuthority}"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/core_file_paths" />
+        </provider>
+
+        <provider
+            android:name="com.xuqm.sdk.cache.SharedCacheProvider"
+            android:authorities="${sharedCacheAuthority}"
+            android:exported="true"
+            android:grantUriPermissions="true" />
+    </application>
+
+</manifest>

+ 39 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt

@@ -0,0 +1,39 @@
+package com.xuqm.sdk
+
+import android.app.Application
+import android.content.Context
+import com.xuqm.sdk.network.HttpConfig
+import com.xuqm.sdk.network.HttpManager
+import com.xuqm.sdk.plugin.PluginPackageManager
+import com.xuqm.sdk.update.AppUpdater
+import com.xuqm.sdk.update.DownloadManager
+import com.xuqm.sdk.utils.DeviceUtils
+
+object CoreSDK {
+    private var appContext: Context? = null
+    private var config: SDKConfig = SDKConfig()
+
+    data class SDKConfig(
+        val debugMode: Boolean = false,
+        val pluginDirectory: String = "plugins",
+    )
+
+    fun init(context: Context, config: SDKConfig = SDKConfig()) {
+        if (appContext != null) return
+        appContext = context.applicationContext
+        this.config = config
+        HttpManager.init(HttpConfig(debugMode = config.debugMode))
+    }
+
+    fun context(): Context = requireNotNull(appContext) { "CoreSDK not initialized" }
+
+    fun pluginPackageManager(): PluginPackageManager = PluginPackageManager.getInstance(context())
+
+    fun downloadManager(): DownloadManager = DownloadManager.getInstance(context())
+
+    fun appUpdater(): AppUpdater = AppUpdater.getInstance(context())
+
+    fun deviceId(): String = DeviceUtils.getDeviceId(context())
+
+    fun deviceInfo() = DeviceUtils.getDeviceInfo(context())
+}

+ 131 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt

@@ -0,0 +1,131 @@
+package com.xuqm.sdk.cache
+
+import android.content.Context
+import android.content.ContentValues
+import android.net.Uri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+
+class SharedCacheManager private constructor(context: Context) {
+
+    companion object {
+        const val AUTHORITY_SUFFIX = ".sdk.cache.provider"
+        const val PATH_CACHE = "cache"
+
+        @Volatile
+        private var instance: SharedCacheManager? = null
+
+        fun getInstance(context: Context): SharedCacheManager {
+            return instance ?: synchronized(this) {
+                instance ?: SharedCacheManager(context.applicationContext).also { instance = it }
+            }
+        }
+    }
+
+    private val appContext = context.applicationContext
+    private val memoryCache = ConcurrentHashMap<String, CacheEntry>()
+
+    data class CacheEntry(
+        val key: String,
+        val value: String,
+        val timestamp: Long,
+        val ttl: Long,
+    ) {
+        fun isExpired(): Boolean = System.currentTimeMillis() - timestamp > ttl
+    }
+
+    fun put(key: String, value: String, ttl: Long = 10 * 60 * 1000) {
+        val entry = CacheEntry(key, value, System.currentTimeMillis(), ttl)
+        memoryCache[key] = entry
+        writeToDisk(entry)
+    }
+
+    suspend fun get(key: String, appPackageName: String? = null): String? = withContext(Dispatchers.IO) {
+        getSync(key, appPackageName)
+    }
+
+    fun getSync(key: String, appPackageName: String? = null): String? {
+        memoryCache[key]?.let {
+            if (!it.isExpired()) return it.value
+            memoryCache.remove(key)
+        }
+
+        return if (appPackageName != null && appPackageName != appContext.packageName) {
+            getFromProvider(key, appPackageName)
+        } else {
+            readFromDisk(key)
+        }
+    }
+
+    fun remove(key: String) {
+        memoryCache.remove(key)
+        File(cacheDir(), "$key.cache").delete()
+    }
+
+    fun putRemote(key: String, value: String, ttl: Long = 10 * 60 * 1000, appPackageName: String): Boolean {
+        val uri = Uri.parse("content://$appPackageName$AUTHORITY_SUFFIX/$PATH_CACHE/$key")
+        return runCatching {
+            appContext.contentResolver.update(
+                uri,
+                ContentValues().apply {
+                    put("key", key)
+                    put("value", value)
+                    put("timestamp", System.currentTimeMillis())
+                    put("ttl", ttl)
+                },
+                null,
+                null,
+            )
+        }.isSuccess
+    }
+
+    private fun getFromProvider(key: String, appPackageName: String): String? {
+        val uri = Uri.parse("content://$appPackageName$AUTHORITY_SUFFIX/$PATH_CACHE/$key")
+        val cursor = runCatching { appContext.contentResolver.query(uri, null, null, null, null) }.getOrNull()
+            ?: return null
+        return cursor.use {
+            if (!it.moveToFirst()) return null
+            val value = it.getString(it.getColumnIndexOrThrow("value"))
+            val timestamp = it.getLong(it.getColumnIndexOrThrow("timestamp"))
+            val ttl = it.getLong(it.getColumnIndexOrThrow("ttl"))
+            if (System.currentTimeMillis() - timestamp > ttl) null else value
+        }
+    }
+
+    private fun writeToDisk(entry: CacheEntry) {
+        val file = File(cacheDir(), "${entry.key}.cache")
+        val json = JSONObject().apply {
+            put("key", entry.key)
+            put("value", entry.value)
+            put("timestamp", entry.timestamp)
+            put("ttl", entry.ttl)
+        }
+        file.writeText(json.toString())
+    }
+
+    private fun readFromDisk(key: String): String? {
+        val file = File(cacheDir(), "$key.cache")
+        if (!file.exists()) return null
+        return runCatching {
+            val json = JSONObject(file.readText())
+            val timestamp = json.getLong("timestamp")
+            val ttl = json.getLong("ttl")
+            if (System.currentTimeMillis() - timestamp > ttl) {
+                file.delete()
+                null
+            } else {
+                json.getString("value")
+            }
+        }.getOrNull()
+    }
+
+    private fun cacheDir(): File = File(appContext.cacheDir, "shared_cache").apply { mkdirs() }
+}
+
+object CacheKeys {
+    const val CURRENT_USER = "current_user"
+    const val LOGIN_SESSION = "login_session"
+}

+ 83 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt

@@ -0,0 +1,83 @@
+package com.xuqm.sdk.cache
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import org.json.JSONObject
+import java.io.File
+
+class SharedCacheProvider : ContentProvider() {
+
+    companion object {
+        private const val CODE_CACHE = 1
+        private const val CODE_CACHE_ITEM = 2
+
+        private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
+            addURI("*", SharedCacheManager.PATH_CACHE, CODE_CACHE)
+            addURI("*", "${SharedCacheManager.PATH_CACHE}/*", CODE_CACHE_ITEM)
+        }
+    }
+
+    override fun onCreate(): Boolean = true
+
+    override fun query(
+        uri: Uri,
+        projection: Array<String>?,
+        selection: String?,
+        selectionArgs: Array<String>?,
+        sortOrder: String?,
+    ): Cursor? {
+        if (uriMatcher.match(uri) != CODE_CACHE_ITEM) return null
+        val key = uri.lastPathSegment ?: return null
+        val cacheDir = File(requireNotNull(context).cacheDir, "shared_cache")
+        val file = File(cacheDir, "$key.cache")
+        if (!file.exists()) return null
+        val json = JSONObject(file.readText())
+        return MatrixCursor(arrayOf("key", "value", "timestamp", "ttl")).apply {
+            addRow(
+                arrayOf(
+                    json.getString("key"),
+                    json.getString("value"),
+                    json.getLong("timestamp"),
+                    json.getLong("ttl"),
+                ),
+            )
+        }
+    }
+
+    override fun getType(uri: Uri): String? = "vnd.android.cursor.item/cache"
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? {
+        return if (writeCache(uri, values)) uri else null
+    }
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
+        val key = uri.lastPathSegment ?: return 0
+        val file = File(File(requireNotNull(context).cacheDir, "shared_cache"), "$key.cache")
+        return if (file.delete()) 1 else 0
+    }
+
+    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
+        return if (writeCache(uri, values)) 1 else 0
+    }
+
+    private fun writeCache(uri: Uri, values: ContentValues?): Boolean {
+        if (uriMatcher.match(uri) != CODE_CACHE_ITEM || values == null) return false
+        val key = values.getAsString("key") ?: uri.lastPathSegment ?: return false
+        val value = values.getAsString("value") ?: return false
+        val timestamp = values.getAsLong("timestamp") ?: System.currentTimeMillis()
+        val ttl = values.getAsLong("ttl") ?: 10 * 60 * 1000
+        val json = JSONObject().apply {
+            put("key", key)
+            put("value", value)
+            put("timestamp", timestamp)
+            put("ttl", ttl)
+        }
+        val cacheDir = File(requireNotNull(context).cacheDir, "shared_cache").apply { mkdirs() }
+        File(cacheDir, "$key.cache").writeText(json.toString())
+        return true
+    }
+}

+ 22 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt

@@ -0,0 +1,22 @@
+package com.xuqm.sdk.communication
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+
+data class Event(val topic: String, val payload: Any? = null)
+
+object EventBus {
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+    private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 32)
+
+    val events: SharedFlow<Event> = eventsFlow
+
+    fun post(topic: String, payload: Any? = null) {
+        scope.launch { eventsFlow.emit(Event(topic, payload)) }
+    }
+}
+

+ 10 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt

@@ -0,0 +1,10 @@
+package com.xuqm.sdk.network
+
+data class HttpResult<T>(
+    val code: Int? = null,
+    val status: String? = null,
+    val data: T? = null,
+    val message: String? = null,
+) {
+    fun isSuccess(): Boolean = status == "0" || status == "200" || code == 200
+}

+ 86 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt

@@ -0,0 +1,86 @@
+package com.xuqm.sdk.network
+
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+data class HttpConfig(
+    val connectTimeout: Long = 30,
+    val readTimeout: Long = 30,
+    val writeTimeout: Long = 30,
+    val debugMode: Boolean = false,
+    val interceptors: List<Interceptor> = emptyList(),
+    val networkInterceptors: List<Interceptor> = emptyList(),
+)
+
+class RetrofitManager private constructor() {
+    companion object {
+        @Volatile private var instance: RetrofitManager? = null
+        fun getInstance(): RetrofitManager = instance ?: synchronized(this) {
+            instance ?: RetrofitManager().also { instance = it }
+        }
+    }
+
+    private val retrofitMap = ConcurrentHashMap<String, Retrofit>()
+    private val serviceCache = ConcurrentHashMap<String, Any>()
+    private var globalConfig: HttpConfig = HttpConfig()
+
+    fun init(config: HttpConfig = HttpConfig()) {
+        globalConfig = config
+    }
+
+    fun <T> getService(baseUrl: String, serviceClass: Class<T>, config: HttpConfig? = null): T {
+        val key = "${baseUrl}_${serviceClass.name}"
+        @Suppress("UNCHECKED_CAST")
+        return serviceCache.getOrPut(key) {
+            createRetrofit(baseUrl, config ?: globalConfig).create(serviceClass)
+        } as T
+    }
+
+    fun clear() {
+        retrofitMap.clear()
+        serviceCache.clear()
+    }
+
+    private fun createRetrofit(baseUrl: String, config: HttpConfig): Retrofit {
+        val client = OkHttpClient.Builder()
+            .connectTimeout(config.connectTimeout, TimeUnit.SECONDS)
+            .readTimeout(config.readTimeout, TimeUnit.SECONDS)
+            .writeTimeout(config.writeTimeout, TimeUnit.SECONDS)
+            .apply {
+                config.interceptors.forEach(::addInterceptor)
+                config.networkInterceptors.forEach(::addNetworkInterceptor)
+                if (config.debugMode) {
+                    addInterceptor(
+                        HttpLoggingInterceptor().apply {
+                            level = HttpLoggingInterceptor.Level.BODY
+                        },
+                    )
+                }
+            }
+            .build()
+
+        return retrofitMap.getOrPut(baseUrl) {
+            Retrofit.Builder()
+                .baseUrl(baseUrl)
+                .client(client)
+                .addConverterFactory(GsonConverterFactory.create())
+                .build()
+        }
+    }
+}
+
+object HttpManager {
+    fun init(config: HttpConfig = HttpConfig()) {
+        RetrofitManager.getInstance().init(config)
+    }
+
+    fun <T> getService(baseUrl: String, serviceClass: Class<T>, config: HttpConfig? = null): T {
+        return RetrofitManager.getInstance().getService(baseUrl, serviceClass, config)
+    }
+}
+

+ 245 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt

@@ -0,0 +1,245 @@
+package com.xuqm.sdk.plugin
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import androidx.core.content.FileProvider
+import com.xuqm.sdk.cache.CacheKeys
+import com.xuqm.sdk.cache.SharedCacheManager
+import com.xuqm.sdk.update.DownloadDecision
+import com.xuqm.sdk.update.DownloadManager
+import com.xuqm.sdk.update.DownloadRequest
+import com.xuqm.sdk.update.StoragePath
+import com.xuqm.sdk.update.VersionCheckResult
+import com.xuqm.sdk.update.VersionCheckStrategy
+import com.xuqm.sdk.update.VersionComparator
+import com.xuqm.sdk.update.VersionInfo
+import org.json.JSONObject
+import java.io.File
+
+class PluginPackageManager private constructor(private val context: Context) {
+
+    data class PluginUpdateInfo(
+        val packageName: String,
+        val versionCode: Long = 0L,
+        val versionName: String = "",
+        val downloadUrl: String,
+        val entryActivity: String? = null,
+        val extras: Map<String, String> = emptyMap(),
+    )
+
+    companion object {
+        @Volatile
+        private var instance: PluginPackageManager? = null
+
+        fun getInstance(context: Context): PluginPackageManager {
+            return instance ?: synchronized(this) {
+                instance ?: PluginPackageManager(context.applicationContext).also { instance = it }
+            }
+        }
+    }
+
+    private val cacheManager = SharedCacheManager.getInstance(context)
+    private val downloadManager = DownloadManager.getInstance(context)
+
+    fun cacheCurrentUser(
+        userId: String,
+        sessionId: String,
+        clientId: String,
+        extraData: Map<String, String> = emptyMap(),
+    ) {
+        val json = JSONObject().apply {
+            put("userId", userId)
+            put("sessionId", sessionId)
+            put("clientId", clientId)
+            put("timestamp", System.currentTimeMillis())
+            extraData.forEach { (key, value) -> put(key, value) }
+        }
+        cacheManager.put(CacheKeys.CURRENT_USER, json.toString(), 10 * 60 * 1000)
+    }
+
+    fun getCachedUser(appPackageName: String? = null): String? {
+        return cacheManager.getSync(CacheKeys.CURRENT_USER, appPackageName)
+    }
+
+    fun isPluginInstalled(packageName: String): Boolean {
+        return runCatching {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                context.packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
+            } else {
+                @Suppress("DEPRECATION")
+                context.packageManager.getPackageInfo(packageName, 0)
+            }
+        }.isSuccess
+    }
+
+    fun getLocalPluginInfo(packageName: String): PluginInfo? {
+        return runCatching {
+            val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                context.packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
+            } else {
+                @Suppress("DEPRECATION")
+                context.packageManager.getPackageInfo(packageName, 0)
+            }
+            PluginInfo(
+                packageName = packageName,
+                versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong(),
+                versionName = info.versionName.orEmpty(),
+                isInstalled = true,
+            )
+        }.getOrNull()
+    }
+
+    fun compareVersion(packageName: String, remoteVersionCode: Long): Int {
+        val local = getLocalPluginInfo(packageName) ?: return 1
+        return remoteVersionCode.compareTo(local.versionCode)
+    }
+
+    fun shouldDownloadPlugin(
+        packageName: String,
+        remoteVersionCode: Long? = null,
+        remoteVersionName: String? = null,
+    ): Boolean {
+        return checkPluginUpdate(
+            packageName = packageName,
+            remoteVersionCode = remoteVersionCode,
+            remoteVersionName = remoteVersionName,
+        ) is VersionCheckResult.NeedUpdate
+    }
+
+    fun checkPluginUpdate(
+        packageName: String,
+        remoteVersionCode: Long? = null,
+        remoteVersionName: String? = null,
+        strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+    ): VersionCheckResult {
+        val local = getLocalPluginInfo(packageName)
+        val current = VersionInfo(
+            versionCode = local?.versionCode ?: 0L,
+            versionName = local?.versionName.orEmpty(),
+        )
+        val remote = VersionInfo(
+            versionCode = remoteVersionCode ?: 0L,
+            versionName = remoteVersionName.orEmpty(),
+        )
+        return if (local == null) {
+            VersionCheckResult.NeedUpdate(current = current, remote = remote, strategy = strategy)
+        } else {
+            VersionComparator.check(current = current, remote = remote, strategy = strategy)
+        }
+    }
+
+    fun downloadPlugin(
+        updateInfo: PluginUpdateInfo,
+        fileName: String = "${updateInfo.packageName}.apk",
+        storagePath: StoragePath = StoragePath.CACHE,
+        customPath: String? = null,
+    ): String {
+        return downloadManager.start(
+            DownloadRequest(
+                url = updateInfo.downloadUrl,
+                fileName = fileName,
+                storagePath = storagePath,
+                customPath = customPath,
+            ),
+        )
+    }
+
+    fun downloadPluginIfNeeded(
+        updateInfo: PluginUpdateInfo,
+        strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+        fileName: String = "${updateInfo.packageName}.apk",
+        storagePath: StoragePath = StoragePath.CACHE,
+        customPath: String? = null,
+    ): DownloadDecision {
+        val checkResult = checkPluginUpdate(
+            packageName = updateInfo.packageName,
+            remoteVersionCode = updateInfo.versionCode,
+            remoteVersionName = updateInfo.versionName,
+            strategy = strategy,
+        )
+        if (checkResult is VersionCheckResult.UpToDate) {
+            val local = getLocalPluginInfo(updateInfo.packageName)
+            return DownloadDecision.Skipped(
+                reason = if (local == null) {
+                    "当前插件无需下载"
+                } else {
+                    "当前已安装相同或更新版本 ${local.versionName}(${local.versionCode})"
+                },
+            )
+        }
+
+        return DownloadDecision.Started(
+            taskId = downloadPlugin(
+                updateInfo = updateInfo,
+                fileName = fileName,
+                storagePath = storagePath,
+                customPath = customPath,
+            ),
+        )
+    }
+
+    fun startPlugin(
+        packageName: String,
+        entryActivity: String? = null,
+        extras: Map<String, String> = emptyMap(),
+    ): Boolean {
+        if (!isPluginInstalled(packageName)) return false
+        val explicitIntent = entryActivity?.let { className ->
+            Intent().apply {
+                setClassName(packageName, className)
+                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                extras.forEach { (key, value) -> putExtra(key, value) }
+            }
+        }
+
+        if (explicitIntent != null && runCatching { context.startActivity(explicitIntent) }.isSuccess) {
+            return true
+        }
+
+        val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)?.apply {
+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            extras.forEach { (key, value) -> putExtra(key, value) }
+        } ?: return false
+
+        return runCatching { context.startActivity(launchIntent) }.isSuccess
+    }
+
+    fun installPlugin(apkFile: File): Boolean {
+        return runCatching {
+            val authority = "${context.packageName}.sdk.fileprovider"
+            val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                FileProvider.getUriForFile(context, authority, apkFile)
+            } else {
+                Uri.fromFile(apkFile)
+            }
+            context.startActivity(
+                Intent(Intent.ACTION_VIEW).apply {
+                    setDataAndType(uri, "application/vnd.android.package-archive")
+                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                },
+            )
+        }.isSuccess
+    }
+
+    fun loadPlugin(apkFile: File): Boolean = installPlugin(apkFile)
+
+    fun reloadPlugin(
+        packageName: String,
+        entryActivity: String? = null,
+        extras: Map<String, String> = emptyMap(),
+    ): Boolean = startPlugin(packageName = packageName, entryActivity = entryActivity, extras = extras)
+
+    fun goToDownload(downloadUrl: String): Boolean {
+        return downloadUrl.isNotBlank()
+    }
+}
+
+data class PluginInfo(
+    val packageName: String,
+    val versionCode: Long,
+    val versionName: String,
+    val isInstalled: Boolean,
+)

+ 20 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt

@@ -0,0 +1,20 @@
+package com.xuqm.sdk.ui
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.widget.Toast
+
+object ToastCenter {
+    private val handler = Handler(Looper.getMainLooper())
+    private var appContext: Context? = null
+
+    fun init(context: Context) {
+        appContext = context.applicationContext
+    }
+
+    fun show(message: String) {
+        val context = appContext ?: return
+        handler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
+    }
+}

+ 128 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt

@@ -0,0 +1,128 @@
+package com.xuqm.sdk.update
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import kotlinx.coroutines.flow.StateFlow
+
+data class UpdateInfo(
+    val versionCode: Int,
+    val versionName: String,
+    val title: String = "发现新版本",
+    val changelog: String = "",
+    val downloadUrl: String,
+    val forceUpdate: Boolean = false,
+)
+
+class AppUpdater private constructor(private val context: Context) {
+
+    companion object {
+        @Volatile private var instance: AppUpdater? = null
+
+        fun getInstance(context: Context): AppUpdater {
+            return instance ?: synchronized(this) {
+                instance ?: AppUpdater(context.applicationContext).also { instance = it }
+            }
+        }
+
+        fun compareVersionCode(currentVersion: Int, newVersion: Int): Int {
+            return VersionComparator.compareVersionCode(currentVersion.toLong(), newVersion.toLong())
+        }
+
+        fun compareVersionName(currentVersion: String, newVersion: String): Int {
+            return VersionComparator.compareVersionName(currentVersion, newVersion)
+        }
+    }
+
+    private val downloadManager = DownloadManager.getInstance(context)
+
+    fun getCurrentVersion(): VersionInfo {
+        val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))
+        } else {
+            @Suppress("DEPRECATION")
+            context.packageManager.getPackageInfo(context.packageName, 0)
+        }
+        val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            packageInfo.longVersionCode
+        } else {
+            @Suppress("DEPRECATION")
+            packageInfo.versionCode.toLong()
+        }
+        return VersionInfo(
+            versionCode = versionCode,
+            versionName = packageInfo.versionName.orEmpty(),
+        )
+    }
+
+    fun shouldDownload(updateInfo: UpdateInfo): Boolean {
+        return checkUpdate(updateInfo) is VersionCheckResult.NeedUpdate
+    }
+
+    fun checkUpdate(
+        updateInfo: UpdateInfo,
+        strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+    ): VersionCheckResult {
+        return VersionComparator.check(
+            current = getCurrentVersion(),
+            remote = VersionInfo(
+                versionCode = updateInfo.versionCode.toLong(),
+                versionName = updateInfo.versionName,
+            ),
+            strategy = strategy,
+        )
+    }
+
+    fun downloadUpdate(
+        updateInfo: UpdateInfo,
+        fileName: String = "app_update.apk",
+        storagePath: StoragePath = StoragePath.CACHE,
+        customPath: String? = null,
+    ): String {
+        return downloadManager.start(
+            DownloadRequest(
+                url = updateInfo.downloadUrl,
+                fileName = fileName,
+                storagePath = storagePath,
+                customPath = customPath,
+            ),
+        )
+    }
+
+    fun downloadUpdateIfNeeded(
+        updateInfo: UpdateInfo,
+        strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+        fileName: String = "app_update.apk",
+        storagePath: StoragePath = StoragePath.CACHE,
+        customPath: String? = null,
+    ): DownloadDecision {
+        val checkResult = checkUpdate(updateInfo, strategy)
+        if (checkResult is VersionCheckResult.UpToDate) {
+            return DownloadDecision.Skipped(
+                reason = "当前已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})",
+            )
+        }
+
+        return DownloadDecision.Started(
+            taskId = downloadUpdate(
+                updateInfo = updateInfo,
+                fileName = fileName,
+                storagePath = storagePath,
+                customPath = customPath,
+            ),
+        )
+    }
+
+    fun observe(taskId: String): StateFlow<DownloadState>? = downloadManager.observe(taskId)
+
+    fun cancel(taskId: String): Boolean = downloadManager.cancel(taskId)
+
+    fun clear(taskId: String): Boolean = downloadManager.clear(taskId)
+
+    fun installApk(file: java.io.File): Boolean = downloadManager.installApk(file)
+
+    fun installFromTask(taskId: String): Boolean {
+        val file = downloadManager.getDownloadedFile(taskId) ?: return false
+        return installApk(file)
+    }
+}

+ 188 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt

@@ -0,0 +1,188 @@
+package com.xuqm.sdk.update
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import okhttp3.Call
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+sealed class DownloadState {
+    data object Idle : DownloadState()
+    data object Starting : DownloadState()
+    data class Progress(
+        val progress: Int,
+        val downloadedBytes: Long,
+        val totalBytes: Long,
+    ) : DownloadState()
+    data class Success(val file: File) : DownloadState()
+    data object Cancelled : DownloadState()
+    data class Error(val message: String) : DownloadState()
+}
+
+enum class StoragePath { DOWNLOADS, CACHE, EXTERNAL_CACHE, FILES, EXTERNAL_FILES, CUSTOM }
+
+data class DownloadRequest(
+    val url: String,
+    val fileName: String,
+    val storagePath: StoragePath = StoragePath.CACHE,
+    val customPath: String? = null,
+)
+
+sealed class DownloadDecision {
+    data class Started(val taskId: String) : DownloadDecision()
+    data class Skipped(val reason: String) : DownloadDecision()
+}
+
+class DownloadManager private constructor(private val context: Context) {
+
+    private data class DownloadTask(
+        val state: MutableStateFlow<DownloadState>,
+        var file: File? = null,
+        var call: Call? = null,
+        var job: Job? = null,
+    )
+
+    companion object {
+        @Volatile private var instance: DownloadManager? = null
+
+        fun getInstance(context: Context): DownloadManager {
+            return instance ?: synchronized(this) {
+                instance ?: DownloadManager(context.applicationContext).also { instance = it }
+            }
+        }
+    }
+
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val okHttpClient = OkHttpClient.Builder().build()
+    private val tasks = ConcurrentHashMap<String, DownloadTask>()
+
+    fun start(request: DownloadRequest): String {
+        val taskId = UUID.randomUUID().toString()
+        val task = DownloadTask(state = MutableStateFlow(DownloadState.Idle))
+        tasks[taskId] = task
+
+        task.job = scope.launch {
+            task.state.value = DownloadState.Starting
+            val targetFile = File(resolvePath(request.storagePath, request.customPath), request.fileName).apply {
+                parentFile?.mkdirs()
+                if (exists()) delete()
+            }
+            task.file = targetFile
+
+            runCatching {
+                val httpRequest = Request.Builder().url(request.url).build()
+                val call = okHttpClient.newCall(httpRequest)
+                task.call = call
+                call.execute().use { response ->
+                    if (!response.isSuccessful) error("下载失败: HTTP ${response.code}")
+
+                    val body = requireNotNull(response.body) { "下载失败: 响应体为空" }
+                    val totalBytes = body.contentLength()
+                    var downloadedBytes = 0L
+
+                    body.byteStream().use { input ->
+                        targetFile.outputStream().use { output ->
+                            val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+                            while (true) {
+                                val read = input.read(buffer)
+                                if (read == -1) break
+                                output.write(buffer, 0, read)
+                                downloadedBytes += read
+                                val progress = if (totalBytes > 0) {
+                                    ((downloadedBytes * 100) / totalBytes).toInt()
+                                } else {
+                                    -1
+                                }
+                                task.state.value = DownloadState.Progress(
+                                    progress = progress,
+                                    downloadedBytes = downloadedBytes,
+                                    totalBytes = totalBytes,
+                                )
+                            }
+                            output.flush()
+                        }
+                    }
+                }
+            }.onSuccess {
+                task.state.value = DownloadState.Success(targetFile)
+                task.call = null
+            }.onFailure {
+                task.state.value = if (it is CancellationException) {
+                    DownloadState.Cancelled
+                } else {
+                    DownloadState.Error(it.message ?: "下载失败")
+                }
+                if (targetFile.exists()) targetFile.delete()
+                task.call = null
+            }
+        }
+
+        return taskId
+    }
+
+    fun observe(taskId: String): StateFlow<DownloadState>? = tasks[taskId]?.state?.asStateFlow()
+
+    fun getState(taskId: String): DownloadState? = tasks[taskId]?.state?.value
+
+    fun getDownloadedFile(taskId: String): File? = (tasks[taskId]?.state?.value as? DownloadState.Success)?.file
+
+    fun cancel(taskId: String): Boolean {
+        val task = tasks[taskId] ?: return false
+        task.call?.cancel()
+        task.job?.cancel()
+        task.file?.takeIf { it.exists() }?.delete()
+        task.state.value = DownloadState.Cancelled
+        return true
+    }
+
+    fun clear(taskId: String): Boolean {
+        val task = tasks.remove(taskId) ?: return false
+        task.call = null
+        task.job = null
+        return true
+    }
+
+    fun installApk(file: File): Boolean {
+        return runCatching {
+            val authority = "${context.packageName}.sdk.fileprovider"
+            val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                FileProvider.getUriForFile(context, authority, file)
+            } else {
+                Uri.fromFile(file)
+            }
+            context.startActivity(
+                Intent(Intent.ACTION_VIEW).apply {
+                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                    setDataAndType(uri, "application/vnd.android.package-archive")
+                },
+            )
+        }.isSuccess
+    }
+
+    private fun resolvePath(storagePath: StoragePath, customPath: String?): String {
+        return when (storagePath) {
+            StoragePath.DOWNLOADS -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
+            StoragePath.CACHE -> context.cacheDir.absolutePath
+            StoragePath.EXTERNAL_CACHE -> context.externalCacheDir?.absolutePath ?: context.cacheDir.absolutePath
+            StoragePath.FILES -> context.filesDir.absolutePath
+            StoragePath.EXTERNAL_FILES -> context.getExternalFilesDir(null)?.absolutePath ?: context.filesDir.absolutePath
+            StoragePath.CUSTOM -> customPath ?: context.cacheDir.absolutePath
+        }
+    }
+}

+ 69 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt

@@ -0,0 +1,69 @@
+package com.xuqm.sdk.update
+
+data class VersionInfo(
+    val versionCode: Long = 0L,
+    val versionName: String = "",
+)
+
+enum class VersionCheckStrategy {
+    VERSION_CODE,
+    VERSION_NAME,
+    VERSION_CODE_OR_NAME,
+}
+
+sealed class VersionCheckResult {
+    data class NeedUpdate(
+        val current: VersionInfo,
+        val remote: VersionInfo,
+        val strategy: VersionCheckStrategy,
+    ) : VersionCheckResult()
+
+    data class UpToDate(
+        val current: VersionInfo,
+        val remote: VersionInfo,
+        val strategy: VersionCheckStrategy,
+    ) : VersionCheckResult()
+}
+
+object VersionComparator {
+    fun compareVersionCode(currentVersion: Long, newVersion: Long): Int = newVersion.compareTo(currentVersion)
+
+    fun compareVersionName(currentVersion: String, newVersion: String): Int {
+        val currentParts = currentVersion.split(".")
+        val newParts = newVersion.split(".")
+        val maxLength = maxOf(currentParts.size, newParts.size)
+        for (index in 0 until maxLength) {
+            val currentPart = currentParts.getOrElse(index) { "0" }.toIntOrNull() ?: 0
+            val newPart = newParts.getOrElse(index) { "0" }.toIntOrNull() ?: 0
+            if (newPart != currentPart) return newPart.compareTo(currentPart)
+        }
+        return 0
+    }
+
+    fun check(
+        current: VersionInfo,
+        remote: VersionInfo,
+        strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+    ): VersionCheckResult {
+        val needsUpdate = when (strategy) {
+            VersionCheckStrategy.VERSION_CODE ->
+                remote.versionCode > 0 && compareVersionCode(current.versionCode, remote.versionCode) > 0
+
+            VersionCheckStrategy.VERSION_NAME ->
+                remote.versionName.isNotBlank() && compareVersionName(current.versionName, remote.versionName) > 0
+
+            VersionCheckStrategy.VERSION_CODE_OR_NAME -> {
+                val byCode = remote.versionCode > 0 && compareVersionCode(current.versionCode, remote.versionCode) > 0
+                val byName = remote.versionName.isNotBlank() &&
+                    compareVersionName(current.versionName, remote.versionName) > 0
+                byCode || byName
+            }
+        }
+
+        return if (needsUpdate) {
+            VersionCheckResult.NeedUpdate(current = current, remote = remote, strategy = strategy)
+        } else {
+            VersionCheckResult.UpToDate(current = current, remote = remote, strategy = strategy)
+        }
+    }
+}

+ 20 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt

@@ -0,0 +1,20 @@
+package com.xuqm.sdk.utils
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+object DateTimeUtils {
+    fun format(
+        timeMillis: Long,
+        pattern: String = "yyyy-MM-dd HH:mm:ss",
+        timeZone: TimeZone = TimeZone.getDefault(),
+        locale: Locale = Locale.getDefault(),
+    ): String {
+        return SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.format(Date(timeMillis))
+    }
+
+    fun now(pattern: String = "yyyy-MM-dd HH:mm:ss"): String = format(System.currentTimeMillis(), pattern)
+}
+

+ 47 - 0
AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt

@@ -0,0 +1,47 @@
+package com.xuqm.sdk.utils
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.provider.Settings
+import java.util.UUID
+
+object DeviceUtils {
+    private const val PREFS_NAME = "commonsdk_device_prefs"
+    private const val KEY_DEVICE_ID = "device_id"
+
+    @SuppressLint("HardwareIds")
+    fun getDeviceId(context: Context): String {
+        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+        var deviceId = prefs.getString(KEY_DEVICE_ID, null)
+        if (deviceId.isNullOrEmpty()) {
+            deviceId = runCatching {
+                Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
+            }.getOrNull()
+            if (deviceId.isNullOrEmpty() || deviceId == "9774d56d682e549c") {
+                deviceId = UUID.randomUUID().toString().replace("-", "")
+            }
+            prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply()
+        }
+        return deviceId
+    }
+
+    fun getPhoneModel(): String = Build.MODEL ?: "Unknown"
+    fun getPhoneVersion(): String = Build.VERSION.RELEASE ?: "Unknown"
+    fun getPhoneBrand(): String = Build.BRAND ?: "Unknown"
+
+    fun getDeviceInfo(context: Context) = DeviceInfo(
+        deviceId = getDeviceId(context),
+        phoneModel = getPhoneModel(),
+        phoneVersion = getPhoneVersion(),
+        phoneBrand = getPhoneBrand(),
+    )
+}
+
+data class DeviceInfo(
+    val deviceId: String,
+    val phoneModel: String,
+    val phoneVersion: String,
+    val phoneBrand: String,
+)
+

+ 16 - 0
AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+    <cache-path
+        name="cache"
+        path="." />
+    <files-path
+        name="files"
+        path="." />
+    <external-cache-path
+        name="external_cache"
+        path="." />
+    <external-files-path
+        name="external_files"
+        path="." />
+</paths>
+

+ 50 - 0
AndroidLibs/docs/architecture.md

@@ -0,0 +1,50 @@
+# AndroidLibs Architecture
+
+## 目标结构
+
+```text
+AndroidLibs/
+├── commonsdk-core/
+├── commonsdk-compose/
+├── lib-szyx/
+├── sample-app/
+├── plugins/
+│   └── plugin-ui/
+└── docs/
+```
+
+## 设计说明
+
+### commonsdk-core
+
+- 提供与业务无关的基础能力
+- 包含多 BaseUrl Retrofit 封装
+- 提供共享缓存 `SharedCacheManager`
+- 提供插件安装、启动、版本比较 `PluginPackageManager`
+- 提供 App 下载与安装 `AppUpdater`
+
+### commonsdk-compose
+
+- 提供 Compose 组件
+- 当前包含基础卡片与手风琴组件
+
+### lib-szyx
+
+- 承载项目专属登录逻辑
+- 登录接口、签名算法、业务 Header 均来自 `LibsDemo`
+- 登录成功后本地持久化,并同步写入共享缓存
+- 插件端支持从宿主共享缓存读取登录态
+
+### sample-app
+
+- 宿主示例
+- 打开 `lib-szyx` 登录页
+- 缓存当前用户并启动 `plugin-ui`
+- 演示插件下载与 App 更新下载
+
+### plugins/plugin-ui
+
+- 独立 APK 插件
+- 支持单独安装运行
+- 支持宿主启动时读取共享登录态
+- 支持再次打开 `lib-szyx` 登录页并更新共享会话

+ 7 - 0
AndroidLibs/gradle.properties

@@ -0,0 +1,7 @@
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.configuration-cache=true
+PUBLISH_GROUP=com.xuqm
+PUBLISH_VERSION=0.1.0-SNAPSHOT

+ 12 - 0
AndroidLibs/gradle/gradle-daemon-jvm.properties

@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21

+ 71 - 0
AndroidLibs/gradle/libs.versions.toml

@@ -0,0 +1,71 @@
+[versions]
+agp = "9.1.0"
+kotlin = "2.3.10"
+compileSdk = "36"
+targetSdk = "36"
+minSdk = "24"
+coreKtx = "1.18.0"
+lifecycle = "2.10.0"
+activityCompose = "1.13.0"
+composeBom = "2026.03.00"
+coroutines = "1.10.2"
+datastore = "1.1.7"
+retrofit = "3.0.0"
+okhttp = "5.3.2"
+gson = "2.13.2"
+jserialization = "1.9.0"
+junit4 = "4.13.2"
+androidxJunit = "1.3.0"
+espresso = "3.7.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
+
+[bundles]
+compose = [
+    "androidx-ui",
+    "androidx-ui-graphics",
+    "androidx-ui-tooling-preview",
+    "androidx-material3",
+    "androidx-material-icons-extended"
+]
+compose-debug = [
+    "androidx-ui-tooling",
+    "androidx-ui-test-manifest"
+]
+network = [
+    "retrofit",
+    "retrofit-converter-gson",
+    "okhttp",
+    "okhttp-logging",
+    "gson"
+]
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

+ 47 - 0
AndroidLibs/gradle/publishing.gradle.kts

@@ -0,0 +1,47 @@
+import org.gradle.api.publish.PublishingExtension
+import org.gradle.api.publish.maven.MavenPublication
+import org.gradle.kotlin.dsl.configure
+
+apply(plugin = "maven-publish")
+
+val publishGroup = providers.gradleProperty("PUBLISH_GROUP").getOrElse("com.xuqm")
+val publishVersion = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT")
+
+group = publishGroup
+version = publishVersion
+
+configure<PublishingExtension> {
+    publications {
+        register<MavenPublication>("release") {
+            groupId = publishGroup
+            artifactId = project.name
+            version = publishVersion
+
+            afterEvaluate {
+                from(components.findByName("release"))
+            }
+        }
+    }
+
+    repositories {
+        maven {
+            val isSnapshot = publishVersion.endsWith("SNAPSHOT")
+            name = if (isSnapshot) "xuqmSnapshot" else "xuqmRelease"
+            url = uri(
+                if (isSnapshot) {
+                    "https://nexus.xuqinmin.com/repository/android-snapshot/"
+                } else {
+                    "https://nexus.xuqinmin.com/repository/android-hosted/"
+                },
+            )
+            credentials {
+                username = providers.gradleProperty("nexus.username")
+                    .orElse(providers.environmentVariable("NEXUS_USERNAME"))
+                    .orNull
+                password = providers.gradleProperty("nexus.password")
+                    .orElse(providers.environmentVariable("NEXUS_PASSWORD"))
+                    .orNull
+            }
+        }
+    }
+}

二進制
AndroidLibs/gradle/wrapper/gradle-wrapper.jar


+ 8 - 0
AndroidLibs/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+

+ 164 - 0
AndroidLibs/gradlew

@@ -0,0 +1,164 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+PRG="$0"
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=`expr $i + 1`
+    done
+    case $i in
+        0) set -- ;;
+        1) set -- "$args0" ;;
+        2) set -- "$args0" "$args1" ;;
+        3) set -- "$args0" "$args1" "$args2" ;;
+        4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=`save "$@"`
+
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
+

+ 79 - 0
AndroidLibs/gradlew.bat

@@ -0,0 +1,79 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
+

+ 47 - 0
AndroidLibs/lib-szyx/build.gradle.kts

@@ -0,0 +1,47 @@
+plugins {
+    alias(libs.plugins.android.library)
+    alias(libs.plugins.kotlin.compose)
+    alias(libs.plugins.kotlin.serialization)
+}
+
+apply(from = rootProject.file("gradle/publishing.gradle.kts"))
+
+android {
+    namespace = "com.xuqm.szyx"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        minSdk = libs.versions.minSdk.get().toInt()
+        consumerProguardFiles("consumer-rules.pro")
+        manifestPlaceholders["authProviderAuthority"] = "com.xuqm.szyx.auth"
+        manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.szyx.sdk.cache.provider"
+        manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.szyx.sdk.fileprovider"
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
+    }
+
+    buildFeatures {
+        compose = true
+    }
+}
+
+kotlin {
+    jvmToolchain(21)
+}
+
+dependencies {
+    api(project(":commonsdk-core"))
+    api(project(":commonsdk-compose"))
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.bundles.compose)
+    implementation(libs.androidx.datastore.preferences)
+    implementation(libs.kotlinx.serialization.json)
+
+    debugImplementation(libs.bundles.compose.debug)
+}

+ 1 - 0
AndroidLibs/lib-szyx/consumer-rules.pro

@@ -0,0 +1 @@
+

+ 12 - 0
AndroidLibs/lib-szyx/src/main/AndroidManifest.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application>
+        <activity
+            android:name=".login.SzyxLoginActivity"
+            android:exported="false"
+            android:theme="@android:style/Theme.Material.NoActionBar" />
+    </application>
+</manifest>

+ 30 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt

@@ -0,0 +1,30 @@
+package com.xuqm.szyx
+
+import android.content.Context
+import com.xuqm.sdk.CoreSDK
+import com.xuqm.szyx.auth.UserSessionManager
+
+object SzyxSDK {
+    data class Config(
+        val baseUrl: String = "https://dev.51trust.com/",
+        val clientId: String = "2000111111110002",
+        val hostAppPackageName: String? = null,
+        val debugMode: Boolean = false,
+    )
+
+    private var config: Config? = null
+    private var appContext: Context? = null
+
+    fun init(context: Context, config: Config = Config()) {
+        this.appContext = context.applicationContext
+        this.config = config
+        CoreSDK.init(context, CoreSDK.SDKConfig(debugMode = config.debugMode))
+        UserSessionManager.init(context)
+    }
+
+    fun isInitialized(): Boolean = appContext != null && config != null
+
+    fun context(): Context = requireNotNull(appContext) { "SzyxSDK not initialized" }
+
+    fun requireConfig(): Config = requireNotNull(config) { "SzyxSDK not initialized" }
+}

+ 14 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt

@@ -0,0 +1,14 @@
+package com.xuqm.szyx.auth
+
+import com.xuqm.sdk.network.HttpResult
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface AuthApi {
+    @POST("am/v3/userCenter/account/getSMSVerifyCode")
+    suspend fun getSmsVerifyCode(@Body request: GetSmsCodeRequest): SmsCodeResult
+
+    @POST("am/v3/userCenter/account/login")
+    suspend fun login(@Body request: LoginRequest): HttpResult<LoginModel>
+}
+

+ 35 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt

@@ -0,0 +1,35 @@
+package com.xuqm.szyx.auth
+
+import com.google.gson.JsonObject
+import com.xuqm.sdk.network.HttpResult
+
+data class GetSmsCodeRequest(
+    val phoneNum: String,
+    val type: Int = 1,
+    val time: Long,
+    val sign: String,
+)
+
+data class LoginRequest(
+    val phoneNum: String,
+    val verifyCode: String,
+    val deviceType: String = "5",
+)
+
+data class LoginModel(
+    val sessionId: String,
+    val userId: String,
+    val userType: Int,
+    val realNameStatus: Int,
+    val enableCert: Boolean,
+    val gxLeader: Boolean,
+    val hasBindFirm: Boolean,
+    val hasFaceDetect: Boolean,
+)
+
+data class LoginSession(
+    val phone: String,
+    val loginModel: LoginModel,
+)
+
+typealias SmsCodeResult = HttpResult<JsonObject>

+ 51 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt

@@ -0,0 +1,51 @@
+package com.xuqm.szyx.auth
+
+import com.xuqm.sdk.network.HttpConfig
+import com.xuqm.sdk.network.HttpManager
+import com.xuqm.szyx.SzyxSDK
+import com.xuqm.szyx.http.BusinessHeaderInterceptor
+import com.xuqm.szyx.utils.SignUtil
+
+class AuthRepository {
+    private fun api(): AuthApi {
+        val config = SzyxSDK.requireConfig()
+        return HttpManager.getService(
+            baseUrl = config.baseUrl,
+            serviceClass = AuthApi::class.java,
+            config = HttpConfig(
+                debugMode = config.debugMode,
+                interceptors = listOf(BusinessHeaderInterceptor()),
+            ),
+        )
+    }
+
+    suspend fun getSmsCode(phone: String): Result<Unit> {
+        val timeStamp = System.currentTimeMillis()
+        val response = api().getSmsVerifyCode(
+            GetSmsCodeRequest(
+                phoneNum = phone,
+                time = timeStamp,
+                sign = SignUtil.generateSign(timeStamp),
+            ),
+        )
+        return if (response.isSuccess()) Result.success(Unit) else Result.failure(IllegalStateException(response.message ?: "获取验证码失败"))
+    }
+
+    suspend fun login(phone: String, verifyCode: String): Result<LoginSession> {
+        val response = api().login(
+            LoginRequest(
+                phoneNum = phone,
+                verifyCode = verifyCode,
+            ),
+        )
+        val model = response.data
+        return if (response.isSuccess() && model != null) {
+            val session = LoginSession(phone = phone, loginModel = model)
+            UserSessionManager.save(session)
+            Result.success(session)
+        } else {
+            Result.failure(IllegalStateException(response.message ?: "登录失败"))
+        }
+    }
+}
+

+ 107 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt

@@ -0,0 +1,107 @@
+package com.xuqm.szyx.auth
+
+import android.content.Context
+import androidx.core.content.edit
+import com.xuqm.sdk.cache.CacheKeys
+import com.xuqm.sdk.cache.SharedCacheManager
+import com.xuqm.szyx.SzyxSDK
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.Serializable
+import org.json.JSONObject
+
+object UserSessionManager {
+    private const val PREF_NAME = "szyx_user_session"
+    private const val KEY_SESSION = "login_session"
+    private lateinit var appContext: Context
+    private val json = Json { ignoreUnknownKeys = true }
+
+    @Serializable
+    private data class SessionCache(
+        val phone: String,
+        val sessionId: String,
+        val userId: String,
+        val userType: Int,
+        val realNameStatus: Int,
+        val enableCert: Boolean,
+        val gxLeader: Boolean,
+        val hasBindFirm: Boolean,
+        val hasFaceDetect: Boolean,
+    )
+
+    fun init(context: Context) {
+        appContext = context.applicationContext
+    }
+
+    fun save(session: LoginSession, syncSharedCache: Boolean = true) {
+        val cache = SessionCache(
+            phone = session.phone,
+            sessionId = session.loginModel.sessionId,
+            userId = session.loginModel.userId,
+            userType = session.loginModel.userType,
+            realNameStatus = session.loginModel.realNameStatus,
+            enableCert = session.loginModel.enableCert,
+            gxLeader = session.loginModel.gxLeader,
+            hasBindFirm = session.loginModel.hasBindFirm,
+            hasFaceDetect = session.loginModel.hasFaceDetect,
+        )
+        prefs().edit { putString(KEY_SESSION, json.encodeToString(cache)) }
+        if (syncSharedCache) {
+            val payload = JSONObject().apply {
+                put("phone", session.phone)
+                put("userId", session.loginModel.userId)
+                put("sessionId", session.loginModel.sessionId)
+                put("clientId", SzyxSDK.requireConfig().clientId)
+                put("timestamp", System.currentTimeMillis())
+            }
+            val sharedCache = SharedCacheManager.getInstance(appContext)
+            val hostPackageName = SzyxSDK.requireConfig().hostAppPackageName
+            if (!hostPackageName.isNullOrEmpty() && hostPackageName != appContext.packageName) {
+                sharedCache.putRemote(CacheKeys.CURRENT_USER, payload.toString(), appPackageName = hostPackageName)
+            } else {
+                sharedCache.put(CacheKeys.CURRENT_USER, payload.toString())
+            }
+        }
+    }
+
+    fun getSession(): LoginSession? {
+        val raw = prefs().getString(KEY_SESSION, null) ?: return null
+        return runCatching { json.decodeFromString(SessionCache.serializer(), raw) }.getOrNull()?.let {
+            LoginSession(
+                phone = it.phone,
+                loginModel = LoginModel(
+                    sessionId = it.sessionId,
+                    userId = it.userId,
+                    userType = it.userType,
+                    realNameStatus = it.realNameStatus,
+                    enableCert = it.enableCert,
+                    gxLeader = it.gxLeader,
+                    hasBindFirm = it.hasBindFirm,
+                    hasFaceDetect = it.hasFaceDetect,
+                ),
+            )
+        }
+    }
+
+    fun loadSharedSession(hostAppPackageName: String): LoginSession? {
+        val raw = SharedCacheManager.getInstance(appContext).getSync(CacheKeys.CURRENT_USER, hostAppPackageName) ?: return null
+        return runCatching {
+            val jsonObject = JSONObject(raw)
+            LoginSession(
+                phone = jsonObject.optString("phone"),
+                loginModel = LoginModel(
+                    sessionId = jsonObject.getString("sessionId"),
+                    userId = jsonObject.getString("userId"),
+                    userType = 0,
+                    realNameStatus = 0,
+                    enableCert = false,
+                    gxLeader = false,
+                    hasBindFirm = false,
+                    hasFaceDetect = false,
+                ),
+            )
+        }.getOrNull()
+    }
+
+    private fun prefs() = appContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+}

+ 37 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt

@@ -0,0 +1,37 @@
+package com.xuqm.szyx.http
+
+import com.xuqm.sdk.utils.DeviceUtils
+import com.xuqm.szyx.SzyxSDK
+import com.xuqm.szyx.auth.UserSessionManager
+import com.xuqm.szyx.utils.SignUtil
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class BusinessHeaderInterceptor : Interceptor {
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val context = SzyxSDK.context()
+        val config = SzyxSDK.requireConfig()
+        val session = UserSessionManager.getSession()
+        val timeStamp = System.currentTimeMillis()
+        val request = chain.request().newBuilder().apply {
+            header("clientId", config.clientId)
+            header("deviceType", "5")
+            header("version", "1.0.0")
+            header("deviceId", DeviceUtils.getDeviceId(context))
+            header("timeStamp", timeStamp.toString())
+            header("sign", SignUtil.generateSign(timeStamp))
+            header("phoneModel", DeviceUtils.getPhoneModel())
+            header("phoneVersion", DeviceUtils.getPhoneVersion())
+            header("phoneBrand", DeviceUtils.getPhoneBrand())
+            session?.loginModel?.sessionId?.let {
+                header("X-Access-Token", it)
+                header("sessionId", it)
+            }
+            if (chain.request().header("Content-Type") == null) {
+                header("Content-Type", "application/json")
+            }
+        }.build()
+        return chain.proceed(request)
+    }
+}
+

+ 119 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt

@@ -0,0 +1,119 @@
+package com.xuqm.szyx.login
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.xuqm.sdk.ui.ToastCenter
+import com.xuqm.szyx.SzyxSDK
+import com.xuqm.szyx.auth.AuthRepository
+import com.xuqm.szyx.auth.UserSessionManager
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class SzyxLoginActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        if (!SzyxSDK.isInitialized()) {
+            SzyxSDK.init(this)
+        }
+        ToastCenter.init(this)
+        setContent { MaterialTheme { LoginScreen { finish() } } }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LoginScreen(onSuccess: () -> Unit) {
+    val scope = rememberCoroutineScope()
+    var phone by remember { mutableStateOf("") }
+    var code by remember { mutableStateOf("") }
+    var loading by remember { mutableStateOf(false) }
+    var countdown by remember { mutableStateOf(0) }
+    val repository = remember { AuthRepository() }
+
+    LaunchedEffect(countdown) {
+        while (countdown > 0) {
+            delay(1000)
+            countdown -= 1
+        }
+    }
+
+    Scaffold(topBar = { TopAppBar(title = { Text("登录") }) }) { innerPadding ->
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(innerPadding)
+                .padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            OutlinedTextField(
+                value = phone,
+                onValueChange = { phone = it },
+                modifier = Modifier.fillMaxWidth(),
+                label = { Text("手机号") },
+            )
+            OutlinedTextField(
+                value = code,
+                onValueChange = { code = it },
+                modifier = Modifier.fillMaxWidth(),
+                label = { Text("验证码") },
+            )
+            Button(
+                onClick = {
+                    scope.launch {
+                        loading = true
+                        repository.getSmsCode(phone)
+                            .onSuccess {
+                                countdown = 60
+                                ToastCenter.show("验证码发送成功")
+                            }
+                            .onFailure { ToastCenter.show(it.message ?: "发送失败") }
+                        loading = false
+                    }
+                },
+                enabled = !loading && countdown == 0,
+                modifier = Modifier.fillMaxWidth(),
+            ) {
+                Text(if (countdown > 0) "${countdown}s 后重试" else "获取验证码")
+            }
+            Button(
+                onClick = {
+                    scope.launch {
+                        loading = true
+                        repository.login(phone, code)
+                            .onSuccess {
+                                ToastCenter.show("登录成功")
+                                onSuccess()
+                            }
+                            .onFailure { ToastCenter.show(it.message ?: "登录失败") }
+                        loading = false
+                    }
+                },
+                enabled = !loading,
+                modifier = Modifier.fillMaxWidth(),
+            ) {
+                Text("登录")
+            }
+
+            UserSessionManager.getSession()?.let {
+                Text("当前登录用户: ${it.phone}")
+                Text("userId: ${it.loginModel.userId}")
+            }
+        }
+    }
+}

+ 17 - 0
AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt

@@ -0,0 +1,17 @@
+package com.xuqm.szyx.utils
+
+import java.security.MessageDigest
+
+object SignUtil {
+    private const val MD5_KEY = "YWQ!@#"
+
+    fun generateSign(timeStamp: Long): String {
+        return md5Hex("timeStamp=${timeStamp}#$MD5_KEY")
+    }
+
+    private fun md5Hex(input: String): String {
+        val md = MessageDigest.getInstance("MD5")
+        return md.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
+    }
+}
+

+ 4 - 0
AndroidLibs/lib-szyx/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<resources>
+    <string name="lib_szyx_name">lib-szyx</string>
+</resources>
+

+ 63 - 0
AndroidLibs/plugins/plugin-ui/build.gradle.kts

@@ -0,0 +1,63 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.xuqm.plugin.ui"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        applicationId = "com.xuqm.plugin.ui"
+        minSdk = libs.versions.minSdk.get().toInt()
+        targetSdk = libs.versions.targetSdk.get().toInt()
+        versionCode = 1
+        versionName = "0.1.0"
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        manifestPlaceholders["authProviderAuthority"] = "com.xuqm.plugin.ui.auth"
+        manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.plugin.ui.sdk.cache.provider"
+        manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.plugin.ui.sdk.fileprovider"
+        buildConfigField("String", "HOST_PACKAGE", "\"com.xuqm.sample\"")
+        buildConfigField("String", "API_BASE_URL", "\"https://dev.51trust.com/\"")
+        buildConfigField("String", "CLIENT_ID", "\"2000111111110002\"")
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro",
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
+    }
+
+    buildFeatures {
+        compose = true
+        buildConfig = true
+    }
+}
+
+kotlin {
+    jvmToolchain(21)
+}
+
+dependencies {
+    implementation(project(":commonsdk-core"))
+    implementation(project(":commonsdk-compose"))
+    implementation(project(":lib-szyx"))
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.runtime.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.bundles.compose)
+
+    testImplementation(libs.junit4)
+    debugImplementation(libs.bundles.compose.debug)
+}

+ 1 - 0
AndroidLibs/plugins/plugin-ui/proguard-rules.pro

@@ -0,0 +1 @@
+

+ 24 - 0
AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@android:style/Theme.Material.NoActionBar">
+        <activity
+            android:name=".PluginUiActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <meta-data
+            android:name="pluginId"
+            android:value="plugin-ui" />
+    </application>
+
+</manifest>
+

+ 114 - 0
AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt

@@ -0,0 +1,114 @@
+package com.xuqm.plugin.ui
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.xuqm.sdk.CoreSDK
+import com.xuqm.sdk.compose.components.FeatureCard
+import com.xuqm.szyx.SzyxSDK
+import com.xuqm.szyx.auth.LoginSession
+import com.xuqm.szyx.auth.UserSessionManager
+import com.xuqm.szyx.login.SzyxLoginActivity
+
+class PluginUiActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val hostPackageName = intent.getStringExtra("hostPackageName")
+
+        CoreSDK.init(this, CoreSDK.SDKConfig(debugMode = true))
+        SzyxSDK.init(
+            this,
+            SzyxSDK.Config(
+                baseUrl = BuildConfig.API_BASE_URL,
+                clientId = BuildConfig.CLIENT_ID,
+                hostAppPackageName = hostPackageName,
+                debugMode = true,
+            ),
+        )
+
+        setContent {
+            MaterialTheme {
+                PluginUiScreen(
+                    hostPackageName = hostPackageName,
+                    onOpenLogin = { startActivity(Intent(this, SzyxLoginActivity::class.java)) },
+                )
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PluginUiScreen(
+    hostPackageName: String?,
+    onOpenLogin: () -> Unit,
+) {
+    val localSession = remember { mutableStateOf<LoginSession?>(UserSessionManager.getSession()) }
+    val sharedSession = remember { mutableStateOf<LoginSession?>(hostPackageName?.let { UserSessionManager.loadSharedSession(it) }) }
+    val lifecycleOwner = LocalLifecycleOwner.current
+
+    DisposableEffect(lifecycleOwner, hostPackageName) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                localSession.value = UserSessionManager.getSession()
+                sharedSession.value = hostPackageName?.let { UserSessionManager.loadSharedSession(it) }
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    Scaffold(topBar = { TopAppBar(title = { Text("Plugin UI") }) }) { innerPadding ->
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(innerPadding),
+            contentPadding = PaddingValues(16.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp),
+        ) {
+            item {
+                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                    Text("本地登录: ${localSession.value?.phone ?: "无"}")
+                    Text("宿主共享登录: ${sharedSession.value?.phone ?: "无"}")
+                    Button(onClick = onOpenLogin, modifier = Modifier.fillMaxWidth()) {
+                        Text("打开 lib-szyx 登录页")
+                    }
+                }
+            }
+
+            item {
+                FeatureCard(
+                    title = "插件独立运行",
+                    description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。",
+                )
+            }
+
+            item {
+                FeatureCard(
+                    title = "共享缓存",
+                    description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。",
+                )
+            }
+        }
+    }
+}

+ 12 - 0
AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt

@@ -0,0 +1,12 @@
+package com.xuqm.plugin.ui.service
+
+import com.xuqm.sdk.network.HttpResult
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.POST
+
+interface PluginUiApiService {
+    @FormUrlEncoded
+    @POST("plugin/ui/demo")
+    suspend fun demo(@Field("module") module: String): HttpResult<Map<String, String>>
+}

+ 4 - 0
AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<resources>
+    <string name="app_name">Plugin UI</string>
+</resources>
+

+ 65 - 0
AndroidLibs/sample-app/build.gradle.kts

@@ -0,0 +1,65 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.xuqm.sample"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        applicationId = "com.xuqm.sample"
+        minSdk = libs.versions.minSdk.get().toInt()
+        targetSdk = libs.versions.targetSdk.get().toInt()
+        versionCode = 1
+        versionName = "0.1.0"
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        manifestPlaceholders["authProviderAuthority"] = "com.xuqm.sample.auth"
+        manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.sample.sdk.cache.provider"
+        manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.sample.sdk.fileprovider"
+        buildConfigField("String", "UPDATE_SERVER_BASE_URL", "\"http://192.168.116.9:3000/\"")
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro",
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
+    }
+
+    buildFeatures {
+        compose = true
+        buildConfig = true
+    }
+}
+
+kotlin {
+    jvmToolchain(21)
+}
+
+dependencies {
+    implementation(project(":commonsdk-core"))
+    implementation(project(":commonsdk-compose"))
+    implementation(project(":lib-szyx"))
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.runtime.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.bundles.compose)
+
+    testImplementation(libs.junit4)
+    androidTestImplementation(libs.androidx.junit)
+    androidTestImplementation(libs.androidx.espresso.core)
+    androidTestImplementation(platform(libs.androidx.compose.bom))
+    androidTestImplementation(libs.androidx.ui.test.junit4)
+    debugImplementation(libs.bundles.compose.debug)
+}

+ 1 - 0
AndroidLibs/sample-app/proguard-rules.pro

@@ -0,0 +1 @@
+

+ 27 - 0
AndroidLibs/sample-app/src/main/AndroidManifest.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
+
+    <queries>
+        <package android:name="com.xuqm.plugin.ui" />
+    </queries>
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:usesCleartextTraffic="true"
+        android:theme="@android:style/Theme.Material.NoActionBar">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>

+ 519 - 0
AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt

@@ -0,0 +1,519 @@
+package com.xuqm.sample
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.xuqm.sdk.CoreSDK
+import com.xuqm.sdk.compose.components.AccordionGroup
+import com.xuqm.sdk.compose.components.FeatureCard
+import com.xuqm.sdk.plugin.PluginPackageManager
+import com.xuqm.sdk.ui.ToastCenter
+import com.xuqm.sdk.update.DownloadState
+import com.xuqm.sdk.update.StoragePath
+import com.xuqm.sdk.update.UpdateInfo
+import com.xuqm.sdk.update.VersionCheckResult
+import com.xuqm.sdk.update.VersionCheckStrategy
+import com.xuqm.sdk.utils.DateTimeUtils
+import com.xuqm.sample.update.UpdateRepository
+import com.xuqm.szyx.SzyxSDK
+import com.xuqm.szyx.auth.LoginSession
+import com.xuqm.szyx.auth.UserSessionManager
+import com.xuqm.szyx.login.SzyxLoginActivity
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+
+    companion object {
+        private const val PLUGIN_PACKAGE_NAME = "com.xuqm.plugin.ui"
+        private const val PLUGIN_ENTRY_ACTIVITY = "com.xuqm.plugin.ui.PluginUiActivity"
+    }
+
+    private val sessionState = mutableStateOf<LoginSession?>(null)
+    private val pluginInstalledState = mutableStateOf(false)
+    private val pluginDownloadState = mutableStateOf<DownloadState>(DownloadState.Idle)
+    private val appDownloadState = mutableStateOf<DownloadState>(DownloadState.Idle)
+    private val pendingAppUpdateState = mutableStateOf<UpdateInfo?>(null)
+
+    private var pluginDownloadTaskId: String? = null
+    private var appDownloadTaskId: String? = null
+    private var pluginDownloadJob: Job? = null
+    private var appDownloadJob: Job? = null
+    private var loginPromptedOnLaunch = false
+    private var reloadPluginAfterInstall = false
+    private val updateRepository by lazy { UpdateRepository(BuildConfig.UPDATE_SERVER_BASE_URL) }
+    private var currentPluginUpdateInfo: PluginPackageManager.PluginUpdateInfo? = null
+
+    private val packageChangedReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) {
+            val packageName = intent?.data?.schemeSpecificPart ?: return
+            if (packageName == PLUGIN_PACKAGE_NAME) {
+                refreshState()
+                ToastCenter.show("plugin-ui 安装状态已更新")
+                if (intent.action == Intent.ACTION_PACKAGE_ADDED && reloadPluginAfterInstall) {
+                    reloadPluginAfterInstall = false
+                    val pluginUpdateInfo = currentPluginUpdateInfo
+                    CoreSDK.pluginPackageManager().reloadPlugin(
+                        packageName = PLUGIN_PACKAGE_NAME,
+                        entryActivity = pluginUpdateInfo?.entryActivity ?: PLUGIN_ENTRY_ACTIVITY,
+                        extras = pluginUpdateInfo?.extras ?: mapOf("hostPackageName" to this@MainActivity.packageName),
+                    )
+                }
+            }
+        }
+    }
+
+    private val loginLauncher =
+        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+            refreshSession()
+        }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        CoreSDK.init(this, CoreSDK.SDKConfig(debugMode = true))
+        SzyxSDK.init(
+            this,
+            SzyxSDK.Config(
+                baseUrl = "https://dev.51trust.com/",
+                clientId = "2000111111110002",
+                hostAppPackageName = packageName,
+                debugMode = true,
+            ),
+        )
+        ToastCenter.init(this)
+        refreshState()
+        ensureLoginOnLaunch()
+        registerPackageChangeReceiver()
+
+        setContent {
+            MaterialTheme {
+                SampleHome(
+                    session = sessionState.value,
+                    pluginInstalled = pluginInstalledState.value,
+                    pluginDownloadState = pluginDownloadState.value,
+                    appDownloadState = appDownloadState.value,
+                    pendingAppUpdate = pendingAppUpdateState.value,
+                    onOpenLogin = ::openLogin,
+                    onOpenPlugin = ::openPlugin,
+                    onInstallPlugin = ::downloadPlugin,
+                    onCancelPluginDownload = ::cancelPluginDownload,
+                    onUpdateApp = ::downloadApp,
+                    onConfirmAppUpdate = ::confirmDownloadAppUpdate,
+                    onDismissAppUpdate = ::dismissAppUpdateDialog,
+                    onCancelAppDownload = ::cancelAppDownload,
+                    onRetryCheckPlugin = ::refreshState,
+                    onExitApp = { finish() },
+                )
+            }
+        }
+    }
+
+    override fun onDestroy() {
+        pluginDownloadJob?.cancel()
+        appDownloadJob?.cancel()
+        runCatching { unregisterReceiver(packageChangedReceiver) }
+        super.onDestroy()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        refreshState()
+    }
+
+    private fun ensureLoginOnLaunch() {
+        if (sessionState.value == null && !loginPromptedOnLaunch) {
+            loginPromptedOnLaunch = true
+            openLogin()
+        }
+    }
+
+    private fun refreshSession() {
+        sessionState.value = UserSessionManager.getSession()
+    }
+
+    private fun refreshState() {
+        refreshSession()
+        pluginInstalledState.value = CoreSDK.pluginPackageManager().isPluginInstalled(PLUGIN_PACKAGE_NAME)
+    }
+
+    private fun openLogin() {
+        loginLauncher.launch(Intent(this, SzyxLoginActivity::class.java))
+    }
+
+    private fun registerPackageChangeReceiver() {
+        val filter = IntentFilter().apply {
+            addAction(Intent.ACTION_PACKAGE_ADDED)
+            addAction(Intent.ACTION_PACKAGE_REMOVED)
+            addDataScheme("package")
+        }
+        ContextCompat.registerReceiver(
+            this,
+            packageChangedReceiver,
+            filter,
+            ContextCompat.RECEIVER_NOT_EXPORTED,
+        )
+    }
+
+    private fun openPlugin() {
+        val session = sessionState.value
+        if (session == null) {
+            ToastCenter.show("请先登录")
+            openLogin()
+            return
+        }
+
+        val pluginManager = CoreSDK.pluginPackageManager()
+        pluginManager.cacheCurrentUser(
+            userId = session.loginModel.userId,
+            sessionId = session.loginModel.sessionId,
+            clientId = SzyxSDK.requireConfig().clientId,
+            extraData = mapOf("phone" to session.phone),
+        )
+        val launched = pluginManager.startPlugin(
+            packageName = PLUGIN_PACKAGE_NAME,
+            entryActivity = PLUGIN_ENTRY_ACTIVITY,
+            extras = mapOf("hostPackageName" to packageName),
+        )
+        if (!launched) {
+            ToastCenter.show("未检测到已安装的 plugin-ui,请先下载安装")
+        }
+    }
+
+    private fun downloadPlugin() {
+        lifecycleScope.launch {
+            updateRepository.fetchLatestPluginUpdate(PLUGIN_PACKAGE_NAME)
+                .onSuccess { remoteUpdate ->
+                    val pluginUpdateInfo = remoteUpdate.copy(
+                        entryActivity = remoteUpdate.entryActivity ?: PLUGIN_ENTRY_ACTIVITY,
+                        extras = mapOf("hostPackageName" to packageName),
+                    )
+                    val checkResult = CoreSDK.pluginPackageManager().checkPluginUpdate(
+                        packageName = pluginUpdateInfo.packageName,
+                        remoteVersionCode = pluginUpdateInfo.versionCode,
+                        remoteVersionName = pluginUpdateInfo.versionName,
+                        strategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+                    )
+                    if (checkResult is VersionCheckResult.UpToDate) {
+                        ToastCenter.show("当前插件已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})")
+                        return@onSuccess
+                    }
+                    currentPluginUpdateInfo = pluginUpdateInfo
+                    val taskId = CoreSDK.pluginPackageManager().downloadPlugin(
+                        updateInfo = pluginUpdateInfo,
+                        fileName = "plugin-ui-release.apk",
+                        storagePath = StoragePath.EXTERNAL_FILES,
+                    )
+                    pluginDownloadTaskId = taskId
+                    observePluginDownload(taskId)
+                }
+                .onFailure {
+                    ToastCenter.show(it.message ?: "获取插件更新配置失败")
+                }
+        }
+    }
+
+    private fun cancelPluginDownload() {
+        val taskId = pluginDownloadTaskId ?: return
+        CoreSDK.downloadManager().cancel(taskId)
+    }
+
+    private fun downloadApp() {
+        lifecycleScope.launch {
+            updateRepository.fetchLatestAppUpdate(packageName)
+                .onSuccess { updateInfo ->
+                    when (
+                        val checkResult = CoreSDK.appUpdater().checkUpdate(
+                            updateInfo = updateInfo,
+                            strategy = VersionCheckStrategy.VERSION_CODE_OR_NAME,
+                        )
+                    ) {
+                        is VersionCheckResult.NeedUpdate -> {
+                            pendingAppUpdateState.value = updateInfo
+                        }
+                        is VersionCheckResult.UpToDate -> {
+                            ToastCenter.show("当前已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})")
+                        }
+                    }
+                }
+                .onFailure {
+                    ToastCenter.show(it.message ?: "获取 App 更新配置失败")
+                }
+        }
+    }
+
+    private fun confirmDownloadAppUpdate() {
+        val updateInfo = pendingAppUpdateState.value ?: return
+        pendingAppUpdateState.value = null
+        val taskId = CoreSDK.appUpdater().downloadUpdate(
+            updateInfo = updateInfo,
+            fileName = "sample-app-update.apk",
+            storagePath = StoragePath.EXTERNAL_FILES,
+        )
+        appDownloadTaskId = taskId
+        observeAppDownload(taskId)
+    }
+
+    private fun cancelAppDownload() {
+        val taskId = appDownloadTaskId ?: return
+        CoreSDK.downloadManager().cancel(taskId)
+    }
+
+    private fun dismissAppUpdateDialog() {
+        pendingAppUpdateState.value = null
+    }
+
+    private fun observePluginDownload(taskId: String) {
+        pluginDownloadJob?.cancel()
+        pluginDownloadJob = lifecycleScope.launch {
+            CoreSDK.downloadManager().observe(taskId)?.collect { state ->
+                pluginDownloadState.value = state
+                when (state) {
+                    is DownloadState.Success -> {
+                        ToastCenter.show("插件下载完成,准备重新加载")
+                        reloadPluginAfterInstall = true
+                        if (!CoreSDK.pluginPackageManager().loadPlugin(state.file)) {
+                            reloadPluginAfterInstall = false
+                            ToastCenter.show("插件加载拉起失败")
+                        }
+                        CoreSDK.downloadManager().clear(taskId)
+                        pluginDownloadTaskId = null
+                        pluginDownloadJob?.cancel()
+                    }
+
+                    is DownloadState.Error -> {
+                        ToastCenter.show("插件下载失败: ${state.message}")
+                        CoreSDK.downloadManager().clear(taskId)
+                        pluginDownloadTaskId = null
+                        pluginDownloadJob?.cancel()
+                    }
+                    DownloadState.Cancelled -> {
+                        ToastCenter.show("插件下载已取消")
+                        CoreSDK.downloadManager().clear(taskId)
+                        pluginDownloadTaskId = null
+                        pluginDownloadJob?.cancel()
+                    }
+                    else -> Unit
+                }
+            }
+        }
+    }
+
+    private fun observeAppDownload(taskId: String) {
+        appDownloadJob?.cancel()
+        appDownloadJob = lifecycleScope.launch {
+            CoreSDK.downloadManager().observe(taskId)?.collect { state ->
+                appDownloadState.value = state
+                when (state) {
+                    is DownloadState.Success -> {
+                        ToastCenter.show("安装包下载完成,准备安装")
+                        if (!CoreSDK.appUpdater().installApk(state.file)) {
+                            ToastCenter.show("应用安装拉起失败")
+                        }
+                        CoreSDK.downloadManager().clear(taskId)
+                        appDownloadTaskId = null
+                        appDownloadJob?.cancel()
+                    }
+
+                    is DownloadState.Error -> {
+                        ToastCenter.show("应用下载失败: ${state.message}")
+                        CoreSDK.downloadManager().clear(taskId)
+                        appDownloadTaskId = null
+                        appDownloadJob?.cancel()
+                    }
+                    DownloadState.Cancelled -> {
+                        ToastCenter.show("应用下载已取消")
+                        CoreSDK.downloadManager().clear(taskId)
+                        appDownloadTaskId = null
+                        appDownloadJob?.cancel()
+                    }
+                    else -> Unit
+                }
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SampleHome(
+    session: LoginSession?,
+    pluginInstalled: Boolean,
+    pluginDownloadState: DownloadState,
+    appDownloadState: DownloadState,
+    pendingAppUpdate: UpdateInfo?,
+    onOpenLogin: () -> Unit,
+    onOpenPlugin: () -> Unit,
+    onInstallPlugin: () -> Unit,
+    onCancelPluginDownload: () -> Unit,
+    onUpdateApp: () -> Unit,
+    onConfirmAppUpdate: () -> Unit,
+    onDismissAppUpdate: () -> Unit,
+    onCancelAppDownload: () -> Unit,
+    onRetryCheckPlugin: () -> Unit,
+    onExitApp: () -> Unit,
+) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    var lastBackPressedAt by remember { mutableLongStateOf(0L) }
+
+    DisposableEffect(lifecycleOwner) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                onRetryCheckPlugin()
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    BackHandler(enabled = session == null) {
+        val now = System.currentTimeMillis()
+        if (now - lastBackPressedAt < 2_000L) {
+            onExitApp()
+        } else {
+            lastBackPressedAt = now
+            ToastCenter.show("未登录,双击返回退出应用")
+        }
+    }
+
+    Scaffold(
+        topBar = { TopAppBar(title = { Text("Sample Host") }) },
+    ) { innerPadding ->
+        pendingAppUpdate?.let { updateInfo ->
+            AlertDialog(
+                onDismissRequest = onDismissAppUpdate,
+                title = { Text(updateInfo.title) },
+                text = {
+                    Text(
+                        "发现新版本 ${updateInfo.versionName}\n\n${updateInfo.changelog.ifBlank { "检测到可用更新,是否立即下载?" }}",
+                    )
+                },
+                confirmButton = {
+                    TextButton(onClick = onConfirmAppUpdate) {
+                        Text("立即更新")
+                    }
+                },
+                dismissButton = {
+                    TextButton(onClick = onDismissAppUpdate) {
+                        Text("稍后再说")
+                    }
+                },
+            )
+        }
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(innerPadding),
+            contentPadding = PaddingValues(16.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp),
+        ) {
+            item {
+                Card(modifier = Modifier.fillMaxWidth()) {
+                    Column(
+                        modifier = Modifier.padding(16.dp),
+                        verticalArrangement = Arrangement.spacedBy(8.dp),
+                    ) {
+                        Text("当前时间: ${DateTimeUtils.now()}")
+                        Text("登录状态: ${session?.phone ?: "未登录"}")
+                        Text("插件安装状态: ${if (pluginInstalled) "已安装" else "未安装或当前宿主不可见"}")
+                        Text("插件下载: ${pluginDownloadState.toDisplayText()}")
+                        Text("应用下载: ${appDownloadState.toDisplayText()}")
+                        Button(onClick = onOpenLogin, modifier = Modifier.fillMaxWidth()) {
+                            Text(if (session == null) "打开登录页" else "重新登录")
+                        }
+                        Button(
+                            onClick = onOpenPlugin,
+                            enabled = session != null,
+                            modifier = Modifier.fillMaxWidth(),
+                        ) {
+                            Text("启动 plugin-ui")
+                        }
+                        Button(onClick = onInstallPlugin, modifier = Modifier.fillMaxWidth()) {
+                            Text("下载并安装 plugin-ui")
+                        }
+                        if (pluginDownloadState is DownloadState.Starting || pluginDownloadState is DownloadState.Progress) {
+                            Button(onClick = onCancelPluginDownload, modifier = Modifier.fillMaxWidth()) {
+                                Text("取消插件下载")
+                            }
+                        }
+                        Button(onClick = onUpdateApp, modifier = Modifier.fillMaxWidth()) {
+                            Text("检查 App 更新")
+                        }
+                        if (appDownloadState is DownloadState.Starting || appDownloadState is DownloadState.Progress) {
+                            Button(onClick = onCancelAppDownload, modifier = Modifier.fillMaxWidth()) {
+                                Text("取消应用下载")
+                            }
+                        }
+                    }
+                }
+            }
+
+            item {
+                AccordionGroup(title = "当前方案", initiallyExpanded = true) {
+                    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                        Text("1. 未登录启动时直接进入 lib-szyx 登录页")
+                        Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度")
+                        Text("3. plugin-ui 通过宿主共享缓存拿到 sessionId 和 userId")
+                    }
+                }
+            }
+
+            item {
+                FeatureCard(
+                    title = "插件结构",
+                    description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。",
+                )
+            }
+        }
+    }
+}
+
+private fun DownloadState.toDisplayText(): String {
+    return when (this) {
+        DownloadState.Idle -> "未开始"
+        DownloadState.Starting -> "准备下载"
+        is DownloadState.Progress -> {
+            val progressText = if (progress >= 0) "$progress%" else "未知进度"
+            "$progressText (${downloadedBytes}/${totalBytes.coerceAtLeast(0)})"
+        }
+        is DownloadState.Success -> "已完成: ${file.name}"
+        DownloadState.Cancelled -> "已取消"
+        is DownloadState.Error -> "失败: $message"
+    }
+}

+ 17 - 0
AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt

@@ -0,0 +1,17 @@
+package com.xuqm.sample.update
+
+import com.xuqm.sdk.network.HttpResult
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface UpdateApi {
+    @GET("api/v1/updates/app/latest")
+    suspend fun getLatestAppUpdate(
+        @Query("packageName") packageName: String,
+    ): HttpResult<AppUpdateResponse>
+
+    @GET("api/v1/updates/plugin/latest")
+    suspend fun getLatestPluginUpdate(
+        @Query("packageName") packageName: String,
+    ): HttpResult<PluginUpdateResponse>
+}

+ 19 - 0
AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt

@@ -0,0 +1,19 @@
+package com.xuqm.sample.update
+
+data class AppUpdateResponse(
+    val packageName: String,
+    val versionCode: Int,
+    val versionName: String,
+    val title: String = "发现新版本",
+    val changelog: String = "",
+    val downloadUrl: String,
+    val forceUpdate: Boolean = false,
+)
+
+data class PluginUpdateResponse(
+    val packageName: String,
+    val versionCode: Long,
+    val versionName: String,
+    val downloadUrl: String,
+    val entryActivity: String? = null,
+)

+ 48 - 0
AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt

@@ -0,0 +1,48 @@
+package com.xuqm.sample.update
+
+import com.xuqm.sdk.network.HttpManager
+import com.xuqm.sdk.plugin.PluginPackageManager
+import com.xuqm.sdk.update.UpdateInfo
+
+class UpdateRepository(
+    private val baseUrl: String,
+) {
+    private val api: UpdateApi by lazy {
+        HttpManager.getService(baseUrl = baseUrl, serviceClass = UpdateApi::class.java)
+    }
+
+    suspend fun fetchLatestAppUpdate(packageName: String): Result<UpdateInfo> {
+        return runCatching {
+            val result = api.getLatestAppUpdate(packageName)
+            if (!result.isSuccess()) {
+                error(result.message ?: "获取 App 更新配置失败")
+            }
+            val data = result.data ?: error("App 更新配置为空")
+            UpdateInfo(
+                versionCode = data.versionCode,
+                versionName = data.versionName,
+                title = data.title,
+                changelog = data.changelog,
+                downloadUrl = data.downloadUrl,
+                forceUpdate = data.forceUpdate,
+            )
+        }
+    }
+
+    suspend fun fetchLatestPluginUpdate(packageName: String): Result<PluginPackageManager.PluginUpdateInfo> {
+        return runCatching {
+            val result = api.getLatestPluginUpdate(packageName)
+            if (!result.isSuccess()) {
+                error(result.message ?: "获取插件更新配置失败")
+            }
+            val data = result.data ?: error("插件更新配置为空")
+            PluginPackageManager.PluginUpdateInfo(
+                packageName = data.packageName,
+                versionCode = data.versionCode,
+                versionName = data.versionName,
+                downloadUrl = data.downloadUrl,
+                entryActivity = data.entryActivity,
+            )
+        }
+    }
+}

+ 4 - 0
AndroidLibs/sample-app/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<resources>
+    <string name="app_name">Sample Host</string>
+</resources>
+

+ 25 - 0
AndroidLibs/settings.gradle.kts

@@ -0,0 +1,25 @@
+pluginManagement {
+    repositories {
+        maven(url = "https://nexus.xuqinmin.com/repository/android/")
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        maven(url = "https://nexus.xuqinmin.com/repository/android/")
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "AndroidLibs"
+
+include(":commonsdk-core")
+include(":commonsdk-compose")
+include(":lib-szyx")
+include(":sample-app")
+include(":plugins:plugin-ui")

+ 31 - 0
frontend/README.md

@@ -0,0 +1,31 @@
+# frontend
+
+当前目录新增两个 Vue 3 前端项目:
+
+- `ops-platform`:运营平台,提供开放注册、版本管理、插件化开关、全量/灰度发布。
+- `admin-platform`:管理平台,负责审核运营账户、禁用账户、管理子账户权限。
+
+## 启动方式
+
+先启动后端:
+
+```bash
+cd server
+mvn -pl version-management-service spring-boot:run
+```
+
+再分别启动前端:
+
+```bash
+cd frontend/ops-platform
+npm install
+npm run dev
+```
+
+```bash
+cd frontend/admin-platform
+npm install
+npm run dev
+```
+
+前端默认请求 `http://127.0.0.1:8080`,如需调整可通过 `VITE_API_BASE_URL` 覆盖。

+ 12 - 0
frontend/admin-platform/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>管理平台</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 22 - 0
frontend/admin-platform/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "admin-platform",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "pinia": "^3.0.1",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.2.3",
+    "typescript": "^5.8.2",
+    "vite": "^6.2.2",
+    "vue-tsc": "^2.2.8"
+  }
+}

+ 14 - 0
frontend/admin-platform/src/App.vue

@@ -0,0 +1,14 @@
+<template>
+  <div class="admin-shell">
+    <header class="hero">
+      <p class="eyebrow">Vue3 · 管理平台</p>
+      <h1>运营账户管理台</h1>
+      <p class="muted">审核运营平台注册、调整账户状态,并统一管理主账户下的子账户权限。</p>
+    </header>
+    <RouterView />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { RouterView } from 'vue-router'
+</script>

+ 59 - 0
frontend/admin-platform/src/api/client.ts

@@ -0,0 +1,59 @@
+export interface ApiResponse<T> {
+  code: number
+  status: string
+  data: T
+  message: string
+}
+
+export interface Account {
+  id: string
+  accountName: string
+  contactName: string
+  email: string
+  phone: string
+  type: 'MAIN' | 'SUB'
+  status: 'PENDING' | 'ACTIVE' | 'DISABLED'
+  permissions: string[]
+  parentAccountId?: string | null
+  createdAt: string
+}
+
+export interface AccountView {
+  mainAccount: Account
+  subAccounts: Account[]
+}
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080'
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const response = await fetch(`${API_BASE_URL}${path}`, {
+    headers: {
+      'Content-Type': 'application/json',
+      ...(init?.headers ?? {}),
+    },
+    ...init,
+  })
+  const payload = (await response.json()) as ApiResponse<T>
+  if (!response.ok) {
+    throw new Error(payload.message)
+  }
+  return payload.data
+}
+
+export const api = {
+  listAccounts() {
+    return request<AccountView[]>('/api/v1/admin/accounts')
+  },
+  updateStatus(accountId: string, status: Account['status']) {
+    return request<Account>(`/api/v1/admin/accounts/${accountId}/status`, {
+      method: 'PATCH',
+      body: JSON.stringify({ status }),
+    })
+  },
+  updateSubPermissions(accountId: string, subAccountId: string, permissions: string[]) {
+    return request<Account>(`/api/v1/admin/accounts/${accountId}/sub-accounts/${subAccountId}/permissions`, {
+      method: 'PUT',
+      body: JSON.stringify({ permissions }),
+    })
+  },
+}

+ 7 - 0
frontend/admin-platform/src/main.ts

@@ -0,0 +1,7 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from './router'
+import './styles.css'
+
+createApp(App).use(createPinia()).use(router).mount('#app')

+ 7 - 0
frontend/admin-platform/src/router/index.ts

@@ -0,0 +1,7 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import AccountManagementView from '../views/AccountManagementView.vue'
+
+export default createRouter({
+  history: createWebHistory(),
+  routes: [{ path: '/', component: AccountManagementView }],
+})

+ 145 - 0
frontend/admin-platform/src/styles.css

@@ -0,0 +1,145 @@
+:root {
+  color-scheme: light;
+  font-family: "PingFang SC", "Noto Sans SC", sans-serif;
+  color: #1c2131;
+  background:
+    linear-gradient(135deg, rgba(255, 196, 92, 0.14), transparent 42%),
+    linear-gradient(225deg, rgba(33, 120, 255, 0.1), transparent 38%),
+    #f8f6f1;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+}
+
+button,
+input {
+  font: inherit;
+}
+
+.admin-shell {
+  min-height: 100vh;
+  padding: 28px;
+}
+
+.hero,
+.panel {
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.hero {
+  margin-bottom: 20px;
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.86);
+  border: 1px solid rgba(28, 33, 49, 0.08);
+  border-radius: 28px;
+  padding: 24px;
+  box-shadow: 0 18px 48px rgba(43, 57, 92, 0.08);
+}
+
+.eyebrow,
+.section-tag {
+  margin: 0 0 8px;
+  font-size: 12px;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: #876327;
+}
+
+.muted {
+  color: #667085;
+}
+
+.section-head,
+.account-card__top,
+.status-actions {
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.account-card {
+  margin-top: 24px;
+  padding-top: 24px;
+  border-top: 1px solid rgba(28, 33, 49, 0.08);
+}
+
+.permission-strip {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin: 14px 0 16px;
+}
+
+.permission-strip span,
+.badge {
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: #f3ead6;
+  font-size: 12px;
+}
+
+.badge[data-status='ACTIVE'] {
+  background: #dff7ee;
+}
+
+.badge[data-status='PENDING'] {
+  background: #fff0c2;
+}
+
+.badge[data-status='DISABLED'] {
+  background: #fbdede;
+}
+
+.table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.table th,
+.table td {
+  padding: 12px 10px;
+  text-align: left;
+  border-bottom: 1px solid rgba(28, 33, 49, 0.08);
+}
+
+input {
+  width: 100%;
+  padding: 10px 12px;
+  border-radius: 12px;
+  border: 1px solid rgba(28, 33, 49, 0.12);
+}
+
+button {
+  border: 0;
+  border-radius: 12px;
+  padding: 10px 14px;
+  cursor: pointer;
+}
+
+.primary {
+  background: linear-gradient(135deg, #132d5a, #b97d21);
+  color: white;
+}
+
+.secondary,
+.ghost {
+  background: #f4efe4;
+}
+
+@media (max-width: 900px) {
+  .section-head,
+  .account-card__top,
+  .status-actions {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+}

+ 94 - 0
frontend/admin-platform/src/views/AccountManagementView.vue

@@ -0,0 +1,94 @@
+<template>
+  <section class="panel">
+    <div class="section-head">
+      <div>
+        <p class="section-tag">账户治理</p>
+        <h2>主账户与子账户管理</h2>
+      </div>
+      <button class="secondary" @click="loadAccounts">刷新</button>
+    </div>
+
+    <div v-for="item in accounts" :key="item.mainAccount.id" class="account-card">
+      <div class="account-card__top">
+        <div>
+          <h3>{{ item.mainAccount.accountName }}</h3>
+          <p class="muted">
+            {{ item.mainAccount.contactName }} · {{ item.mainAccount.email }} · {{ item.mainAccount.phone }}
+          </p>
+        </div>
+        <div class="status-actions">
+          <span class="badge" :data-status="item.mainAccount.status">{{ item.mainAccount.status }}</span>
+          <button class="ghost" @click="updateStatus(item.mainAccount.id, 'ACTIVE')">审核通过</button>
+          <button class="ghost" @click="updateStatus(item.mainAccount.id, 'DISABLED')">禁用</button>
+        </div>
+      </div>
+
+      <div class="permission-strip">
+        <span v-for="permission in item.mainAccount.permissions" :key="permission">{{ permission }}</span>
+      </div>
+
+      <table class="table">
+        <thead>
+          <tr>
+            <th>子账户</th>
+            <th>联系人</th>
+            <th>状态</th>
+            <th>权限</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="sub in item.subAccounts" :key="sub.id">
+            <td>{{ sub.accountName }}</td>
+            <td>{{ sub.contactName }}</td>
+            <td>{{ sub.status }}</td>
+            <td>
+              <input
+                :value="permissionInputs[sub.id] ?? sub.permissions.join(',')"
+                @input="permissionInputs[sub.id] = ($event.target as HTMLInputElement).value"
+              />
+            </td>
+            <td>
+              <button class="primary" @click="savePermissions(item.mainAccount.id, sub.id)">保存权限</button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { api, type AccountView } from '../api/client'
+
+const accounts = ref<AccountView[]>([])
+const permissionInputs = reactive<Record<string, string>>({})
+
+async function loadAccounts() {
+  accounts.value = await api.listAccounts()
+  accounts.value.forEach(item => {
+    item.subAccounts.forEach(sub => {
+      permissionInputs[sub.id] = sub.permissions.join(',')
+    })
+  })
+}
+
+async function updateStatus(accountId: string, status: 'ACTIVE' | 'DISABLED') {
+  await api.updateStatus(accountId, status)
+  await loadAccounts()
+}
+
+async function savePermissions(accountId: string, subAccountId: string) {
+  const permissions = (permissionInputs[subAccountId] ?? '')
+    .split(',')
+    .map(item => item.trim())
+    .filter(Boolean)
+  await api.updateSubPermissions(accountId, subAccountId, permissions)
+  await loadAccounts()
+}
+
+onMounted(() => {
+  void loadAccounts()
+})
+</script>

+ 1 - 0
frontend/admin-platform/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 17 - 0
frontend/admin-platform/tsconfig.app.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "strict": true,
+    "jsx": "preserve",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "types": ["vite/client"],
+    "skipLibCheck": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 6 - 0
frontend/admin-platform/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" }
+  ]
+}

+ 9 - 0
frontend/admin-platform/vite.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    port: 5174,
+  },
+})

+ 12 - 0
frontend/ops-platform/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>运营平台</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 22 - 0
frontend/ops-platform/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "ops-platform",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "pinia": "^3.0.1",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.2.3",
+    "typescript": "^5.8.2",
+    "vite": "^6.2.2",
+    "vue-tsc": "^2.2.8"
+  }
+}

+ 22 - 0
frontend/ops-platform/src/App.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="shell">
+    <aside class="sidebar">
+      <div>
+        <p class="eyebrow">Vue3 · 运营平台</p>
+        <h1>版本运营工作台</h1>
+        <p class="muted">开放注册、子账户、版本管理、灰度发布都集中在这里。</p>
+      </div>
+      <nav class="nav">
+        <RouterLink to="/register">平台注册</RouterLink>
+        <RouterLink to="/versions">版本管理</RouterLink>
+      </nav>
+    </aside>
+    <main class="content">
+      <RouterView />
+    </main>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { RouterLink, RouterView } from 'vue-router'
+</script>

+ 135 - 0
frontend/ops-platform/src/api/client.ts

@@ -0,0 +1,135 @@
+export interface ApiResponse<T> {
+  code: number
+  status: string
+  data: T
+  message: string
+}
+
+export interface Account {
+  id: string
+  accountName: string
+  contactName: string
+  email: string
+  phone: string
+  status: 'PENDING' | 'ACTIVE' | 'DISABLED'
+  type: 'MAIN' | 'SUB'
+  parentAccountId?: string | null
+  permissions: string[]
+  createdAt: string
+}
+
+export interface ReleaseRecord {
+  id: string
+  packageType: 'APP' | 'PLUGIN'
+  versionCode: number
+  versionName: string
+  title: string
+  changelog: string
+  downloadUrl: string
+  uploadedFileName: string
+  status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE'
+  publishStrategy: string
+  grayRule?: {
+    hookName: string
+    groupCodes: string[]
+    quickSelectionCodes: string[]
+    userIds: string[]
+  } | null
+}
+
+export interface ApplicationDetail {
+  application: {
+    id: string
+    name: string
+    packageName: string
+    pluginPackageName: string
+    pluginManagementEnabled: boolean
+    businessModules: string[]
+  }
+  releases: ReleaseRecord[]
+}
+
+export interface AudienceUser {
+  id: string
+  nickname: string
+  phone: string
+  email: string
+  region: string
+  groupCode: string
+  groupName: string
+  quickSelectionCodes: string[]
+}
+
+export interface AudienceGroup {
+  code: string
+  name: string
+  description: string
+  userCount: number
+}
+
+export interface QuickSelection {
+  code: string
+  name: string
+  description: string
+  userCount: number
+}
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080'
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const response = await fetch(`${API_BASE_URL}${path}`, {
+    headers: {
+      'Content-Type': 'application/json',
+      ...(init?.headers ?? {}),
+    },
+    ...init,
+  })
+  const payload = (await response.json()) as ApiResponse<T>
+  if (!response.ok) {
+    throw new Error(payload.message)
+  }
+  return payload.data
+}
+
+export const api = {
+  registerAccount(payload: Pick<Account, 'accountName' | 'contactName' | 'email' | 'phone'>) {
+    return request<Account>('/api/v1/open/accounts/register', {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    })
+  },
+  listApplications() {
+    return request<ApplicationDetail[]>('/api/v1/ops/version/applications')
+  },
+  togglePluginManagement(appId: string, enabled: boolean) {
+    return request(`/api/v1/ops/version/applications/${appId}/plugin-management`, {
+      method: 'PUT',
+      body: JSON.stringify({ enabled }),
+    })
+  },
+  uploadRelease(appId: string, payload: Record<string, unknown>) {
+    return request<ReleaseRecord>(`/api/v1/ops/version/applications/${appId}/releases/upload`, {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    })
+  },
+  publishRelease(appId: string, releaseId: string, payload: Record<string, unknown>) {
+    return request<ReleaseRecord>(`/api/v1/ops/version/applications/${appId}/releases/${releaseId}/publish`, {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    })
+  },
+  listAudienceGroups() {
+    return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups')
+  },
+  listQuickSelections() {
+    return request<QuickSelection[]>('/api/v1/ops/version/audiences/quick-selections')
+  },
+  listAudienceUsers(params: { keyword?: string; groupCode?: string; quickSelectionCode?: string }) {
+    const search = new URLSearchParams()
+    Object.entries(params).forEach(([key, value]) => {
+      if (value) search.set(key, value)
+    })
+    return request<AudienceUser[]>(`/api/v1/ops/version/audiences/users?${search.toString()}`)
+  },
+}

+ 7 - 0
frontend/ops-platform/src/main.ts

@@ -0,0 +1,7 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from './router'
+import './styles.css'
+
+createApp(App).use(createPinia()).use(router).mount('#app')

+ 12 - 0
frontend/ops-platform/src/router/index.ts

@@ -0,0 +1,12 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import RegisterView from '../views/RegisterView.vue'
+import VersionManagementView from '../views/VersionManagementView.vue'
+
+export default createRouter({
+  history: createWebHistory(),
+  routes: [
+    { path: '/', redirect: '/register' },
+    { path: '/register', component: RegisterView },
+    { path: '/versions', component: VersionManagementView },
+  ],
+})

+ 221 - 0
frontend/ops-platform/src/styles.css

@@ -0,0 +1,221 @@
+:root {
+  color-scheme: light;
+  font-family: "PingFang SC", "Noto Sans SC", sans-serif;
+  line-height: 1.5;
+  color: #10233d;
+  background:
+    radial-gradient(circle at top left, rgba(31, 169, 255, 0.18), transparent 36%),
+    radial-gradient(circle at bottom right, rgba(12, 205, 180, 0.16), transparent 26%),
+    #f4f8fc;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+}
+
+button,
+input,
+textarea,
+select {
+  font: inherit;
+}
+
+.shell {
+  display: grid;
+  grid-template-columns: 280px 1fr;
+  min-height: 100vh;
+}
+
+.sidebar {
+  padding: 32px 24px;
+  background: linear-gradient(180deg, #0f2747 0%, #123863 100%);
+  color: white;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.content {
+  padding: 28px;
+}
+
+.nav {
+  display: grid;
+  gap: 12px;
+}
+
+.nav a {
+  color: rgba(255, 255, 255, 0.84);
+  text-decoration: none;
+  padding: 12px 14px;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, 0.08);
+}
+
+.nav a.router-link-active {
+  background: rgba(255, 255, 255, 0.2);
+  color: white;
+}
+
+.page {
+  display: grid;
+  gap: 20px;
+}
+
+.stack {
+  grid-template-columns: 1.25fr 1fr;
+  align-items: start;
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.84);
+  border: 1px solid rgba(16, 35, 61, 0.08);
+  border-radius: 24px;
+  padding: 24px;
+  box-shadow: 0 18px 45px rgba(22, 57, 97, 0.08);
+  backdrop-filter: blur(16px);
+}
+
+.panel.soft {
+  background: linear-gradient(160deg, rgba(217, 243, 255, 0.92), rgba(255, 255, 255, 0.9));
+}
+
+.eyebrow,
+.section-tag {
+  text-transform: uppercase;
+  letter-spacing: 0.12em;
+  font-size: 12px;
+  color: #2d78a2;
+  margin: 0 0 8px;
+}
+
+.muted {
+  color: #607188;
+}
+
+.section-head,
+.app-card__top,
+.selected-bar,
+.filters {
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.form-grid {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin-top: 20px;
+}
+
+.form-grid label,
+.filters label {
+  display: grid;
+  gap: 8px;
+  font-size: 14px;
+}
+
+.form-grid .full,
+.filters .grow {
+  grid-column: 1 / -1;
+}
+
+input,
+textarea,
+select {
+  width: 100%;
+  border-radius: 14px;
+  border: 1px solid rgba(16, 35, 61, 0.14);
+  background: white;
+  padding: 12px 14px;
+}
+
+button {
+  border: 0;
+  border-radius: 14px;
+  padding: 12px 18px;
+  cursor: pointer;
+}
+
+.primary {
+  background: linear-gradient(135deg, #0d72ff, #11b8a5);
+  color: white;
+}
+
+.secondary,
+.ghost,
+.chip-button {
+  background: #eef5fb;
+  color: #163454;
+}
+
+.app-card {
+  border-top: 1px solid rgba(16, 35, 61, 0.08);
+  margin-top: 20px;
+  padding-top: 20px;
+}
+
+.chips {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.chips span,
+.chip-button {
+  padding: 6px 10px;
+  border-radius: 999px;
+  font-size: 12px;
+}
+
+.table {
+  width: 100%;
+  border-collapse: collapse;
+  margin-top: 16px;
+}
+
+.table th,
+.table td {
+  text-align: left;
+  padding: 12px 10px;
+  border-bottom: 1px solid rgba(16, 35, 61, 0.08);
+  font-size: 14px;
+}
+
+.actions {
+  display: flex;
+  gap: 8px;
+}
+
+.toggle {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.plain-list {
+  margin: 0;
+  padding-left: 18px;
+}
+
+.success-text {
+  color: #0f7f65;
+  margin-top: 12px;
+}
+
+@media (max-width: 1100px) {
+  .shell,
+  .stack {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar {
+    gap: 24px;
+  }
+}

+ 66 - 0
frontend/ops-platform/src/views/RegisterView.vue

@@ -0,0 +1,66 @@
+<template>
+  <section class="page">
+    <div class="panel">
+      <p class="section-tag">开放注册</p>
+      <h2>运营平台主账户注册</h2>
+      <p class="muted">注册后默认进入待审核状态,管理平台可统一审核与禁用。</p>
+      <form class="form-grid" @submit.prevent="submit">
+        <label>
+          企业名称
+          <input v-model="form.accountName" required placeholder="例如:星云运营中心" />
+        </label>
+        <label>
+          联系人
+          <input v-model="form.contactName" required placeholder="请输入联系人" />
+        </label>
+        <label>
+          邮箱
+          <input v-model="form.email" required type="email" placeholder="ops@example.com" />
+        </label>
+        <label>
+          手机号
+          <input v-model="form.phone" required placeholder="13800138000" />
+        </label>
+        <button class="primary" :disabled="loading">{{ loading ? '提交中...' : '提交注册' }}</button>
+      </form>
+      <p v-if="message" class="success-text">{{ message }}</p>
+    </div>
+
+    <div class="panel soft">
+      <p class="section-tag">账号规则</p>
+      <ul class="plain-list">
+        <li>主账户由运营方自行注册。</li>
+        <li>子账户由主账户创建并赋权,权限按业务动作拆分。</li>
+        <li>版本发布、插件化开关、灰度发布都建议只给受控子账户。</li>
+      </ul>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref } from 'vue'
+import { api } from '../api/client'
+
+const form = reactive({
+  accountName: '',
+  contactName: '',
+  email: '',
+  phone: '',
+})
+
+const loading = ref(false)
+const message = ref('')
+
+async function submit() {
+  loading.value = true
+  message.value = ''
+  try {
+    const result = await api.registerAccount(form)
+    message.value = `注册成功:${result.accountName}(状态:${result.status})`
+  } catch (error) {
+    message.value = error instanceof Error ? error.message : '注册失败'
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 293 - 0
frontend/ops-platform/src/views/VersionManagementView.vue

@@ -0,0 +1,293 @@
+<template>
+  <section class="page stack">
+    <div class="panel">
+      <div class="section-head">
+        <div>
+          <p class="section-tag">业务模块</p>
+          <h2>版本管理</h2>
+        </div>
+        <button class="secondary" @click="loadAll">刷新</button>
+      </div>
+
+      <div v-for="item in applications" :key="item.application.id" class="app-card">
+        <div class="app-card__top">
+          <div>
+            <h3>{{ item.application.name }}</h3>
+            <p class="muted">{{ item.application.packageName }}</p>
+            <p class="chips">
+              <span v-for="module in item.application.businessModules" :key="module">{{ module }}</span>
+            </p>
+          </div>
+          <label class="toggle">
+            <input
+              type="checkbox"
+              :checked="item.application.pluginManagementEnabled"
+              @change="togglePlugin(item.application.id, ($event.target as HTMLInputElement).checked)"
+            />
+            插件化管理
+          </label>
+        </div>
+
+        <form class="form-grid" @submit.prevent="upload(item.application.id)">
+          <label>
+            包类型
+            <select v-model="uploadForms[item.application.id].packageType">
+              <option value="APP">App</option>
+              <option value="PLUGIN">插件</option>
+            </select>
+          </label>
+          <label>
+            版本号
+            <input v-model="uploadForms[item.application.id].versionName" placeholder="0.3.0" required />
+          </label>
+          <label>
+            版本码
+            <input v-model.number="uploadForms[item.application.id].versionCode" type="number" min="1" required />
+          </label>
+          <label>
+            包文件名
+            <input v-model="uploadForms[item.application.id].uploadedFileName" placeholder="app-release.apk" required />
+          </label>
+          <label class="full">
+            下载地址
+            <input v-model="uploadForms[item.application.id].downloadUrl" placeholder="https://example.com/app.apk" required />
+          </label>
+          <label class="full">
+            更新标题
+            <input v-model="uploadForms[item.application.id].title" placeholder="发现新版本" required />
+          </label>
+          <label class="full">
+            更新说明
+            <textarea v-model="uploadForms[item.application.id].changelog" rows="3"></textarea>
+          </label>
+          <button class="primary">上传版本包</button>
+        </form>
+
+        <table class="table">
+          <thead>
+            <tr>
+              <th>版本</th>
+              <th>类型</th>
+              <th>状态</th>
+              <th>策略</th>
+              <th>灰度目标</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="release in item.releases" :key="release.id">
+              <td>{{ release.versionName }} ({{ release.versionCode }})</td>
+              <td>{{ release.packageType }}</td>
+              <td>{{ release.status }}</td>
+              <td>{{ release.publishStrategy }}</td>
+              <td>{{ graySummary(release) }}</td>
+              <td class="actions">
+                <button class="ghost" @click="publishFull(item.application.id, release.id)">全量发布</button>
+                <button class="ghost" @click="selectGrayRelease(item.application.id, release.id)">灰度发布</button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div class="panel">
+      <div class="section-head">
+        <div>
+          <p class="section-tag">灰度发布</p>
+          <h2>用户平台 Hook 选人</h2>
+        </div>
+        <p class="muted">用户数据已脱敏,可按分组、快速选择或单个用户圈定。</p>
+      </div>
+
+      <div class="filters">
+        <label>
+          分组
+          <select v-model="filters.groupCode" @change="loadUsers">
+            <option value="">全部</option>
+            <option v-for="group in groups" :key="group.code" :value="group.code">
+              {{ group.name }} ({{ group.userCount }})
+            </option>
+          </select>
+        </label>
+        <label>
+          快速选择
+          <select v-model="filters.quickSelectionCode" @change="loadUsers">
+            <option value="">全部</option>
+            <option v-for="item in quickSelections" :key="item.code" :value="item.code">
+              {{ item.name }} ({{ item.userCount }})
+            </option>
+          </select>
+        </label>
+        <label class="grow">
+          搜索
+          <input v-model="filters.keyword" placeholder="用户 ID / 昵称 / 地区" @input="loadUsers" />
+        </label>
+      </div>
+
+      <div class="selected-bar">
+        <span>当前灰度版本:{{ selectedReleaseId || '请先点一个版本的灰度发布' }}</span>
+        <span>已选择用户:{{ selectedUsers.length }}</span>
+        <button class="primary" :disabled="!selectedReleaseId" @click="publishGray">确认灰度发布</button>
+      </div>
+
+      <div class="chips">
+        <button
+          v-for="item in quickSelections"
+          :key="item.code"
+          class="chip-button"
+          @click="applyQuickSelection(item.code)"
+        >
+          {{ item.name }}
+        </button>
+      </div>
+
+      <table class="table">
+        <thead>
+          <tr>
+            <th></th>
+            <th>用户 ID</th>
+            <th>昵称</th>
+            <th>手机号</th>
+            <th>邮箱</th>
+            <th>地区</th>
+            <th>分组</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="user in users" :key="user.id">
+            <td>
+              <input type="checkbox" :checked="selectedUsers.includes(user.id)" @change="toggleUser(user.id)" />
+            </td>
+            <td>{{ user.id }}</td>
+            <td>{{ user.nickname }}</td>
+            <td>{{ user.phone }}</td>
+            <td>{{ user.email }}</td>
+            <td>{{ user.region }}</td>
+            <td>{{ user.groupName }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type QuickSelection, type ReleaseRecord } from '../api/client'
+
+const applications = ref<ApplicationDetail[]>([])
+const groups = ref<AudienceGroup[]>([])
+const quickSelections = ref<QuickSelection[]>([])
+const users = ref<AudienceUser[]>([])
+const selectedUsers = ref<string[]>([])
+const selectedAppId = ref('')
+const selectedReleaseId = ref('')
+
+const filters = reactive({
+  keyword: '',
+  groupCode: '',
+  quickSelectionCode: '',
+})
+
+const uploadForms = reactive<Record<string, {
+  packageType: 'APP' | 'PLUGIN'
+  versionCode: number
+  versionName: string
+  title: string
+  changelog: string
+  downloadUrl: string
+  uploadedFileName: string
+  entryActivity: string
+  forceUpdate: boolean
+}>>({})
+
+function ensureForm(appId: string) {
+  if (!uploadForms[appId]) {
+    uploadForms[appId] = {
+      packageType: 'APP',
+      versionCode: 1,
+      versionName: '',
+      title: '发现新版本',
+      changelog: '',
+      downloadUrl: '',
+      uploadedFileName: '',
+      entryActivity: 'com.xuqm.plugin.ui.PluginUiActivity',
+      forceUpdate: false,
+    }
+  }
+}
+
+function graySummary(release: ReleaseRecord) {
+  if (!release.grayRule) return '全量'
+  const rule = release.grayRule
+  return `组 ${rule.groupCodes.length} / 快选 ${rule.quickSelectionCodes.length} / 用户 ${rule.userIds.length}`
+}
+
+function selectGrayRelease(appId: string, releaseId: string) {
+  selectedAppId.value = appId
+  selectedReleaseId.value = releaseId
+}
+
+async function loadAll() {
+  const [appList, groupList, quickList] = await Promise.all([
+    api.listApplications(),
+    api.listAudienceGroups(),
+    api.listQuickSelections(),
+  ])
+  applications.value = appList
+  groups.value = groupList
+  quickSelections.value = quickList
+  appList.forEach(item => ensureForm(item.application.id))
+  await loadUsers()
+}
+
+async function loadUsers() {
+  users.value = await api.listAudienceUsers(filters)
+}
+
+async function togglePlugin(appId: string, enabled: boolean) {
+  await api.togglePluginManagement(appId, enabled)
+  await loadAll()
+}
+
+async function upload(appId: string) {
+  const form = uploadForms[appId]
+  await api.uploadRelease(appId, form)
+  await loadAll()
+}
+
+async function publishFull(appId: string, releaseId: string) {
+  await api.publishRelease(appId, releaseId, { grayPublish: false })
+  await loadAll()
+}
+
+function toggleUser(userId: string) {
+  if (selectedUsers.value.includes(userId)) {
+    selectedUsers.value = selectedUsers.value.filter(item => item !== userId)
+  } else {
+    selectedUsers.value = [...selectedUsers.value, userId]
+  }
+}
+
+function applyQuickSelection(code: string) {
+  filters.quickSelectionCode = code
+  void loadUsers()
+}
+
+async function publishGray() {
+  if (!selectedReleaseId.value || !selectedAppId.value) return
+  await api.publishRelease(selectedAppId.value, selectedReleaseId.value, {
+    grayPublish: true,
+    hookName: 'user-platform-gray-hook',
+    groupCodes: filters.groupCode ? [filters.groupCode] : [],
+    quickSelectionCodes: filters.quickSelectionCode ? [filters.quickSelectionCode] : [],
+    userIds: selectedUsers.value,
+  })
+  await loadAll()
+}
+
+onMounted(() => {
+  void loadAll()
+})
+</script>

+ 1 - 0
frontend/ops-platform/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 17 - 0
frontend/ops-platform/tsconfig.app.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "strict": true,
+    "jsx": "preserve",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "types": ["vite/client"],
+    "skipLibCheck": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 6 - 0
frontend/ops-platform/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" }
+  ]
+}

+ 9 - 0
frontend/ops-platform/vite.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    port: 5173,
+  },
+})

+ 36 - 0
server/README.md

@@ -0,0 +1,36 @@
+# server
+
+当前目录用于承载和 Android 客户端配套的服务项目。
+
+现有项目:
+
+- `version-service`:旧版 Node.js 示例服务,保留用于参考。
+- `version-management-service`:新版 Spring Boot 微服务,承载运营平台注册、管理平台账号管理、版本上传、全量/灰度发布,以及 Android 客户端兼容更新接口。
+
+## 当前推荐服务
+
+```bash
+cd server
+mvn -pl version-management-service spring-boot:run
+```
+
+默认监听 `http://127.0.0.1:8080`。
+
+## 基础设施
+
+- JDK:21
+- Spring Boot:3.4.4
+- 数据库:MySQL `xuqinmin.com:3306/androidLibsServer`
+- 缓存:Redis `redisdev.xuqinmin.com:6379/0`
+
+当前版本管理、账户、灰度用户等数据使用 MySQL 持久化,灰度选人列表使用 Redis 缓存。
+
+## 已实现能力
+
+- 运营平台开放主账户注册,并支持主账户创建子账户
+- 管理平台查看运营平台账户、审核/禁用账户、调整子账户权限
+- 版本管理支持 App / 插件包上传、插件化开关、全量发布、灰度发布
+- 灰度发布通过用户平台钩子数据源获取脱敏用户列表,支持分组、快速选择、单选用户
+- 保留 Android 现有兼容接口:
+  - `GET /api/v1/updates/app/latest`
+  - `GET /api/v1/updates/plugin/latest`

+ 33 - 0
server/pom.xml

@@ -0,0 +1,33 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.xuqm</groupId>
+    <artifactId>server-parent</artifactId>
+    <version>0.1.0</version>
+    <packaging>pom</packaging>
+    <name>server-parent</name>
+    <description>Spring Boot microservices workspace for AndroidLibsGroup</description>
+
+    <modules>
+        <module>version-management-service</module>
+    </modules>
+
+    <properties>
+        <java.version>21</java.version>
+        <spring-boot.version>3.4.4</spring-boot.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+</project>

+ 12 - 0
server/settings.xml

@@ -0,0 +1,12 @@
+<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
+    <mirrors>
+        <mirror>
+            <id>central-direct</id>
+            <name>Maven Central</name>
+            <url>https://repo1.maven.org/maven2</url>
+            <mirrorOf>central</mirrorOf>
+        </mirror>
+    </mirrors>
+</settings>

+ 72 - 0
server/version-management-service/pom.xml

@@ -0,0 +1,72 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.xuqm</groupId>
+        <artifactId>server-parent</artifactId>
+        <version>0.1.0</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>version-management-service</artifactId>
+    <name>version-management-service</name>
+    <description>Version management microservice for operator/admin platforms</description>
+
+    <properties>
+        <maven.compiler.release>${java.version}</maven.compiler.release>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-cache</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.datatype</groupId>
+            <artifactId>jackson-datatype-jsr310</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <release>${java.version}</release>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 14 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java

@@ -0,0 +1,14 @@
+package com.xuqm.versionmanagement;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
+
+@EnableCaching
+@SpringBootApplication
+public class VersionManagementApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(VersionManagementApplication.class, args);
+    }
+}

+ 195 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java

@@ -0,0 +1,195 @@
+package com.xuqm.versionmanagement.config;
+
+import com.xuqm.versionmanagement.model.PlatformData;
+import com.xuqm.versionmanagement.persistence.entity.AccountEntity;
+import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity;
+import com.xuqm.versionmanagement.persistence.entity.HookGroupEntity;
+import com.xuqm.versionmanagement.persistence.entity.HookUserEntity;
+import com.xuqm.versionmanagement.persistence.entity.QuickSelectionEntity;
+import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity;
+import com.xuqm.versionmanagement.persistence.repository.AccountRepository;
+import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository;
+import com.xuqm.versionmanagement.persistence.repository.HookGroupRepository;
+import com.xuqm.versionmanagement.persistence.repository.HookUserRepository;
+import com.xuqm.versionmanagement.persistence.repository.QuickSelectionRepository;
+import com.xuqm.versionmanagement.persistence.repository.ReleaseRepository;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class DataInitializer {
+
+    @Bean
+    CommandLineRunner seedVersionManagementData(
+        AccountRepository accountRepository,
+        ApplicationRepository applicationRepository,
+        ReleaseRepository releaseRepository,
+        HookUserRepository hookUserRepository,
+        HookGroupRepository hookGroupRepository,
+        QuickSelectionRepository quickSelectionRepository
+    ) {
+        return args -> {
+            if (applicationRepository.count() > 0) {
+                return;
+            }
+
+            AccountEntity main = new AccountEntity();
+            main.setId("ACC-1001");
+            main.setAccountName("星云运营中心");
+            main.setContactName("林青");
+            main.setEmail("ops@nebula.example");
+            main.setPhone("13800138000");
+            main.setType(PlatformData.AccountType.MAIN);
+            main.setStatus(PlatformData.AccountStatus.ACTIVE);
+            main.setPermissions(List.of("version:read", "version:write", "release:publish", "subaccount:grant"));
+            main.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 0));
+
+            AccountEntity sub = new AccountEntity();
+            sub.setId("SUB-2001");
+            sub.setAccountName("星云发布子账号");
+            sub.setContactName("苏宁");
+            sub.setEmail("release@nebula.example");
+            sub.setPhone("13900139000");
+            sub.setType(PlatformData.AccountType.SUB);
+            sub.setStatus(PlatformData.AccountStatus.ACTIVE);
+            sub.setParentAccountId(main.getId());
+            sub.setPermissions(List.of("version:read", "version:write", "release:publish"));
+            sub.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 10));
+            accountRepository.saveAll(List.of(main, sub));
+
+            ApplicationEntity app = new ApplicationEntity();
+            app.setId("APP-001");
+            app.setName("宿主 App");
+            app.setPackageName("com.xuqm.sample");
+            app.setPluginPackageName("com.xuqm.plugin.ui");
+            app.setPluginManagementEnabled(true);
+            app.setBusinessModules(List.of("IM", "PUSH", "VERSION"));
+            app.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 0));
+            applicationRepository.save(app);
+
+            releaseRepository.saveAll(List.of(
+                release("REL-APP-001", app.getId(), PlatformData.PackageType.APP, "com.xuqm.sample", 2, "0.2.0",
+                    "发现新版本", "1. 新增统一下载管理\n2. 新增插件版本管理\n3. 优化宿主更新流程",
+                    "http://192.168.116.9:10223/app.apk", null, false, "sample-app-release-v0.2.0.apk",
+                    PlatformData.ReleaseStatus.PUBLISHED, "FULL", null, List.of(), List.of(), List.of(),
+                    LocalDateTime.of(2026, 3, 27, 9, 30), LocalDateTime.of(2026, 3, 27, 9, 45)),
+                release("REL-PLUGIN-001", app.getId(), PlatformData.PackageType.PLUGIN, "com.xuqm.plugin.ui", 2, "0.2.0",
+                    "插件 UI 更新", "1. 修复插件页面展示问题\n2. 优化宿主拉起体验",
+                    "http://192.168.116.9:10223/plugin-ui-release.apk", "com.xuqm.plugin.ui.PluginUiActivity", false, "plugin-ui-release-v0.2.0.apk",
+                    PlatformData.ReleaseStatus.PUBLISHED, "FULL", null, List.of(), List.of(), List.of(),
+                    LocalDateTime.of(2026, 3, 27, 9, 35), LocalDateTime.of(2026, 3, 27, 9, 50)),
+                release("REL-APP-002", app.getId(), PlatformData.PackageType.APP, "com.xuqm.sample", 3, "0.3.0-beta",
+                    "0.3.0 灰度测试", "1. 新增版本平台灰度能力\n2. 支持用户分组圈选",
+                    "http://192.168.116.9:10223/app-beta.apk", null, false, "sample-app-release-v0.3.0-beta.apk",
+                    PlatformData.ReleaseStatus.GRAYSCALE, "GRAY", "user-platform-gray-hook", List.of("beta-core"), List.of("vip-seed"), List.of("USER-1001"),
+                    LocalDateTime.of(2026, 3, 27, 10, 0), LocalDateTime.of(2026, 3, 27, 10, 15))
+            ));
+
+            hookGroupRepository.saveAll(List.of(
+                group("beta-core", "核心灰测组", "用于首批核心功能灰度发布"),
+                group("city-service", "城市服务组", "按业务城市运营维度组织"),
+                group("north-region", "北区用户组", "面向北方区域试点用户")
+            ));
+
+            quickSelectionRepository.saveAll(List.of(
+                quick("vip-seed", "VIP 种子用户", "高活跃、高容忍度用户集合"),
+                quick("east-region", "东区优先", "优先面向华东区域发布"),
+                quick("north-pilot", "北区试点", "北区单点试运营人群")
+            ));
+
+            hookUserRepository.saveAll(List.of(
+                hookUser("USER-1001", "星野小满", "13700001111", "xiaoman@example.com", "上海", "beta-core", "核心灰测组", List.of("vip-seed", "east-region")),
+                hookUser("USER-1002", "陈知远", "13600002222", "zhiyuan@example.com", "杭州", "beta-core", "核心灰测组", List.of("east-region")),
+                hookUser("USER-1003", "苏静", "13500003333", "sujing@example.com", "成都", "city-service", "城市服务组", List.of("vip-seed")),
+                hookUser("USER-1004", "王亦舟", "13400004444", "zhou@example.com", "北京", "north-region", "北区用户组", List.of("north-pilot"))
+            ));
+        };
+    }
+
+    private ReleaseEntity release(
+        String id,
+        String appId,
+        PlatformData.PackageType packageType,
+        String packageName,
+        int versionCode,
+        String versionName,
+        String title,
+        String changelog,
+        String downloadUrl,
+        String entryActivity,
+        boolean forceUpdate,
+        String uploadedFileName,
+        PlatformData.ReleaseStatus status,
+        String publishStrategy,
+        String hookName,
+        List<String> groupCodes,
+        List<String> quickSelectionCodes,
+        List<String> userIds,
+        LocalDateTime uploadedAt,
+        LocalDateTime publishedAt
+    ) {
+        ReleaseEntity entity = new ReleaseEntity();
+        entity.setId(id);
+        entity.setAppId(appId);
+        entity.setPackageType(packageType);
+        entity.setPackageName(packageName);
+        entity.setVersionCode(versionCode);
+        entity.setVersionName(versionName);
+        entity.setTitle(title);
+        entity.setChangelog(changelog);
+        entity.setDownloadUrl(downloadUrl);
+        entity.setEntryActivity(entryActivity);
+        entity.setForceUpdate(forceUpdate);
+        entity.setUploadedFileName(uploadedFileName);
+        entity.setStatus(status);
+        entity.setPublishStrategy(publishStrategy);
+        entity.setHookName(hookName);
+        entity.setGroupCodes(groupCodes);
+        entity.setQuickSelectionCodes(quickSelectionCodes);
+        entity.setUserIds(userIds);
+        entity.setUploadedAt(uploadedAt);
+        entity.setPublishedAt(publishedAt);
+        return entity;
+    }
+
+    private HookGroupEntity group(String code, String name, String description) {
+        HookGroupEntity entity = new HookGroupEntity();
+        entity.setCode(code);
+        entity.setName(name);
+        entity.setDescription(description);
+        return entity;
+    }
+
+    private QuickSelectionEntity quick(String code, String name, String description) {
+        QuickSelectionEntity entity = new QuickSelectionEntity();
+        entity.setCode(code);
+        entity.setName(name);
+        entity.setDescription(description);
+        return entity;
+    }
+
+    private HookUserEntity hookUser(
+        String id,
+        String nickname,
+        String phone,
+        String email,
+        String region,
+        String groupCode,
+        String groupName,
+        List<String> quickSelectionCodes
+    ) {
+        HookUserEntity entity = new HookUserEntity();
+        entity.setId(id);
+        entity.setNickname(nickname);
+        entity.setPhone(phone);
+        entity.setEmail(email);
+        entity.setRegion(region);
+        entity.setGroupCode(groupCode);
+        entity.setGroupName(groupName);
+        entity.setQuickSelectionCodes(quickSelectionCodes);
+        return entity;
+    }
+}

+ 32 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java

@@ -0,0 +1,32 @@
+package com.xuqm.versionmanagement.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.springframework.cache.annotation.CachingConfigurer;
+import org.springframework.cache.interceptor.SimpleKeyGenerator;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+
+@Configuration
+public class RedisConfig implements CachingConfigurer {
+
+    @Bean
+    public RedisCacheConfiguration redisCacheConfiguration() {
+        ObjectMapper objectMapper = new ObjectMapper()
+            .registerModule(new JavaTimeModule())
+            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
+        return RedisCacheConfiguration.defaultCacheConfig()
+            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
+    }
+
+    @Bean
+    @Override
+    public SimpleKeyGenerator keyGenerator() {
+        return new SimpleKeyGenerator();
+    }
+}

+ 16 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java

@@ -0,0 +1,16 @@
+package com.xuqm.versionmanagement.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+            .allowedOrigins("*")
+            .allowedMethods("GET", "POST", "PUT", "PATCH", "OPTIONS");
+    }
+}

+ 56 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java

@@ -0,0 +1,56 @@
+package com.xuqm.versionmanagement.controller;
+
+import com.xuqm.versionmanagement.model.ApiResponse;
+import com.xuqm.versionmanagement.model.PlatformData;
+import com.xuqm.versionmanagement.service.AccountService;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Validated
+@RestController
+@RequestMapping("/api/v1/admin/accounts")
+public class AdminAccountController {
+
+    private final AccountService accountService;
+
+    public AdminAccountController(AccountService accountService) {
+        this.accountService = accountService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<AccountService.AccountView>> listAccounts() {
+        return ApiResponse.success(accountService.listAccounts());
+    }
+
+    @PatchMapping("/{accountId}/status")
+    public ApiResponse<PlatformData.Account> updateStatus(
+        @PathVariable String accountId,
+        @RequestBody @Validated UpdateStatusRequest request
+    ) {
+        return ApiResponse.success(accountService.updateStatus(accountId, request.status()), "账户状态已更新");
+    }
+
+    @PutMapping("/{accountId}/sub-accounts/{subAccountId}/permissions")
+    public ApiResponse<PlatformData.Account> updateSubAccountPermissions(
+        @PathVariable String accountId,
+        @PathVariable String subAccountId,
+        @RequestBody @Validated UpdatePermissionsRequest request
+    ) {
+        PlatformData.Account account = accountService.updateSubPermissions(accountId, subAccountId, request.permissions());
+        return ApiResponse.success(account, "子账户权限已更新");
+    }
+
+    public record UpdateStatusRequest(PlatformData.AccountStatus status) {
+    }
+
+    public record UpdatePermissionsRequest(@NotEmpty(message = "不能为空") List<String> permissions) {
+    }
+}

+ 32 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java

@@ -0,0 +1,32 @@
+package com.xuqm.versionmanagement.controller;
+
+import com.xuqm.versionmanagement.model.ApiResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException exception) {
+        return ResponseEntity.badRequest().body(new ApiResponse<>(400, "400", null, exception.getMessage()));
+    }
+
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
+        String message = exception.getBindingResult().getFieldErrors().stream()
+            .findFirst()
+            .map(error -> error.getField() + " " + error.getDefaultMessage())
+            .orElse("请求参数校验失败");
+        return ResponseEntity.badRequest().body(new ApiResponse<>(400, "400", null, message));
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<ApiResponse<Void>> handleOther(Exception exception) {
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+            .body(new ApiResponse<>(500, "500", null, exception.getMessage()));
+    }
+}

+ 57 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java

@@ -0,0 +1,57 @@
+package com.xuqm.versionmanagement.controller;
+
+import com.xuqm.versionmanagement.model.ApiResponse;
+import com.xuqm.versionmanagement.model.PlatformData;
+import java.util.LinkedHashMap;
+import com.xuqm.versionmanagement.service.VersionManagementService;
+import java.util.Map;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class CompatibilityUpdateController {
+
+    private final VersionManagementService versionManagementService;
+
+    public CompatibilityUpdateController(VersionManagementService versionManagementService) {
+        this.versionManagementService = versionManagementService;
+    }
+
+    @GetMapping("/health")
+    public ApiResponse<Map<String, String>> health() {
+        return ApiResponse.success(Map.of("status", "UP"));
+    }
+
+    @GetMapping("/api/v1/updates/app/latest")
+    public ApiResponse<Map<String, Object>> latestApp(
+        @RequestParam String packageName,
+        @RequestParam(required = false) String userId
+    ) {
+        PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId);
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("packageName", release.getPackageName());
+        payload.put("versionCode", release.getVersionCode());
+        payload.put("versionName", release.getVersionName());
+        payload.put("title", release.getTitle());
+        payload.put("changelog", release.getChangelog());
+        payload.put("downloadUrl", release.getDownloadUrl());
+        payload.put("forceUpdate", release.isForceUpdate());
+        return ApiResponse.success(payload);
+    }
+
+    @GetMapping("/api/v1/updates/plugin/latest")
+    public ApiResponse<Map<String, Object>> latestPlugin(
+        @RequestParam String packageName,
+        @RequestParam(required = false) String userId
+    ) {
+        PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId);
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("packageName", release.getPackageName());
+        payload.put("versionCode", release.getVersionCode());
+        payload.put("versionName", release.getVersionName());
+        payload.put("downloadUrl", release.getDownloadUrl());
+        payload.put("entryActivity", release.getEntryActivity());
+        return ApiResponse.success(payload);
+    }
+}

+ 143 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java

@@ -0,0 +1,143 @@
+package com.xuqm.versionmanagement.controller;
+
+import com.xuqm.versionmanagement.model.ApiResponse;
+import com.xuqm.versionmanagement.model.PlatformData;
+import com.xuqm.versionmanagement.service.UserHookService;
+import com.xuqm.versionmanagement.service.VersionManagementService;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Validated
+@RestController
+@RequestMapping("/api/v1/ops/version")
+public class OpsVersionController {
+
+    private final VersionManagementService versionManagementService;
+    private final UserHookService userHookService;
+
+    public OpsVersionController(VersionManagementService versionManagementService, UserHookService userHookService) {
+        this.versionManagementService = versionManagementService;
+        this.userHookService = userHookService;
+    }
+
+    @GetMapping("/applications")
+    public ApiResponse<List<VersionManagementService.ApplicationDetail>> listApplications() {
+        return ApiResponse.success(versionManagementService.listApplications());
+    }
+
+    @PutMapping("/applications/{appId}/plugin-management")
+    public ApiResponse<PlatformData.ApplicationConfig> togglePluginManagement(
+        @PathVariable String appId,
+        @RequestBody TogglePluginManagementRequest request
+    ) {
+        PlatformData.ApplicationConfig config = versionManagementService.togglePluginManagement(appId, request.enabled());
+        return ApiResponse.success(config, "插件化能力已更新");
+    }
+
+    @PostMapping("/applications/{appId}/releases/upload")
+    public ApiResponse<PlatformData.ReleaseRecord> uploadRelease(
+        @PathVariable String appId,
+        @RequestBody @Validated UploadReleaseRequest request
+    ) {
+        PlatformData.ReleaseRecord release = versionManagementService.uploadRelease(
+            appId,
+            new VersionManagementService.UploadReleaseCommand(
+                request.packageType(),
+                request.versionCode(),
+                request.versionName(),
+                request.title(),
+                request.changelog(),
+                request.downloadUrl(),
+                request.entryActivity(),
+                request.forceUpdate(),
+                request.uploadedFileName()
+            )
+        );
+        return ApiResponse.success(release, "版本包已上传");
+    }
+
+    @PostMapping("/applications/{appId}/releases/{releaseId}/publish")
+    public ApiResponse<PlatformData.ReleaseRecord> publishRelease(
+        @PathVariable String appId,
+        @PathVariable String releaseId,
+        @RequestBody PublishReleaseRequest request
+    ) {
+        PlatformData.ReleaseRecord release = versionManagementService.publishRelease(
+            appId,
+            releaseId,
+            new VersionManagementService.PublishReleaseCommand(
+                request.grayPublish(),
+                request.hookName(),
+                request.groupCodes(),
+                request.quickSelectionCodes(),
+                request.userIds()
+            )
+        );
+        return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功");
+    }
+
+    @GetMapping("/audiences/users")
+    public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers(
+        @RequestParam(required = false) String keyword,
+        @RequestParam(required = false) String groupCode,
+        @RequestParam(required = false) String quickSelectionCode
+    ) {
+        return ApiResponse.success(userHookService.getAudienceBundle(keyword, groupCode, quickSelectionCode).users());
+    }
+
+    @GetMapping("/audiences/groups")
+    public ApiResponse<List<UserHookService.GroupSummary>> listAudienceGroups() {
+        return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).groups());
+    }
+
+    @GetMapping("/audiences/quick-selections")
+    public ApiResponse<List<UserHookService.QuickSelectionSummary>> listQuickSelections() {
+        return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections());
+    }
+
+    public record TogglePluginManagementRequest(boolean enabled) {
+    }
+
+    public record UploadReleaseRequest(
+        @NotNull(message = "不能为空") PlatformData.PackageType packageType,
+        int versionCode,
+        @NotBlank(message = "不能为空") String versionName,
+        @NotBlank(message = "不能为空") String title,
+        String changelog,
+        @NotBlank(message = "不能为空") String downloadUrl,
+        String entryActivity,
+        boolean forceUpdate,
+        @NotBlank(message = "不能为空") String uploadedFileName
+    ) {
+    }
+
+    public record PublishReleaseRequest(
+        boolean grayPublish,
+        String hookName,
+        List<String> groupCodes,
+        List<String> quickSelectionCodes,
+        List<String> userIds
+    ) {
+        public List<String> groupCodes() {
+            return groupCodes == null ? List.of() : groupCodes;
+        }
+
+        public List<String> quickSelectionCodes() {
+            return quickSelectionCodes == null ? List.of() : quickSelectionCodes;
+        }
+
+        public List<String> userIds() {
+            return userIds == null ? List.of() : userIds;
+        }
+    }
+}

Some files were not shown because too many files changed in this diff