feat: initialize android libs platform workspace
这个提交包含在:
当前提交
6e44428e8a
14
.gitignore
vendored
普通文件
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
普通文件
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
普通文件
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`
|
||||
9
AndroidLibs/build.gradle.kts
普通文件
9
AndroidLibs/build.gradle.kts
普通文件
@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
group = "com.xuqm"
|
||||
version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT")
|
||||
@ -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>
|
||||
|
||||
50
AndroidLibs/docs/architecture.md
普通文件
50
AndroidLibs/docs/architecture.md
普通文件
@ -0,0 +1,50 @@
|
||||
# AndroidLibs Architecture
|
||||
|
||||
## 目标结构
|
||||
|
||||
```text
|
||||
AndroidLibs/
|
||||
├── commonsdk-core/
|
||||
├── commonsdk-compose/
|
||||
├── lib-szyx/
|
||||
├── sample-app/
|
||||
├── plugins/
|
||||
│ └── plugin-ui/
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## 设计说明
|
||||
|
||||
### commonsdk-core
|
||||
|
||||
- 提供与业务无关的基础能力
|
||||
- 包含多 BaseUrl Retrofit 封装
|
||||
- 提供共享缓存 `SharedCacheManager`
|
||||
- 提供插件安装、启动、版本比较 `PluginPackageManager`
|
||||
- 提供 App 下载与安装 `AppUpdater`
|
||||
|
||||
### commonsdk-compose
|
||||
|
||||
- 提供 Compose 组件
|
||||
- 当前包含基础卡片与手风琴组件
|
||||
|
||||
### lib-szyx
|
||||
|
||||
- 承载项目专属登录逻辑
|
||||
- 登录接口、签名算法、业务 Header 均来自 `LibsDemo`
|
||||
- 登录成功后本地持久化,并同步写入共享缓存
|
||||
- 插件端支持从宿主共享缓存读取登录态
|
||||
|
||||
### sample-app
|
||||
|
||||
- 宿主示例
|
||||
- 打开 `lib-szyx` 登录页
|
||||
- 缓存当前用户并启动 `plugin-ui`
|
||||
- 演示插件下载与 App 更新下载
|
||||
|
||||
### plugins/plugin-ui
|
||||
|
||||
- 独立 APK 插件
|
||||
- 支持单独安装运行
|
||||
- 支持宿主启动时读取共享登录态
|
||||
- 支持再次打开 `lib-szyx` 登录页并更新共享会话
|
||||
@ -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
普通文件
二进制
AndroidLibs/gradle/wrapper/gradle-wrapper.jar
vendored
普通文件
二进制文件未显示。
8
AndroidLibs/gradle/wrapper/gradle-wrapper.properties
vendored
普通文件
8
AndroidLibs/gradle/wrapper/gradle-wrapper.properties
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
可执行文件
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
普通文件
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)
|
||||
}
|
||||
1
AndroidLibs/plugins/plugin-ui/proguard-rules.pro
vendored
普通文件
1
AndroidLibs/plugins/plugin-ui/proguard-rules.pro
vendored
普通文件
@ -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)
|
||||
}
|
||||
1
AndroidLibs/sample-app/proguard-rules.pro
vendored
普通文件
1
AndroidLibs/sample-app/proguard-rules.pro
vendored
普通文件
@ -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>
|
||||
|
||||
25
AndroidLibs/settings.gradle.kts
普通文件
25
AndroidLibs/settings.gradle.kts
普通文件
@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven(url = "https://nexus.xuqinmin.com/repository/android/")
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
maven(url = "https://nexus.xuqinmin.com/repository/android/")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AndroidLibs"
|
||||
|
||||
include(":commonsdk-core")
|
||||
include(":commonsdk-compose")
|
||||
include(":lib-szyx")
|
||||
include(":sample-app")
|
||||
include(":plugins:plugin-ui")
|
||||
31
frontend/README.md
普通文件
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>
|
||||
1
frontend/admin-platform/src/vite-env.d.ts
vendored
普通文件
1
frontend/admin-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: 5174,
|
||||
},
|
||||
})
|
||||
12
frontend/ops-platform/index.html
普通文件
12
frontend/ops-platform/index.html
普通文件
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>运营平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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 },
|
||||
],
|
||||
})
|
||||
221
frontend/ops-platform/src/styles.css
普通文件
221
frontend/ops-platform/src/styles.css
普通文件
@ -0,0 +1,221 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #10233d;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(31, 169, 255, 0.18), transparent 36%),
|
||||
radial-gradient(circle at bottom right, rgba(12, 205, 180, 0.16), transparent 26%),
|
||||
#f4f8fc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 32px 24px;
|
||||
background: linear-gradient(180deg, #0f2747 0%, #123863 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
text-decoration: none;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav a.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(16, 35, 61, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 18px 45px rgba(22, 57, 97, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.panel.soft {
|
||||
background: linear-gradient(160deg, rgba(217, 243, 255, 0.92), rgba(255, 255, 255, 0.9));
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-tag {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
color: #2d78a2;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #607188;
|
||||
}
|
||||
|
||||
.section-head,
|
||||
.app-card__top,
|
||||
.selected-bar,
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-grid label,
|
||||
.filters label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-grid .full,
|
||||
.filters .grow {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(16, 35, 61, 0.14);
|
||||
background: white;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, #0d72ff, #11b8a5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary,
|
||||
.ghost,
|
||||
.chip-button {
|
||||
background: #eef5fb;
|
||||
color: #163454;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border-top: 1px solid rgba(16, 35, 61, 0.08);
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chips span,
|
||||
.chip-button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid rgba(16, 35, 61, 0.08);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plain-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #0f7f65;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell,
|
||||
.stack {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@ -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
普通文件
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
普通文件
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`。
|
||||
|
||||
## 基础设施
|
||||
|
||||
- JDK:21
|
||||
- Spring Boot:3.4.4
|
||||
- 数据库:MySQL `xuqinmin.com:3306/androidLibsServer`
|
||||
- 缓存:Redis `redisdev.xuqinmin.com:6379/0`
|
||||
|
||||
当前版本管理、账户、灰度用户等数据使用 MySQL 持久化,灰度选人列表使用 Redis 缓存。
|
||||
|
||||
## 已实现能力
|
||||
|
||||
- 运营平台开放主账户注册,并支持主账户创建子账户
|
||||
- 管理平台查看运营平台账户、审核/禁用账户、调整子账户权限
|
||||
- 版本管理支持 App / 插件包上传、插件化开关、全量发布、灰度发布
|
||||
- 灰度发布通过用户平台钩子数据源获取脱敏用户列表,支持分组、快速选择、单选用户
|
||||
- 保留 Android 现有兼容接口:
|
||||
- `GET /api/v1/updates/app/latest`
|
||||
- `GET /api/v1/updates/plugin/latest`
|
||||
33
server/pom.xml
普通文件
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
普通文件
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 中更改的文件太多 显示更多
正在加载...
在新工单中引用
屏蔽一个用户