feat: initialize android libs platform workspace

这个提交包含在:
徐勤民 2026-03-27 15:44:01 +08:00
当前提交 6e44428e8a
共有 125 个文件被更改,包括 7424 次插入0 次删除

14
.gitignore vendored 普通文件
查看文件

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

11
AndroidLibs/.gitignore vendored 普通文件
查看文件

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

57
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`

查看文件

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

查看文件

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

查看文件

@ -0,0 +1 @@

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -0,0 +1 @@

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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` 登录页并更新共享会话

查看文件

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

查看文件

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

查看文件

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

查看文件

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

二进制
AndroidLibs/gradle/wrapper/gradle-wrapper.jar vendored 普通文件

二进制文件未显示。

查看文件

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

164
AndroidLibs/gradlew vendored 可执行文件
查看文件

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

79
AndroidLibs/gradlew.bat vendored 普通文件
查看文件

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

查看文件

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

查看文件

@ -0,0 +1 @@

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -0,0 +1 @@

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -0,0 +1 @@

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

31
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` 覆盖。

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

1
frontend/ops-platform/src/vite-env.d.ts vendored 普通文件
查看文件

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

查看文件

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

查看文件

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

查看文件

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

36
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`
## 基础设施
- JDK21
- Spring Boot3.4.4
- 数据库MySQL `xuqinmin.com:3306/androidLibsServer`
- 缓存Redis `redisdev.xuqinmin.com:6379/0`
当前版本管理、账户、灰度用户等数据使用 MySQL 持久化,灰度选人列表使用 Redis 缓存。
## 已实现能力
- 运营平台开放主账户注册,并支持主账户创建子账户
- 管理平台查看运营平台账户、审核/禁用账户、调整子账户权限
- 版本管理支持 App / 插件包上传、插件化开关、全量发布、灰度发布
- 灰度发布通过用户平台钩子数据源获取脱敏用户列表,支持分组、快速选择、单选用户
- 保留 Android 现有兼容接口:
- `GET /api/v1/updates/app/latest`
- `GET /api/v1/updates/plugin/latest`

33
server/pom.xml 普通文件
查看文件

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

12
server/settings.xml 普通文件
查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

某些文件未显示,因为此 diff 中更改的文件太多 显示更多