commit 6e44428e8aaa276ba773731ac7d9de4cb1ae0ddc Author: 徐勤民 Date: Fri Mar 27 15:44:01 2026 +0800 feat: initialize android libs platform workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfbd199 --- /dev/null +++ b/.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 diff --git a/AndroidLibs/.gitignore b/AndroidLibs/.gitignore new file mode 100644 index 0000000..90a83b5 --- /dev/null +++ b/AndroidLibs/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +.gradle-home/ +.idea/ +.kotlin/ +local.properties +build/ +*/build/ +captures/ +*.iml +*.apk +*.aab diff --git a/AndroidLibs/README.md b/AndroidLibs/README.md new file mode 100644 index 0000000..b2df95e --- /dev/null +++ b/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` diff --git a/AndroidLibs/build.gradle.kts b/AndroidLibs/build.gradle.kts new file mode 100644 index 0000000..6707963 --- /dev/null +++ b/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") diff --git a/AndroidLibs/commonsdk-compose/build.gradle.kts b/AndroidLibs/commonsdk-compose/build.gradle.kts new file mode 100644 index 0000000..9cee32a --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/commonsdk-compose/consumer-rules.pro b/AndroidLibs/commonsdk-compose/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2d10029 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt new file mode 100644 index 0000000..2bf7623 --- /dev/null +++ b/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() + } + } + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt new file mode 100644 index 0000000..23c45c1 --- /dev/null +++ b/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) + } + } +} diff --git a/AndroidLibs/commonsdk-core/build.gradle.kts b/AndroidLibs/commonsdk-core/build.gradle.kts new file mode 100644 index 0000000..f031854 --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/commonsdk-core/consumer-rules.pro b/AndroidLibs/commonsdk-core/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/commonsdk-core/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml b/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..13963c9 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt new file mode 100644 index 0000000..333a259 --- /dev/null +++ b/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()) +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt new file mode 100644 index 0000000..34c2994 --- /dev/null +++ b/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() + + 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" +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt new file mode 100644 index 0000000..5bc934a --- /dev/null +++ b/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?, + selection: String?, + selectionArgs: Array?, + 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?): 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?): 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 + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt new file mode 100644 index 0000000..47972dd --- /dev/null +++ b/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(extraBufferCapacity = 32) + + val events: SharedFlow = eventsFlow + + fun post(topic: String, payload: Any? = null) { + scope.launch { eventsFlow.emit(Event(topic, payload)) } + } +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt new file mode 100644 index 0000000..052b02e --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt @@ -0,0 +1,10 @@ +package com.xuqm.sdk.network + +data class HttpResult( + 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 +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt new file mode 100644 index 0000000..4db33d5 --- /dev/null +++ b/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 = emptyList(), + val networkInterceptors: List = 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() + private val serviceCache = ConcurrentHashMap() + private var globalConfig: HttpConfig = HttpConfig() + + fun init(config: HttpConfig = HttpConfig()) { + globalConfig = config + } + + fun getService(baseUrl: String, serviceClass: Class, 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 getService(baseUrl: String, serviceClass: Class, config: HttpConfig? = null): T { + return RetrofitManager.getInstance().getService(baseUrl, serviceClass, config) + } +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt new file mode 100644 index 0000000..6e212c3 --- /dev/null +++ b/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 = 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 = 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 = 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 = 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, +) diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt new file mode 100644 index 0000000..021ff2d --- /dev/null +++ b/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() } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt new file mode 100644 index 0000000..6e4ee8f --- /dev/null +++ b/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? = 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) + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt new file mode 100644 index 0000000..9aba442 --- /dev/null +++ b/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, + 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() + + 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? = 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 + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt new file mode 100644 index 0000000..15946b9 --- /dev/null +++ b/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) + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt new file mode 100644 index 0000000..cbaea7d --- /dev/null +++ b/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) +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt new file mode 100644 index 0000000..4fe424b --- /dev/null +++ b/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, +) + diff --git a/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml b/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml new file mode 100644 index 0000000..8ee28f4 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/AndroidLibs/docs/architecture.md b/AndroidLibs/docs/architecture.md new file mode 100644 index 0000000..a7b58ce --- /dev/null +++ b/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` 登录页并更新共享会话 diff --git a/AndroidLibs/gradle.properties b/AndroidLibs/gradle.properties new file mode 100644 index 0000000..3eb9177 --- /dev/null +++ b/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 diff --git a/AndroidLibs/gradle/gradle-daemon-jvm.properties b/AndroidLibs/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/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 diff --git a/AndroidLibs/gradle/libs.versions.toml b/AndroidLibs/gradle/libs.versions.toml new file mode 100644 index 0000000..20ba47c --- /dev/null +++ b/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" } diff --git a/AndroidLibs/gradle/publishing.gradle.kts b/AndroidLibs/gradle/publishing.gradle.kts new file mode 100644 index 0000000..b8dd33f --- /dev/null +++ b/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 { + publications { + register("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 + } + } + } +} diff --git a/AndroidLibs/gradle/wrapper/gradle-wrapper.jar b/AndroidLibs/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/AndroidLibs/gradle/wrapper/gradle-wrapper.jar differ diff --git a/AndroidLibs/gradle/wrapper/gradle-wrapper.properties b/AndroidLibs/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5a1a6d4 --- /dev/null +++ b/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 + diff --git a/AndroidLibs/gradlew b/AndroidLibs/gradlew new file mode 100755 index 0000000..2920cc7 --- /dev/null +++ b/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" "$@" + diff --git a/AndroidLibs/gradlew.bat b/AndroidLibs/gradlew.bat new file mode 100644 index 0000000..7357d50 --- /dev/null +++ b/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 + diff --git a/AndroidLibs/lib-szyx/build.gradle.kts b/AndroidLibs/lib-szyx/build.gradle.kts new file mode 100644 index 0000000..c190685 --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/lib-szyx/consumer-rules.pro b/AndroidLibs/lib-szyx/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/lib-szyx/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml b/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10d5d3c --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt new file mode 100644 index 0000000..1c4413c --- /dev/null +++ b/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" } +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt new file mode 100644 index 0000000..e76eca3 --- /dev/null +++ b/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 +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt new file mode 100644 index 0000000..260873c --- /dev/null +++ b/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 diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt new file mode 100644 index 0000000..9a0c90f --- /dev/null +++ b/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 { + 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 { + 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 ?: "登录失败")) + } + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt new file mode 100644 index 0000000..97165d0 --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt new file mode 100644 index 0000000..5faa671 --- /dev/null +++ b/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) + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt new file mode 100644 index 0000000..6eb11cc --- /dev/null +++ b/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}") + } + } + } +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt new file mode 100644 index 0000000..c6913a3 --- /dev/null +++ b/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) } + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/res/values/strings.xml b/AndroidLibs/lib-szyx/src/main/res/values/strings.xml new file mode 100644 index 0000000..3634de0 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + lib-szyx + + diff --git a/AndroidLibs/plugins/plugin-ui/build.gradle.kts b/AndroidLibs/plugins/plugin-ui/build.gradle.kts new file mode 100644 index 0000000..4e9e9ee --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/plugins/plugin-ui/proguard-rules.pro b/AndroidLibs/plugins/plugin-ui/proguard-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/proguard-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml b/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..92aeadb --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt new file mode 100644 index 0000000..f7a1062 --- /dev/null +++ b/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(UserSessionManager.getSession()) } + val sharedSession = remember { mutableStateOf(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 与宿主共享并更新用户会话。", + ) + } + } + } +} diff --git a/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt new file mode 100644 index 0000000..ebf368c --- /dev/null +++ b/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> +} diff --git a/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml b/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..7c708b8 --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Plugin UI + + diff --git a/AndroidLibs/sample-app/build.gradle.kts b/AndroidLibs/sample-app/build.gradle.kts new file mode 100644 index 0000000..09d3055 --- /dev/null +++ b/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) +} diff --git a/AndroidLibs/sample-app/proguard-rules.pro b/AndroidLibs/sample-app/proguard-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/sample-app/proguard-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/sample-app/src/main/AndroidManifest.xml b/AndroidLibs/sample-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4613d20 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt new file mode 100644 index 0000000..e440525 --- /dev/null +++ b/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(null) + private val pluginInstalledState = mutableStateOf(false) + private val pluginDownloadState = mutableStateOf(DownloadState.Idle) + private val appDownloadState = mutableStateOf(DownloadState.Idle) + private val pendingAppUpdateState = mutableStateOf(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" + } +} diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt new file mode 100644 index 0000000..2fa2e83 --- /dev/null +++ b/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 + + @GET("api/v1/updates/plugin/latest") + suspend fun getLatestPluginUpdate( + @Query("packageName") packageName: String, + ): HttpResult +} diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt new file mode 100644 index 0000000..bee6678 --- /dev/null +++ b/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, +) diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt new file mode 100644 index 0000000..63adae6 --- /dev/null +++ b/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 { + 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 { + 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, + ) + } + } +} diff --git a/AndroidLibs/sample-app/src/main/res/values/strings.xml b/AndroidLibs/sample-app/src/main/res/values/strings.xml new file mode 100644 index 0000000..71f4981 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Sample Host + + diff --git a/AndroidLibs/settings.gradle.kts b/AndroidLibs/settings.gradle.kts new file mode 100644 index 0000000..4b8eb89 --- /dev/null +++ b/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") diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e03a8d4 --- /dev/null +++ b/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` 覆盖。 diff --git a/frontend/admin-platform/index.html b/frontend/admin-platform/index.html new file mode 100644 index 0000000..1e7e3b2 --- /dev/null +++ b/frontend/admin-platform/index.html @@ -0,0 +1,12 @@ + + + + + + 管理平台 + + +
+ + + diff --git a/frontend/admin-platform/package.json b/frontend/admin-platform/package.json new file mode 100644 index 0000000..71706d7 --- /dev/null +++ b/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" + } +} diff --git a/frontend/admin-platform/src/App.vue b/frontend/admin-platform/src/App.vue new file mode 100644 index 0000000..635663f --- /dev/null +++ b/frontend/admin-platform/src/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/admin-platform/src/api/client.ts b/frontend/admin-platform/src/api/client.ts new file mode 100644 index 0000000..2aa32bc --- /dev/null +++ b/frontend/admin-platform/src/api/client.ts @@ -0,0 +1,59 @@ +export interface ApiResponse { + 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(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + const payload = (await response.json()) as ApiResponse + if (!response.ok) { + throw new Error(payload.message) + } + return payload.data +} + +export const api = { + listAccounts() { + return request('/api/v1/admin/accounts') + }, + updateStatus(accountId: string, status: Account['status']) { + return request(`/api/v1/admin/accounts/${accountId}/status`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }) + }, + updateSubPermissions(accountId: string, subAccountId: string, permissions: string[]) { + return request(`/api/v1/admin/accounts/${accountId}/sub-accounts/${subAccountId}/permissions`, { + method: 'PUT', + body: JSON.stringify({ permissions }), + }) + }, +} diff --git a/frontend/admin-platform/src/main.ts b/frontend/admin-platform/src/main.ts new file mode 100644 index 0000000..e18e6cc --- /dev/null +++ b/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') diff --git a/frontend/admin-platform/src/router/index.ts b/frontend/admin-platform/src/router/index.ts new file mode 100644 index 0000000..d6a982a --- /dev/null +++ b/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 }], +}) diff --git a/frontend/admin-platform/src/styles.css b/frontend/admin-platform/src/styles.css new file mode 100644 index 0000000..02bfae1 --- /dev/null +++ b/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; + } +} diff --git a/frontend/admin-platform/src/views/AccountManagementView.vue b/frontend/admin-platform/src/views/AccountManagementView.vue new file mode 100644 index 0000000..3050c7b --- /dev/null +++ b/frontend/admin-platform/src/views/AccountManagementView.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/admin-platform/src/vite-env.d.ts b/frontend/admin-platform/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/admin-platform/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/admin-platform/tsconfig.app.json b/frontend/admin-platform/tsconfig.app.json new file mode 100644 index 0000000..c9e234a --- /dev/null +++ b/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"] +} diff --git a/frontend/admin-platform/tsconfig.json b/frontend/admin-platform/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/frontend/admin-platform/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/frontend/admin-platform/vite.config.ts b/frontend/admin-platform/vite.config.ts new file mode 100644 index 0000000..22172f4 --- /dev/null +++ b/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, + }, +}) diff --git a/frontend/ops-platform/index.html b/frontend/ops-platform/index.html new file mode 100644 index 0000000..64895ed --- /dev/null +++ b/frontend/ops-platform/index.html @@ -0,0 +1,12 @@ + + + + + + 运营平台 + + +
+ + + diff --git a/frontend/ops-platform/package.json b/frontend/ops-platform/package.json new file mode 100644 index 0000000..1f757e8 --- /dev/null +++ b/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" + } +} diff --git a/frontend/ops-platform/src/App.vue b/frontend/ops-platform/src/App.vue new file mode 100644 index 0000000..eb81c62 --- /dev/null +++ b/frontend/ops-platform/src/App.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/ops-platform/src/api/client.ts b/frontend/ops-platform/src/api/client.ts new file mode 100644 index 0000000..5423f36 --- /dev/null +++ b/frontend/ops-platform/src/api/client.ts @@ -0,0 +1,135 @@ +export interface ApiResponse { + 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(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + const payload = (await response.json()) as ApiResponse + if (!response.ok) { + throw new Error(payload.message) + } + return payload.data +} + +export const api = { + registerAccount(payload: Pick) { + return request('/api/v1/open/accounts/register', { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + listApplications() { + return request('/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) { + return request(`/api/v1/ops/version/applications/${appId}/releases/upload`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + publishRelease(appId: string, releaseId: string, payload: Record) { + return request(`/api/v1/ops/version/applications/${appId}/releases/${releaseId}/publish`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + listAudienceGroups() { + return request('/api/v1/ops/version/audiences/groups') + }, + listQuickSelections() { + return request('/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(`/api/v1/ops/version/audiences/users?${search.toString()}`) + }, +} diff --git a/frontend/ops-platform/src/main.ts b/frontend/ops-platform/src/main.ts new file mode 100644 index 0000000..e18e6cc --- /dev/null +++ b/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') diff --git a/frontend/ops-platform/src/router/index.ts b/frontend/ops-platform/src/router/index.ts new file mode 100644 index 0000000..a1fd13b --- /dev/null +++ b/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 }, + ], +}) diff --git a/frontend/ops-platform/src/styles.css b/frontend/ops-platform/src/styles.css new file mode 100644 index 0000000..4e0d01a --- /dev/null +++ b/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; + } +} diff --git a/frontend/ops-platform/src/views/RegisterView.vue b/frontend/ops-platform/src/views/RegisterView.vue new file mode 100644 index 0000000..ccdbc40 --- /dev/null +++ b/frontend/ops-platform/src/views/RegisterView.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/ops-platform/src/views/VersionManagementView.vue b/frontend/ops-platform/src/views/VersionManagementView.vue new file mode 100644 index 0000000..649894f --- /dev/null +++ b/frontend/ops-platform/src/views/VersionManagementView.vue @@ -0,0 +1,293 @@ + + + diff --git a/frontend/ops-platform/src/vite-env.d.ts b/frontend/ops-platform/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/ops-platform/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/ops-platform/tsconfig.app.json b/frontend/ops-platform/tsconfig.app.json new file mode 100644 index 0000000..c9e234a --- /dev/null +++ b/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"] +} diff --git a/frontend/ops-platform/tsconfig.json b/frontend/ops-platform/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/frontend/ops-platform/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/frontend/ops-platform/vite.config.ts b/frontend/ops-platform/vite.config.ts new file mode 100644 index 0000000..7a8771e --- /dev/null +++ b/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, + }, +}) diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d534507 --- /dev/null +++ b/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` diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..bc12baa --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,33 @@ + + 4.0.0 + + com.xuqm + server-parent + 0.1.0 + pom + server-parent + Spring Boot microservices workspace for AndroidLibsGroup + + + version-management-service + + + + 21 + 3.4.4 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + diff --git a/server/settings.xml b/server/settings.xml new file mode 100644 index 0000000..e05eb69 --- /dev/null +++ b/server/settings.xml @@ -0,0 +1,12 @@ + + + + central-direct + Maven Central + https://repo1.maven.org/maven2 + central + + + diff --git a/server/version-management-service/pom.xml b/server/version-management-service/pom.xml new file mode 100644 index 0000000..ddba477 --- /dev/null +++ b/server/version-management-service/pom.xml @@ -0,0 +1,72 @@ + + 4.0.0 + + + com.xuqm + server-parent + 0.1.0 + ../pom.xml + + + version-management-service + version-management-service + Version management microservice for operator/admin platforms + + + ${java.version} + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-validation + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + maven-compiler-plugin + + ${java.version} + + + + + diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java new file mode 100644 index 0000000..4c25104 --- /dev/null +++ b/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); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java new file mode 100644 index 0000000..7e199ac --- /dev/null +++ b/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 groupCodes, + List quickSelectionCodes, + List 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 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; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java new file mode 100644 index 0000000..ac6a9a5 --- /dev/null +++ b/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(); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java new file mode 100644 index 0000000..1b652f7 --- /dev/null +++ b/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"); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java new file mode 100644 index 0000000..e5fa53e --- /dev/null +++ b/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> listAccounts() { + return ApiResponse.success(accountService.listAccounts()); + } + + @PatchMapping("/{accountId}/status") + public ApiResponse updateStatus( + @PathVariable String accountId, + @RequestBody @Validated UpdateStatusRequest request + ) { + return ApiResponse.success(accountService.updateStatus(accountId, request.status()), "账户状态已更新"); + } + + @PutMapping("/{accountId}/sub-accounts/{subAccountId}/permissions") + public ApiResponse 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 permissions) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java new file mode 100644 index 0000000..7b9ee65 --- /dev/null +++ b/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> handleIllegalArgument(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(new ApiResponse<>(400, "400", null, exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleOther(Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ApiResponse<>(500, "500", null, exception.getMessage())); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java new file mode 100644 index 0000000..69bfcc3 --- /dev/null +++ b/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> health() { + return ApiResponse.success(Map.of("status", "UP")); + } + + @GetMapping("/api/v1/updates/app/latest") + public ApiResponse> latestApp( + @RequestParam String packageName, + @RequestParam(required = false) String userId + ) { + PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId); + Map 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> latestPlugin( + @RequestParam String packageName, + @RequestParam(required = false) String userId + ) { + PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId); + Map 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); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java new file mode 100644 index 0000000..0c9b63a --- /dev/null +++ b/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> listApplications() { + return ApiResponse.success(versionManagementService.listApplications()); + } + + @PutMapping("/applications/{appId}/plugin-management") + public ApiResponse 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 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 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> 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> listAudienceGroups() { + return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).groups()); + } + + @GetMapping("/audiences/quick-selections") + public ApiResponse> 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 groupCodes, + List quickSelectionCodes, + List userIds + ) { + public List groupCodes() { + return groupCodes == null ? List.of() : groupCodes; + } + + public List quickSelectionCodes() { + return quickSelectionCodes == null ? List.of() : quickSelectionCodes; + } + + public List userIds() { + return userIds == null ? List.of() : userIds; + } + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java new file mode 100644 index 0000000..8aa9c1d --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java @@ -0,0 +1,74 @@ +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.Email; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping +public class PublicAccountController { + + private final AccountService accountService; + + public PublicAccountController(AccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("/api/v1/open/accounts/register") + public ApiResponse register(@RequestBody @Validated RegisterRequest request) { + PlatformData.Account account = accountService.registerMainAccount( + new AccountService.RegisterAccountCommand( + request.accountName(), + request.contactName(), + request.email(), + request.phone() + ) + ); + return ApiResponse.success(account, "运营平台注册成功,等待管理平台审核"); + } + + @PostMapping("/api/v1/ops/accounts/{accountId}/sub-accounts") + public ApiResponse createSubAccount( + @PathVariable String accountId, + @RequestBody @Validated CreateSubAccountRequest request + ) { + PlatformData.Account account = accountService.createSubAccount( + accountId, + new AccountService.CreateSubAccountCommand( + request.accountName(), + request.contactName(), + request.email(), + request.phone(), + request.permissions() + ) + ); + return ApiResponse.success(account, "子账户创建成功"); + } + + public record RegisterRequest( + @NotBlank(message = "不能为空") String accountName, + @NotBlank(message = "不能为空") String contactName, + @Email(message = "格式不正确") String email, + @NotBlank(message = "不能为空") String phone + ) { + } + + public record CreateSubAccountRequest( + @NotBlank(message = "不能为空") String accountName, + @NotBlank(message = "不能为空") String contactName, + @Email(message = "格式不正确") String email, + @NotBlank(message = "不能为空") String phone, + List permissions + ) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java new file mode 100644 index 0000000..3582856 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java @@ -0,0 +1,12 @@ +package com.xuqm.versionmanagement.model; + +public record ApiResponse(int code, String status, T data, String message) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "0", data, "success"); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(200, "0", data, message); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java new file mode 100644 index 0000000..58a6060 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java @@ -0,0 +1,574 @@ +package com.xuqm.versionmanagement.model; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class PlatformData { + + private List accounts = new ArrayList<>(); + private List applications = new ArrayList<>(); + private List releases = new ArrayList<>(); + private List hookUsers = new ArrayList<>(); + private List hookGroups = new ArrayList<>(); + private List hookQuickSelections = new ArrayList<>(); + + public List getAccounts() { + return accounts; + } + + public void setAccounts(List accounts) { + this.accounts = accounts; + } + + public List getApplications() { + return applications; + } + + public void setApplications(List applications) { + this.applications = applications; + } + + public List getReleases() { + return releases; + } + + public void setReleases(List releases) { + this.releases = releases; + } + + public List getHookUsers() { + return hookUsers; + } + + public void setHookUsers(List hookUsers) { + this.hookUsers = hookUsers; + } + + public List getHookGroups() { + return hookGroups; + } + + public void setHookGroups(List hookGroups) { + this.hookGroups = hookGroups; + } + + public List getHookQuickSelections() { + return hookQuickSelections; + } + + public void setHookQuickSelections(List hookQuickSelections) { + this.hookQuickSelections = hookQuickSelections; + } + + public enum AccountType { + MAIN, + SUB + } + + public enum AccountStatus { + PENDING, + ACTIVE, + DISABLED + } + + public enum PackageType { + APP, + PLUGIN + } + + public enum ReleaseStatus { + DRAFT, + PUBLISHED, + GRAYSCALE + } + + public static class Account { + private String id; + private String accountName; + private String contactName; + private String email; + private String phone; + private AccountType type; + private AccountStatus status; + private String parentAccountId; + private List permissions = new ArrayList<>(); + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public AccountType getType() { + return type; + } + + public void setType(AccountType type) { + this.type = type; + } + + public AccountStatus getStatus() { + return status; + } + + public void setStatus(AccountStatus status) { + this.status = status; + } + + public String getParentAccountId() { + return parentAccountId; + } + + public void setParentAccountId(String parentAccountId) { + this.parentAccountId = parentAccountId; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + } + + public static class ApplicationConfig { + private String id; + private String name; + private String packageName; + private String pluginPackageName; + private boolean pluginManagementEnabled; + private List businessModules = new ArrayList<>(); + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPluginPackageName() { + return pluginPackageName; + } + + public void setPluginPackageName(String pluginPackageName) { + this.pluginPackageName = pluginPackageName; + } + + public boolean isPluginManagementEnabled() { + return pluginManagementEnabled; + } + + public void setPluginManagementEnabled(boolean pluginManagementEnabled) { + this.pluginManagementEnabled = pluginManagementEnabled; + } + + public List getBusinessModules() { + return businessModules; + } + + public void setBusinessModules(List businessModules) { + this.businessModules = businessModules; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + } + + public static class ReleaseRecord { + private String id; + private String appId; + private PackageType packageType; + private String packageName; + private int versionCode; + private String versionName; + private String title; + private String changelog; + private String downloadUrl; + private String entryActivity; + private boolean forceUpdate; + private String uploadedFileName; + private ReleaseStatus status; + private String publishStrategy; + private GrayRule grayRule; + private LocalDateTime uploadedAt; + private LocalDateTime publishedAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public PackageType getPackageType() { + return packageType; + } + + public void setPackageType(PackageType packageType) { + this.packageType = packageType; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getChangelog() { + return changelog; + } + + public void setChangelog(String changelog) { + this.changelog = changelog; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public String getEntryActivity() { + return entryActivity; + } + + public void setEntryActivity(String entryActivity) { + this.entryActivity = entryActivity; + } + + public boolean isForceUpdate() { + return forceUpdate; + } + + public void setForceUpdate(boolean forceUpdate) { + this.forceUpdate = forceUpdate; + } + + public String getUploadedFileName() { + return uploadedFileName; + } + + public void setUploadedFileName(String uploadedFileName) { + this.uploadedFileName = uploadedFileName; + } + + public ReleaseStatus getStatus() { + return status; + } + + public void setStatus(ReleaseStatus status) { + this.status = status; + } + + public String getPublishStrategy() { + return publishStrategy; + } + + public void setPublishStrategy(String publishStrategy) { + this.publishStrategy = publishStrategy; + } + + public GrayRule getGrayRule() { + return grayRule; + } + + public void setGrayRule(GrayRule grayRule) { + this.grayRule = grayRule; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(LocalDateTime uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public LocalDateTime getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(LocalDateTime publishedAt) { + this.publishedAt = publishedAt; + } + } + + public static class GrayRule { + private String hookName; + private List groupCodes = new ArrayList<>(); + private List quickSelectionCodes = new ArrayList<>(); + private List userIds = new ArrayList<>(); + + public String getHookName() { + return hookName; + } + + public void setHookName(String hookName) { + this.hookName = hookName; + } + + public List getGroupCodes() { + return groupCodes; + } + + public void setGroupCodes(List groupCodes) { + this.groupCodes = groupCodes; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + + public List getUserIds() { + return userIds; + } + + public void setUserIds(List userIds) { + this.userIds = userIds; + } + } + + public static class HookUser { + private String id; + private String nickname; + private String phone; + private String email; + private String region; + private String groupCode; + private String groupName; + private List quickSelectionCodes = new ArrayList<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getGroupCode() { + return groupCode; + } + + public void setGroupCode(String groupCode) { + this.groupCode = groupCode; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + } + + public static class HookGroup { + private String code; + private String name; + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } + + public static class QuickSelection { + private String code; + private String name; + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java new file mode 100644 index 0000000..70b684e --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java @@ -0,0 +1,130 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import com.xuqm.versionmanagement.model.PlatformData; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_account") +public class AccountEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String accountName; + + @Column(nullable = false, length = 64) + private String contactName; + + @Column(nullable = false, length = 128) + private String email; + + @Column(nullable = false, length = 32) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.AccountType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.AccountStatus status; + + @Column(length = 64) + private String parentAccountId; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List permissions; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public PlatformData.AccountType getType() { + return type; + } + + public void setType(PlatformData.AccountType type) { + this.type = type; + } + + public PlatformData.AccountStatus getStatus() { + return status; + } + + public void setStatus(PlatformData.AccountStatus status) { + this.status = status; + } + + public String getParentAccountId() { + return parentAccountId; + } + + public void setParentAccountId(String parentAccountId) { + this.parentAccountId = parentAccountId; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java new file mode 100644 index 0000000..dc39ec8 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java @@ -0,0 +1,92 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_application") +public class ApplicationEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String name; + + @Column(nullable = false, length = 128, unique = true) + private String packageName; + + @Column(length = 128) + private String pluginPackageName; + + @Column(nullable = false) + private boolean pluginManagementEnabled; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 512) + private List businessModules; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPluginPackageName() { + return pluginPackageName; + } + + public void setPluginPackageName(String pluginPackageName) { + this.pluginPackageName = pluginPackageName; + } + + public boolean isPluginManagementEnabled() { + return pluginManagementEnabled; + } + + public void setPluginManagementEnabled(boolean pluginManagementEnabled) { + this.pluginManagementEnabled = pluginManagementEnabled; + } + + public List getBusinessModules() { + return businessModules; + } + + public void setBusinessModules(List businessModules) { + this.businessModules = businessModules; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java new file mode 100644 index 0000000..1d0012c --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java @@ -0,0 +1,44 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "vm_hook_group") +public class HookGroupEntity { + + @Id + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java new file mode 100644 index 0000000..bfa7871 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java @@ -0,0 +1,102 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.List; + +@Entity +@Table(name = "vm_hook_user") +public class HookUserEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String nickname; + + @Column(nullable = false, length = 32) + private String phone; + + @Column(nullable = false, length = 128) + private String email; + + @Column(length = 64) + private String region; + + @Column(length = 64) + private String groupCode; + + @Column(length = 128) + private String groupName; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List quickSelectionCodes; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getGroupCode() { + return groupCode; + } + + public void setGroupCode(String groupCode) { + this.groupCode = groupCode; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java new file mode 100644 index 0000000..518f57c --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java @@ -0,0 +1,44 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "vm_quick_selection") +public class QuickSelectionEntity { + + @Id + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java new file mode 100644 index 0000000..9f56266 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java @@ -0,0 +1,241 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import com.xuqm.versionmanagement.model.PlatformData; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_release") +public class ReleaseEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.PackageType packageType; + + @Column(nullable = false, length = 128) + private String packageName; + + @Column(nullable = false) + private int versionCode; + + @Column(nullable = false, length = 64) + private String versionName; + + @Column(nullable = false, length = 128) + private String title; + + @Column(length = 4000) + private String changelog; + + @Column(nullable = false, length = 512) + private String downloadUrl; + + @Column(length = 256) + private String entryActivity; + + @Column(nullable = false) + private boolean forceUpdate; + + @Column(nullable = false, length = 256) + private String uploadedFileName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.ReleaseStatus status; + + @Column(nullable = false, length = 32) + private String publishStrategy; + + @Column(length = 128) + private String hookName; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List groupCodes; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List quickSelectionCodes; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 2000) + private List userIds; + + @Column(nullable = false) + private LocalDateTime uploadedAt; + + private LocalDateTime publishedAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public PlatformData.PackageType getPackageType() { + return packageType; + } + + public void setPackageType(PlatformData.PackageType packageType) { + this.packageType = packageType; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getChangelog() { + return changelog; + } + + public void setChangelog(String changelog) { + this.changelog = changelog; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public String getEntryActivity() { + return entryActivity; + } + + public void setEntryActivity(String entryActivity) { + this.entryActivity = entryActivity; + } + + public boolean isForceUpdate() { + return forceUpdate; + } + + public void setForceUpdate(boolean forceUpdate) { + this.forceUpdate = forceUpdate; + } + + public String getUploadedFileName() { + return uploadedFileName; + } + + public void setUploadedFileName(String uploadedFileName) { + this.uploadedFileName = uploadedFileName; + } + + public PlatformData.ReleaseStatus getStatus() { + return status; + } + + public void setStatus(PlatformData.ReleaseStatus status) { + this.status = status; + } + + public String getPublishStrategy() { + return publishStrategy; + } + + public void setPublishStrategy(String publishStrategy) { + this.publishStrategy = publishStrategy; + } + + public String getHookName() { + return hookName; + } + + public void setHookName(String hookName) { + this.hookName = hookName; + } + + public List getGroupCodes() { + return groupCodes; + } + + public void setGroupCodes(List groupCodes) { + this.groupCodes = groupCodes; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + + public List getUserIds() { + return userIds; + } + + public void setUserIds(List userIds) { + this.userIds = userIds; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(LocalDateTime uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public LocalDateTime getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(LocalDateTime publishedAt) { + this.publishedAt = publishedAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java new file mode 100644 index 0000000..9268694 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java @@ -0,0 +1,29 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.Arrays; +import java.util.List; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return ""; + } + return String.join(",", attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return List.of(); + } + return Arrays.stream(dbData.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .toList(); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java new file mode 100644 index 0000000..26518d3 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java @@ -0,0 +1,13 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AccountRepository extends JpaRepository { + + List findByTypeOrderByCreatedAtAsc(PlatformData.AccountType type); + + List findByParentAccountIdOrderByCreatedAtAsc(String parentAccountId); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java new file mode 100644 index 0000000..9ab953d --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java new file mode 100644 index 0000000..23448ca --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.HookGroupEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HookGroupRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java new file mode 100644 index 0000000..12786af --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.HookUserEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HookUserRepository extends JpaRepository { + + List findByIdIn(List ids); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java new file mode 100644 index 0000000..3dcdf5e --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.QuickSelectionEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuickSelectionRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java new file mode 100644 index 0000000..a170a36 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java @@ -0,0 +1,16 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReleaseRepository extends JpaRepository { + + List findByAppIdOrderByUploadedAtDesc(String appId); + + List findByPackageNameAndPackageType(String packageName, PlatformData.PackageType packageType); + + Optional findByIdAndAppId(String id, String appId); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java new file mode 100644 index 0000000..b6bd169 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java @@ -0,0 +1,107 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import com.xuqm.versionmanagement.persistence.repository.AccountRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountService { + + private final AccountRepository accountRepository; + private final PlatformMapper platformMapper; + + public AccountService(AccountRepository accountRepository, PlatformMapper platformMapper) { + this.accountRepository = accountRepository; + this.platformMapper = platformMapper; + } + + @Transactional + public PlatformData.Account registerMainAccount(RegisterAccountCommand command) { + AccountEntity entity = new AccountEntity(); + entity.setId(nextId("ACC")); + entity.setAccountName(command.accountName()); + entity.setContactName(command.contactName()); + entity.setEmail(command.email()); + entity.setPhone(command.phone()); + entity.setType(PlatformData.AccountType.MAIN); + entity.setStatus(PlatformData.AccountStatus.PENDING); + entity.setPermissions(List.of("version:read", "version:write", "release:publish", "subaccount:grant")); + entity.setCreatedAt(LocalDateTime.now()); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + @Transactional + public PlatformData.Account createSubAccount(String parentAccountId, CreateSubAccountCommand command) { + findAccount(parentAccountId); + AccountEntity entity = new AccountEntity(); + entity.setId(nextId("SUB")); + entity.setAccountName(command.accountName()); + entity.setContactName(command.contactName()); + entity.setEmail(command.email()); + entity.setPhone(command.phone()); + entity.setType(PlatformData.AccountType.SUB); + entity.setStatus(PlatformData.AccountStatus.ACTIVE); + entity.setParentAccountId(parentAccountId); + entity.setPermissions(command.permissions()); + entity.setCreatedAt(LocalDateTime.now()); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + public List listAccounts() { + return accountRepository.findByTypeOrderByCreatedAtAsc(PlatformData.AccountType.MAIN).stream() + .map(platformMapper::toAccount) + .map(account -> new AccountView( + account, + accountRepository.findByParentAccountIdOrderByCreatedAtAsc(account.getId()).stream() + .map(platformMapper::toAccount) + .toList() + )) + .toList(); + } + + @Transactional + public PlatformData.Account updateStatus(String accountId, PlatformData.AccountStatus status) { + AccountEntity entity = findAccount(accountId); + entity.setStatus(status); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + @Transactional + public PlatformData.Account updateSubPermissions(String parentAccountId, String subAccountId, List permissions) { + findAccount(parentAccountId); + AccountEntity entity = accountRepository.findById(subAccountId) + .filter(item -> parentAccountId.equals(item.getParentAccountId())) + .orElseThrow(() -> new IllegalArgumentException("子账户不存在")); + entity.setPermissions(permissions); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + private AccountEntity findAccount(String accountId) { + return accountRepository.findById(accountId) + .orElseThrow(() -> new IllegalArgumentException("账户不存在")); + } + + private String nextId(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + public record RegisterAccountCommand(String accountName, String contactName, String email, String phone) { + } + + public record CreateSubAccountCommand( + String accountName, + String contactName, + String email, + String phone, + List permissions + ) { + } + + public record AccountView(PlatformData.Account mainAccount, List subAccounts) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java new file mode 100644 index 0000000..251dc35 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java @@ -0,0 +1,122 @@ +package com.xuqm.versionmanagement.service; + +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 java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PlatformMapper { + + public PlatformData.Account toAccount(AccountEntity entity) { + PlatformData.Account account = new PlatformData.Account(); + account.setId(entity.getId()); + account.setAccountName(entity.getAccountName()); + account.setContactName(entity.getContactName()); + account.setEmail(entity.getEmail()); + account.setPhone(entity.getPhone()); + account.setType(entity.getType()); + account.setStatus(entity.getStatus()); + account.setParentAccountId(entity.getParentAccountId()); + account.setPermissions(entity.getPermissions()); + account.setCreatedAt(entity.getCreatedAt()); + return account; + } + + public PlatformData.ApplicationConfig toApplication(ApplicationEntity entity) { + PlatformData.ApplicationConfig config = new PlatformData.ApplicationConfig(); + config.setId(entity.getId()); + config.setName(entity.getName()); + config.setPackageName(entity.getPackageName()); + config.setPluginPackageName(entity.getPluginPackageName()); + config.setPluginManagementEnabled(entity.isPluginManagementEnabled()); + config.setBusinessModules(entity.getBusinessModules()); + config.setCreatedAt(entity.getCreatedAt()); + return config; + } + + public PlatformData.ReleaseRecord toRelease(ReleaseEntity entity) { + PlatformData.ReleaseRecord release = new PlatformData.ReleaseRecord(); + release.setId(entity.getId()); + release.setAppId(entity.getAppId()); + release.setPackageType(entity.getPackageType()); + release.setPackageName(entity.getPackageName()); + release.setVersionCode(entity.getVersionCode()); + release.setVersionName(entity.getVersionName()); + release.setTitle(entity.getTitle()); + release.setChangelog(entity.getChangelog()); + release.setDownloadUrl(entity.getDownloadUrl()); + release.setEntryActivity(entity.getEntryActivity()); + release.setForceUpdate(entity.isForceUpdate()); + release.setUploadedFileName(entity.getUploadedFileName()); + release.setStatus(entity.getStatus()); + release.setPublishStrategy(entity.getPublishStrategy()); + if (entity.getHookName() != null || !entity.getGroupCodes().isEmpty() || !entity.getQuickSelectionCodes().isEmpty() || !entity.getUserIds().isEmpty()) { + PlatformData.GrayRule grayRule = new PlatformData.GrayRule(); + grayRule.setHookName(entity.getHookName()); + grayRule.setGroupCodes(entity.getGroupCodes()); + grayRule.setQuickSelectionCodes(entity.getQuickSelectionCodes()); + grayRule.setUserIds(entity.getUserIds()); + release.setGrayRule(grayRule); + } + release.setUploadedAt(entity.getUploadedAt()); + release.setPublishedAt(entity.getPublishedAt()); + return release; + } + + public PlatformData.HookUser toHookUser(HookUserEntity entity) { + PlatformData.HookUser user = new PlatformData.HookUser(); + user.setId(entity.getId()); + user.setNickname(entity.getNickname()); + user.setPhone(entity.getPhone()); + user.setEmail(entity.getEmail()); + user.setRegion(entity.getRegion()); + user.setGroupCode(entity.getGroupCode()); + user.setGroupName(entity.getGroupName()); + user.setQuickSelectionCodes(entity.getQuickSelectionCodes()); + return user; + } + + public PlatformData.HookGroup toHookGroup(HookGroupEntity entity) { + PlatformData.HookGroup group = new PlatformData.HookGroup(); + group.setCode(entity.getCode()); + group.setName(entity.getName()); + group.setDescription(entity.getDescription()); + return group; + } + + public PlatformData.QuickSelection toQuickSelection(QuickSelectionEntity entity) { + PlatformData.QuickSelection selection = new PlatformData.QuickSelection(); + selection.setCode(entity.getCode()); + selection.setName(entity.getName()); + selection.setDescription(entity.getDescription()); + return selection; + } + + public ReleaseEntity toReleaseEntity(String appId, PlatformData.ApplicationConfig app, VersionManagementService.UploadReleaseCommand command, String id) { + ReleaseEntity entity = new ReleaseEntity(); + entity.setId(id); + entity.setAppId(appId); + entity.setPackageType(command.packageType()); + entity.setPackageName(command.packageType() == PlatformData.PackageType.PLUGIN ? app.getPluginPackageName() : app.getPackageName()); + entity.setVersionCode(command.versionCode()); + entity.setVersionName(command.versionName()); + entity.setTitle(command.title()); + entity.setChangelog(command.changelog()); + entity.setDownloadUrl(command.downloadUrl()); + entity.setEntryActivity(command.entryActivity()); + entity.setForceUpdate(command.forceUpdate()); + entity.setUploadedFileName(command.uploadedFileName()); + entity.setStatus(PlatformData.ReleaseStatus.DRAFT); + entity.setPublishStrategy("NONE"); + entity.setGroupCodes(List.of()); + entity.setQuickSelectionCodes(List.of()); + entity.setUserIds(List.of()); + return entity; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java new file mode 100644 index 0000000..bbd4ef3 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java @@ -0,0 +1,187 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.repository.HookGroupRepository; +import com.xuqm.versionmanagement.persistence.repository.HookUserRepository; +import com.xuqm.versionmanagement.persistence.repository.QuickSelectionRepository; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class UserHookService { + + private final HookUserRepository hookUserRepository; + private final HookGroupRepository hookGroupRepository; + private final QuickSelectionRepository quickSelectionRepository; + private final PlatformMapper platformMapper; + + public UserHookService( + HookUserRepository hookUserRepository, + HookGroupRepository hookGroupRepository, + QuickSelectionRepository quickSelectionRepository, + PlatformMapper platformMapper + ) { + this.hookUserRepository = hookUserRepository; + this.hookGroupRepository = hookGroupRepository; + this.quickSelectionRepository = quickSelectionRepository; + this.platformMapper = platformMapper; + } + + @Cacheable(cacheNames = "audienceBundle", key = "T(String).valueOf(#keyword).concat('|').concat(T(String).valueOf(#groupCode)).concat('|').concat(T(String).valueOf(#quickSelectionCode))") + public AudienceBundle getAudienceBundle(String keyword, String groupCode, String quickSelectionCode) { + List sourceUsers = hookUserRepository.findAll().stream() + .map(platformMapper::toHookUser) + .toList(); + + List users = sourceUsers.stream() + .filter(user -> isBlank(groupCode) || groupCode.equalsIgnoreCase(user.getGroupCode())) + .filter(user -> isBlank(quickSelectionCode) || user.getQuickSelectionCodes().contains(quickSelectionCode)) + .filter(user -> matchKeyword(user, keyword)) + .sorted(Comparator.comparing(PlatformData.HookUser::getGroupCode).thenComparing(PlatformData.HookUser::getId)) + .map(this::mask) + .toList(); + + List groups = hookGroupRepository.findAll().stream() + .map(platformMapper::toHookGroup) + .map(group -> new GroupSummary( + group.getCode(), + group.getName(), + group.getDescription(), + sourceUsers.stream().filter(user -> Objects.equals(user.getGroupCode(), group.getCode())).count() + )) + .toList(); + + List quickSelections = quickSelectionRepository.findAll().stream() + .map(platformMapper::toQuickSelection) + .map(item -> new QuickSelectionSummary( + item.getCode(), + item.getName(), + item.getDescription(), + sourceUsers.stream().filter(user -> user.getQuickSelectionCodes().contains(item.getCode())).count() + )) + .toList(); + + return new AudienceBundle(users, groups, quickSelections); + } + + public List findUsersByIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return hookUserRepository.findByIdIn(userIds).stream() + .map(platformMapper::toHookUser) + .toList(); + } + + @CacheEvict(cacheNames = "audienceBundle", allEntries = true) + public void evictAudienceCache() { + } + + public boolean matchesGrayRule(PlatformData.HookUser user, PlatformData.GrayRule grayRule) { + if (grayRule == null || user == null) { + return false; + } + if (grayRule.getUserIds().contains(user.getId())) { + return true; + } + if (grayRule.getGroupCodes().contains(user.getGroupCode())) { + return true; + } + return grayRule.getQuickSelectionCodes().stream() + .anyMatch(code -> user.getQuickSelectionCodes().contains(code)); + } + + private boolean matchKeyword(PlatformData.HookUser user, String keyword) { + if (isBlank(keyword)) { + return true; + } + String normalized = keyword.toLowerCase(Locale.ROOT).trim(); + return Arrays.asList(user.getId(), user.getNickname(), user.getPhone(), user.getEmail(), user.getRegion()) + .stream() + .filter(Objects::nonNull) + .map(value -> value.toLowerCase(Locale.ROOT)) + .anyMatch(value -> value.contains(normalized)); + } + + private MaskedUser mask(PlatformData.HookUser user) { + return new MaskedUser( + maskId(user.getId()), + maskNickname(user.getNickname()), + maskPhone(user.getPhone()), + maskEmail(user.getEmail()), + user.getRegion(), + user.getGroupCode(), + user.getGroupName(), + user.getQuickSelectionCodes() + ); + } + + private String maskId(String id) { + if (id == null || id.length() <= 4) { + return "****"; + } + return id.substring(0, 2) + "****" + id.substring(id.length() - 2); + } + + private String maskNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + return "匿名用户"; + } + if (nickname.length() <= 2) { + return nickname.charAt(0) + "*"; + } + return nickname.charAt(0) + "**" + nickname.charAt(nickname.length() - 1); + } + + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return "***********"; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private String maskEmail(String email) { + if (email == null || !email.contains("@")) { + return "***"; + } + String[] parts = email.split("@", 2); + String name = parts[0]; + String maskedName = name.length() <= 2 ? name.charAt(0) + "*" : name.substring(0, 2) + "***"; + return maskedName + "@" + parts[1]; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + public record AudienceBundle( + List users, + List groups, + List quickSelections + ) { + } + + public record MaskedUser( + String id, + String nickname, + String phone, + String email, + String region, + String groupCode, + String groupName, + List quickSelectionCodes + ) { + } + + public record GroupSummary(String code, String name, String description, long userCount) { + } + + public record QuickSelectionSummary(String code, String name, String description, long userCount) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java new file mode 100644 index 0000000..6f6c10b --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java @@ -0,0 +1,147 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository; +import com.xuqm.versionmanagement.persistence.repository.ReleaseRepository; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class VersionManagementService { + + private final ApplicationRepository applicationRepository; + private final ReleaseRepository releaseRepository; + private final UserHookService userHookService; + private final PlatformMapper platformMapper; + + public VersionManagementService( + ApplicationRepository applicationRepository, + ReleaseRepository releaseRepository, + UserHookService userHookService, + PlatformMapper platformMapper + ) { + this.applicationRepository = applicationRepository; + this.releaseRepository = releaseRepository; + this.userHookService = userHookService; + this.platformMapper = platformMapper; + } + + public List listApplications() { + return applicationRepository.findAll().stream() + .map(platformMapper::toApplication) + .map(app -> new ApplicationDetail( + app, + releaseRepository.findByAppIdOrderByUploadedAtDesc(app.getId()).stream() + .map(platformMapper::toRelease) + .toList() + )) + .toList(); + } + + @Transactional + public PlatformData.ApplicationConfig togglePluginManagement(String appId, boolean enabled) { + ApplicationEntity entity = findApplicationEntity(appId); + entity.setPluginManagementEnabled(enabled); + return platformMapper.toApplication(applicationRepository.save(entity)); + } + + @Transactional + public PlatformData.ReleaseRecord uploadRelease(String appId, UploadReleaseCommand command) { + PlatformData.ApplicationConfig app = platformMapper.toApplication(findApplicationEntity(appId)); + ReleaseEntity entity = platformMapper.toReleaseEntity(appId, app, command, nextId("REL")); + entity.setUploadedAt(LocalDateTime.now()); + return platformMapper.toRelease(releaseRepository.save(entity)); + } + + @Transactional + public PlatformData.ReleaseRecord publishRelease(String appId, String releaseId, PublishReleaseCommand command) { + findApplicationEntity(appId); + ReleaseEntity release = findReleaseEntity(appId, releaseId); + if (command.grayPublish()) { + List matchedUsers = userHookService.findUsersByIds(command.userIds()); + if (!command.userIds().isEmpty() && matchedUsers.size() != command.userIds().size()) { + throw new IllegalArgumentException("存在未命中的灰度用户"); + } + release.setHookName(command.hookName()); + release.setGroupCodes(command.groupCodes()); + release.setQuickSelectionCodes(command.quickSelectionCodes()); + release.setUserIds(command.userIds()); + release.setStatus(PlatformData.ReleaseStatus.GRAYSCALE); + release.setPublishStrategy("GRAY"); + } else { + release.setHookName(null); + release.setGroupCodes(List.of()); + release.setQuickSelectionCodes(List.of()); + release.setUserIds(List.of()); + release.setStatus(PlatformData.ReleaseStatus.PUBLISHED); + release.setPublishStrategy("FULL"); + } + release.setPublishedAt(LocalDateTime.now()); + return platformMapper.toRelease(releaseRepository.save(release)); + } + + public PlatformData.ReleaseRecord getLatestAppRelease(String packageName, String userId) { + return selectLatestRelease(packageName, PlatformData.PackageType.APP, userId); + } + + public PlatformData.ReleaseRecord getLatestPluginRelease(String packageName, String userId) { + return selectLatestRelease(packageName, PlatformData.PackageType.PLUGIN, userId); + } + + private PlatformData.ReleaseRecord selectLatestRelease(String packageName, PlatformData.PackageType packageType, String userId) { + PlatformData.HookUser user = userId == null ? null : userHookService.findUsersByIds(List.of(userId)).stream().findFirst().orElse(null); + + return releaseRepository.findByPackageNameAndPackageType(packageName, packageType).stream() + .map(platformMapper::toRelease) + .filter(release -> release.getStatus() == PlatformData.ReleaseStatus.PUBLISHED + || (release.getStatus() == PlatformData.ReleaseStatus.GRAYSCALE && userHookService.matchesGrayRule(user, release.getGrayRule()))) + .max(Comparator.comparingInt(PlatformData.ReleaseRecord::getVersionCode) + .thenComparing(PlatformData.ReleaseRecord::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder()))) + .orElseThrow(() -> new IllegalArgumentException("版本配置不存在")); + } + + private ApplicationEntity findApplicationEntity(String appId) { + return applicationRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("应用不存在")); + } + + private ReleaseEntity findReleaseEntity(String appId, String releaseId) { + return releaseRepository.findByIdAndAppId(releaseId, appId) + .orElseThrow(() -> new IllegalArgumentException("版本不存在")); + } + + private String nextId(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + public record ApplicationDetail(PlatformData.ApplicationConfig application, List releases) { + } + + public record UploadReleaseCommand( + PlatformData.PackageType packageType, + int versionCode, + String versionName, + String title, + String changelog, + String downloadUrl, + String entryActivity, + boolean forceUpdate, + String uploadedFileName + ) { + } + + public record PublishReleaseCommand( + boolean grayPublish, + String hookName, + List groupCodes, + List quickSelectionCodes, + List userIds + ) { + } +} diff --git a/server/version-management-service/src/main/resources/application.yml b/server/version-management-service/src/main/resources/application.yml new file mode 100644 index 0000000..13440a9 --- /dev/null +++ b/server/version-management-service/src/main/resources/application.yml @@ -0,0 +1,32 @@ +server: + port: 8080 + +spring: + application: + name: version-management-service + datasource: + url: jdbc:mysql://xuqinmin.com:3306/androidLibsServer?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: androidLibsServer + password: iXc8rHydtzRpYFHJ + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + properties: + hibernate: + format_sql: true + data: + redis: + host: redisdev.xuqinmin.com + port: 6379 + database: 0 + password: xuqinmin1022 + cache: + type: redis + redis: + time-to-live: 10m + +logging: + level: + org.hibernate.SQL: info diff --git a/server/version-service/README.md b/server/version-service/README.md new file mode 100644 index 0000000..60e98ed --- /dev/null +++ b/server/version-service/README.md @@ -0,0 +1,68 @@ +# version-service + +用于给 `sample-app` 和 `plugin-ui` 提供统一的版本管理接口。 + +## 启动 + +```bash +cd __server__/version-service +npm start +``` + +默认监听: + +```text +http://0.0.0.0:3000 +``` + +## 接口 + +### 健康检查 + +```bash +curl http://127.0.0.1:3000/health +``` + +### 获取 App 最新版本 + +```bash +curl "http://127.0.0.1:3000/api/v1/updates/app/latest?packageName=com.xuqm.sample" +``` + +### 获取插件最新版本 + +```bash +curl "http://127.0.0.1:3000/api/v1/updates/plugin/latest?packageName=com.xuqm.plugin.ui" +``` + +### 更新 App 配置 + +```bash +curl --location --request PUT "http://127.0.0.1:3000/api/v1/admin/updates/app" \ +--header "Content-Type: application/json" \ +--data '{ + "packageName": "com.xuqm.sample", + "versionCode": 2, + "versionName": "0.2.0", + "title": "发现新版本", + "changelog": "更新内容", + "downloadUrl": "http://192.168.116.9:10223/app.apk", + "forceUpdate": false +}' +``` + +### 更新插件配置 + +```bash +curl --location --request PUT "http://127.0.0.1:3000/api/v1/admin/updates/plugin" \ +--header "Content-Type: application/json" \ +--data '{ + "packageName": "com.xuqm.plugin.ui", + "versionCode": 2, + "versionName": "0.2.0", + "downloadUrl": "http://192.168.116.9:10223/plugin-ui-release.apk", + "entryActivity": "com.xuqm.plugin.ui.PluginUiActivity" +}' +``` + +版本数据存放在 [`data/version-config.json`](./data/version-config.json)。 diff --git a/server/version-service/data/version-config.json b/server/version-service/data/version-config.json new file mode 100644 index 0000000..f1ad3e9 --- /dev/null +++ b/server/version-service/data/version-config.json @@ -0,0 +1,22 @@ +{ + "apps": { + "com.xuqm.sample": { + "packageName": "com.xuqm.sample", + "versionCode": 2, + "versionName": "0.2.0", + "title": "发现新版本", + "changelog": "1. 新增统一下载管理\n2. 新增插件版本管理\n3. 优化宿主更新流程", + "downloadUrl": "http://192.168.116.9:10223/app.apk", + "forceUpdate": false + } + }, + "plugins": { + "com.xuqm.plugin.ui": { + "packageName": "com.xuqm.plugin.ui", + "versionCode": 2, + "versionName": "0.2.0", + "downloadUrl": "http://192.168.116.9:10223/plugin-ui-release.apk", + "entryActivity": "com.xuqm.plugin.ui.PluginUiActivity" + } + } +} diff --git a/server/version-service/package.json b/server/version-service/package.json new file mode 100644 index 0000000..df6a709 --- /dev/null +++ b/server/version-service/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xuqm/version-service", + "version": "0.1.0", + "private": true, + "description": "Version service for Android host app and plugin update management", + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server/version-service/src/index.js b/server/version-service/src/index.js new file mode 100644 index 0000000..d19aad9 --- /dev/null +++ b/server/version-service/src/index.js @@ -0,0 +1,141 @@ +const http = require("http"); +const fs = require("fs/promises"); +const path = require("path"); +const { URL } = require("url"); + +const HOST = process.env.HOST || "0.0.0.0"; +const PORT = Number(process.env.PORT || 3000); +const DATA_FILE = path.join(__dirname, "..", "data", "version-config.json"); + +async function readConfig() { + const raw = await fs.readFile(DATA_FILE, "utf8"); + return JSON.parse(raw); +} + +async function writeConfig(config) { + await fs.writeFile(DATA_FILE, JSON.stringify(config, null, 2), "utf8"); +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + res.end(JSON.stringify(payload)); +} + +function ok(data, message = "success") { + return { code: 200, status: "0", data, message }; +} + +function fail(statusCode, message) { + return { + statusCode, + payload: { code: statusCode, status: String(statusCode), data: null, message } + }; +} + +async function parseBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +async function handleGetLatestApp(url, res) { + const packageName = url.searchParams.get("packageName"); + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const app = config.apps[packageName]; + if (!app) { + const result = fail(404, `app config not found for ${packageName}`); + return sendJson(res, result.statusCode, result.payload); + } + return sendJson(res, 200, ok(app)); +} + +async function handleGetLatestPlugin(url, res) { + const packageName = url.searchParams.get("packageName"); + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const plugin = config.plugins[packageName]; + if (!plugin) { + const result = fail(404, `plugin config not found for ${packageName}`); + return sendJson(res, result.statusCode, result.payload); + } + return sendJson(res, 200, ok(plugin)); +} + +async function handleUpdateConfig(kind, req, res) { + const body = await parseBody(req); + const packageName = body.packageName; + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const target = kind === "app" ? config.apps : config.plugins; + target[packageName] = body; + await writeConfig(config); + return sendJson(res, 200, ok(body, `${kind} config updated`)); +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + return res.end(); + } + + if (req.method === "GET" && url.pathname === "/health") { + return sendJson(res, 200, ok({ status: "UP" })); + } + + if (req.method === "GET" && url.pathname === "/api/v1/updates/app/latest") { + return handleGetLatestApp(url, res); + } + + if (req.method === "GET" && url.pathname === "/api/v1/updates/plugin/latest") { + return handleGetLatestPlugin(url, res); + } + + if (req.method === "PUT" && url.pathname === "/api/v1/admin/updates/app") { + return handleUpdateConfig("app", req, res); + } + + if (req.method === "PUT" && url.pathname === "/api/v1/admin/updates/plugin") { + return handleUpdateConfig("plugin", req, res); + } + + const result = fail(404, "route not found"); + return sendJson(res, result.statusCode, result.payload); + } catch (error) { + return sendJson(res, 500, { + code: 500, + status: "500", + data: null, + message: error.message || "internal server error" + }); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`version-service is running at http://${HOST}:${PORT}`); +});