From 6e44428e8aaa276ba773731ac7d9de4cb1ae0ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Fri, 27 Mar 2026 15:44:01 +0800 Subject: [PATCH] feat: initialize android libs platform workspace --- .gitignore | 14 + AndroidLibs/.gitignore | 11 + AndroidLibs/README.md | 57 ++ AndroidLibs/build.gradle.kts | 9 + .../commonsdk-compose/build.gradle.kts | 39 ++ .../commonsdk-compose/consumer-rules.pro | 1 + .../src/main/AndroidManifest.xml | 3 + .../composesdk/components/AccordionGroup.kt | 58 ++ .../xuqm/composesdk/components/FeatureCard.kt | 29 + AndroidLibs/commonsdk-core/build.gradle.kts | 34 ++ AndroidLibs/commonsdk-core/consumer-rules.pro | 1 + .../src/main/AndroidManifest.xml | 24 + .../src/main/java/com/xuqm/sdk/CoreSDK.kt | 39 ++ .../com/xuqm/sdk/cache/SharedCacheManager.kt | 131 ++++ .../com/xuqm/sdk/cache/SharedCacheProvider.kt | 83 +++ .../com/xuqm/sdk/communication/EventBus.kt | 22 + .../java/com/xuqm/sdk/network/HttpResult.kt | 10 + .../com/xuqm/sdk/network/RetrofitManager.kt | 86 +++ .../xuqm/sdk/plugin/PluginPackageManager.kt | 245 ++++++++ .../main/java/com/xuqm/sdk/ui/ToastCenter.kt | 20 + .../java/com/xuqm/sdk/update/AppUpdater.kt | 128 ++++ .../com/xuqm/sdk/update/DownloadManager.kt | 188 ++++++ .../com/xuqm/sdk/update/VersionComparator.kt | 69 +++ .../java/com/xuqm/sdk/utils/DateTimeUtils.kt | 20 + .../java/com/xuqm/sdk/utils/DeviceUtils.kt | 47 ++ .../src/main/res/xml/core_file_paths.xml | 16 + AndroidLibs/docs/architecture.md | 50 ++ AndroidLibs/gradle.properties | 7 + .../gradle/gradle-daemon-jvm.properties | 12 + AndroidLibs/gradle/libs.versions.toml | 71 +++ AndroidLibs/gradle/publishing.gradle.kts | 47 ++ AndroidLibs/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + AndroidLibs/gradlew | 164 +++++ AndroidLibs/gradlew.bat | 79 +++ AndroidLibs/lib-szyx/build.gradle.kts | 47 ++ AndroidLibs/lib-szyx/consumer-rules.pro | 1 + .../lib-szyx/src/main/AndroidManifest.xml | 12 + .../src/main/java/com/xuqm/szyx/SzyxSDK.kt | 30 + .../main/java/com/xuqm/szyx/auth/AuthApi.kt | 14 + .../java/com/xuqm/szyx/auth/AuthModels.kt | 35 ++ .../java/com/xuqm/szyx/auth/AuthRepository.kt | 51 ++ .../com/xuqm/szyx/auth/UserSessionManager.kt | 107 ++++ .../szyx/http/BusinessHeaderInterceptor.kt | 37 ++ .../com/xuqm/szyx/login/SzyxLoginActivity.kt | 119 ++++ .../main/java/com/xuqm/szyx/utils/SignUtil.kt | 17 + .../lib-szyx/src/main/res/values/strings.xml | 4 + .../plugins/plugin-ui/build.gradle.kts | 63 ++ .../plugins/plugin-ui/proguard-rules.pro | 1 + .../plugin-ui/src/main/AndroidManifest.xml | 24 + .../com/xuqm/plugin/ui/PluginUiActivity.kt | 114 ++++ .../plugin/ui/service/PluginUiApiService.kt | 12 + .../plugin-ui/src/main/res/values/strings.xml | 4 + AndroidLibs/sample-app/build.gradle.kts | 65 ++ AndroidLibs/sample-app/proguard-rules.pro | 1 + .../sample-app/src/main/AndroidManifest.xml | 27 + .../main/java/com/xuqm/sample/MainActivity.kt | 519 ++++++++++++++++ .../java/com/xuqm/sample/update/UpdateApi.kt | 17 + .../com/xuqm/sample/update/UpdateModels.kt | 19 + .../xuqm/sample/update/UpdateRepository.kt | 48 ++ .../src/main/res/values/strings.xml | 4 + AndroidLibs/settings.gradle.kts | 25 + frontend/README.md | 31 + frontend/admin-platform/index.html | 12 + frontend/admin-platform/package.json | 22 + frontend/admin-platform/src/App.vue | 14 + frontend/admin-platform/src/api/client.ts | 59 ++ frontend/admin-platform/src/main.ts | 7 + frontend/admin-platform/src/router/index.ts | 7 + frontend/admin-platform/src/styles.css | 145 +++++ .../src/views/AccountManagementView.vue | 94 +++ frontend/admin-platform/src/vite-env.d.ts | 1 + frontend/admin-platform/tsconfig.app.json | 17 + frontend/admin-platform/tsconfig.json | 6 + frontend/admin-platform/vite.config.ts | 9 + frontend/ops-platform/index.html | 12 + frontend/ops-platform/package.json | 22 + frontend/ops-platform/src/App.vue | 22 + frontend/ops-platform/src/api/client.ts | 135 ++++ frontend/ops-platform/src/main.ts | 7 + frontend/ops-platform/src/router/index.ts | 12 + frontend/ops-platform/src/styles.css | 221 +++++++ .../ops-platform/src/views/RegisterView.vue | 66 ++ .../src/views/VersionManagementView.vue | 293 +++++++++ frontend/ops-platform/src/vite-env.d.ts | 1 + frontend/ops-platform/tsconfig.app.json | 17 + frontend/ops-platform/tsconfig.json | 6 + frontend/ops-platform/vite.config.ts | 9 + server/README.md | 36 ++ server/pom.xml | 33 + server/settings.xml | 12 + server/version-management-service/pom.xml | 72 +++ .../VersionManagementApplication.java | 14 + .../config/DataInitializer.java | 195 ++++++ .../versionmanagement/config/RedisConfig.java | 32 + .../versionmanagement/config/WebConfig.java | 16 + .../controller/AdminAccountController.java | 56 ++ .../controller/ApiExceptionHandler.java | 32 + .../CompatibilityUpdateController.java | 57 ++ .../controller/OpsVersionController.java | 143 +++++ .../controller/PublicAccountController.java | 74 +++ .../versionmanagement/model/ApiResponse.java | 12 + .../versionmanagement/model/PlatformData.java | 574 ++++++++++++++++++ .../persistence/entity/AccountEntity.java | 130 ++++ .../persistence/entity/ApplicationEntity.java | 92 +++ .../persistence/entity/HookGroupEntity.java | 44 ++ .../persistence/entity/HookUserEntity.java | 102 ++++ .../entity/QuickSelectionEntity.java | 44 ++ .../persistence/entity/ReleaseEntity.java | 241 ++++++++ .../entity/StringListConverter.java | 29 + .../repository/AccountRepository.java | 13 + .../repository/ApplicationRepository.java | 7 + .../repository/HookGroupRepository.java | 7 + .../repository/HookUserRepository.java | 10 + .../repository/QuickSelectionRepository.java | 7 + .../repository/ReleaseRepository.java | 16 + .../service/AccountService.java | 107 ++++ .../service/PlatformMapper.java | 122 ++++ .../service/UserHookService.java | 187 ++++++ .../service/VersionManagementService.java | 147 +++++ .../src/main/resources/application.yml | 32 + server/version-service/README.md | 68 +++ .../version-service/data/version-config.json | 22 + server/version-service/package.json | 12 + server/version-service/src/index.js | 141 +++++ 125 files changed, 7424 insertions(+) create mode 100644 .gitignore create mode 100644 AndroidLibs/.gitignore create mode 100644 AndroidLibs/README.md create mode 100644 AndroidLibs/build.gradle.kts create mode 100644 AndroidLibs/commonsdk-compose/build.gradle.kts create mode 100644 AndroidLibs/commonsdk-compose/consumer-rules.pro create mode 100644 AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt create mode 100644 AndroidLibs/commonsdk-core/build.gradle.kts create mode 100644 AndroidLibs/commonsdk-core/consumer-rules.pro create mode 100644 AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml create mode 100644 AndroidLibs/docs/architecture.md create mode 100644 AndroidLibs/gradle.properties create mode 100644 AndroidLibs/gradle/gradle-daemon-jvm.properties create mode 100644 AndroidLibs/gradle/libs.versions.toml create mode 100644 AndroidLibs/gradle/publishing.gradle.kts create mode 100644 AndroidLibs/gradle/wrapper/gradle-wrapper.jar create mode 100644 AndroidLibs/gradle/wrapper/gradle-wrapper.properties create mode 100755 AndroidLibs/gradlew create mode 100644 AndroidLibs/gradlew.bat create mode 100644 AndroidLibs/lib-szyx/build.gradle.kts create mode 100644 AndroidLibs/lib-szyx/consumer-rules.pro create mode 100644 AndroidLibs/lib-szyx/src/main/AndroidManifest.xml create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt create mode 100644 AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt create mode 100644 AndroidLibs/lib-szyx/src/main/res/values/strings.xml create mode 100644 AndroidLibs/plugins/plugin-ui/build.gradle.kts create mode 100644 AndroidLibs/plugins/plugin-ui/proguard-rules.pro create mode 100644 AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml create mode 100644 AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt create mode 100644 AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt create mode 100644 AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml create mode 100644 AndroidLibs/sample-app/build.gradle.kts create mode 100644 AndroidLibs/sample-app/proguard-rules.pro create mode 100644 AndroidLibs/sample-app/src/main/AndroidManifest.xml create mode 100644 AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt create mode 100644 AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt create mode 100644 AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt create mode 100644 AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt create mode 100644 AndroidLibs/sample-app/src/main/res/values/strings.xml create mode 100644 AndroidLibs/settings.gradle.kts create mode 100644 frontend/README.md create mode 100644 frontend/admin-platform/index.html create mode 100644 frontend/admin-platform/package.json create mode 100644 frontend/admin-platform/src/App.vue create mode 100644 frontend/admin-platform/src/api/client.ts create mode 100644 frontend/admin-platform/src/main.ts create mode 100644 frontend/admin-platform/src/router/index.ts create mode 100644 frontend/admin-platform/src/styles.css create mode 100644 frontend/admin-platform/src/views/AccountManagementView.vue create mode 100644 frontend/admin-platform/src/vite-env.d.ts create mode 100644 frontend/admin-platform/tsconfig.app.json create mode 100644 frontend/admin-platform/tsconfig.json create mode 100644 frontend/admin-platform/vite.config.ts create mode 100644 frontend/ops-platform/index.html create mode 100644 frontend/ops-platform/package.json create mode 100644 frontend/ops-platform/src/App.vue create mode 100644 frontend/ops-platform/src/api/client.ts create mode 100644 frontend/ops-platform/src/main.ts create mode 100644 frontend/ops-platform/src/router/index.ts create mode 100644 frontend/ops-platform/src/styles.css create mode 100644 frontend/ops-platform/src/views/RegisterView.vue create mode 100644 frontend/ops-platform/src/views/VersionManagementView.vue create mode 100644 frontend/ops-platform/src/vite-env.d.ts create mode 100644 frontend/ops-platform/tsconfig.app.json create mode 100644 frontend/ops-platform/tsconfig.json create mode 100644 frontend/ops-platform/vite.config.ts create mode 100644 server/README.md create mode 100644 server/pom.xml create mode 100644 server/settings.xml create mode 100644 server/version-management-service/pom.xml create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java create mode 100644 server/version-management-service/src/main/resources/application.yml create mode 100644 server/version-service/README.md create mode 100644 server/version-service/data/version-config.json create mode 100644 server/version-service/package.json create mode 100644 server/version-service/src/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfbd199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.idea/ +.m2/ +.gradle/ +.kotlin/ +build/ +target/ +node_modules/ +dist/ +coverage/ +*.iml +*.log +AndroidLibs/.gradle-home/ +AndroidLibs/local.properties diff --git a/AndroidLibs/.gitignore b/AndroidLibs/.gitignore new file mode 100644 index 0000000..90a83b5 --- /dev/null +++ b/AndroidLibs/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +.gradle-home/ +.idea/ +.kotlin/ +local.properties +build/ +*/build/ +captures/ +*.iml +*.apk +*.aab diff --git a/AndroidLibs/README.md b/AndroidLibs/README.md new file mode 100644 index 0000000..b2df95e --- /dev/null +++ b/AndroidLibs/README.md @@ -0,0 +1,57 @@ +# AndroidLibs + +一个面向开源的 Android 插件化项目基线,包含宿主 App、业务插件以及可复用的基础 SDK。 + +## 模块结构 + +- `commonsdk-core`: SDK 核心,承载网络、共享缓存、插件管理、App 更新、设备信息与时间工具。 +- `commonsdk-compose`: Compose 扩展组件。 +- `lib-szyx`: 项目专属 SDK,承载真实登录接口、签名、业务 Header 与会话管理。 +- `sample-app`: 示例宿主应用。 +- `plugins/plugin-ui`: UI 演示插件,可独立运行,也可被宿主拉起。 +- `docs`: 方案文档。 + +## 技术基线 + +- JDK 21 +- AGP 9.1.0 +- Kotlin 2.3.10 +- Compose BOM 2026.03.00 + +## Nexus + +- 依赖拉取仓库:`https://nexus.xuqinmin.com/repository/android/` +- Snapshot 上传:`https://nexus.xuqinmin.com/repository/android-snapshot/` +- Release 上传:`https://nexus.xuqinmin.com/repository/android-hosted/` + +发布账号请放入本地 `local.properties` 或环境变量,不要提交到仓库。 + +## 发布配置 + +建议在 `local.properties` 中提供: + +```properties +nexus.username=your-username +nexus.password=your-password +``` + +然后执行: + +```bash +./gradlew publish +``` + +## 当前实现重点 + +- `sample-app` 与 `plugin-ui` 共享 `commonsdk-core / commonsdk-compose / lib-szyx` +- 登录接口和签名逻辑参考 `LibsDemo` 中现有实现 +- `commonsdk-core` 提供: + - `HttpManager / RetrofitManager` + - `SharedCacheManager / SharedCacheProvider` + - `PluginPackageManager` + - `AppUpdater` +- `lib-szyx` 提供: + - `SzyxSDK` + - `AuthApi / AuthRepository` + - `BusinessHeaderInterceptor` + - `SzyxLoginActivity` diff --git a/AndroidLibs/build.gradle.kts b/AndroidLibs/build.gradle.kts new file mode 100644 index 0000000..6707963 --- /dev/null +++ b/AndroidLibs/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} + +group = "com.xuqm" +version = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT") diff --git a/AndroidLibs/commonsdk-compose/build.gradle.kts b/AndroidLibs/commonsdk-compose/build.gradle.kts new file mode 100644 index 0000000..9cee32a --- /dev/null +++ b/AndroidLibs/commonsdk-compose/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +android { + namespace = "com.xuqm.sdk.compose" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + api(project(":commonsdk-core")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + + debugImplementation(libs.bundles.compose.debug) +} diff --git a/AndroidLibs/commonsdk-compose/consumer-rules.pro b/AndroidLibs/commonsdk-compose/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2d10029 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt new file mode 100644 index 0000000..2bf7623 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt @@ -0,0 +1,58 @@ +package com.xuqm.sdk.compose.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AccordionGroup( + title: String, + modifier: Modifier = Modifier, + initiallyExpanded: Boolean = false, + content: @Composable () -> Unit, +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + + Card(modifier = modifier.fillMaxWidth()) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = null, + ) + } + AnimatedVisibility(expanded) { + Column(modifier = Modifier.padding(16.dp)) { + content() + } + } + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt new file mode 100644 index 0000000..23c45c1 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt @@ -0,0 +1,29 @@ +package com.xuqm.sdk.compose.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun FeatureCard( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/AndroidLibs/commonsdk-core/build.gradle.kts b/AndroidLibs/commonsdk-core/build.gradle.kts new file mode 100644 index 0000000..f031854 --- /dev/null +++ b/AndroidLibs/commonsdk-core/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.android.library) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +android { + namespace = "com.xuqm.sdk.core" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + api(libs.androidx.core.ktx) + api(libs.bundles.network) + api(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.datastore.preferences) + + testImplementation(libs.junit4) +} diff --git a/AndroidLibs/commonsdk-core/consumer-rules.pro b/AndroidLibs/commonsdk-core/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/commonsdk-core/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml b/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..13963c9 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt new file mode 100644 index 0000000..333a259 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt @@ -0,0 +1,39 @@ +package com.xuqm.sdk + +import android.app.Application +import android.content.Context +import com.xuqm.sdk.network.HttpConfig +import com.xuqm.sdk.network.HttpManager +import com.xuqm.sdk.plugin.PluginPackageManager +import com.xuqm.sdk.update.AppUpdater +import com.xuqm.sdk.update.DownloadManager +import com.xuqm.sdk.utils.DeviceUtils + +object CoreSDK { + private var appContext: Context? = null + private var config: SDKConfig = SDKConfig() + + data class SDKConfig( + val debugMode: Boolean = false, + val pluginDirectory: String = "plugins", + ) + + fun init(context: Context, config: SDKConfig = SDKConfig()) { + if (appContext != null) return + appContext = context.applicationContext + this.config = config + HttpManager.init(HttpConfig(debugMode = config.debugMode)) + } + + fun context(): Context = requireNotNull(appContext) { "CoreSDK not initialized" } + + fun pluginPackageManager(): PluginPackageManager = PluginPackageManager.getInstance(context()) + + fun downloadManager(): DownloadManager = DownloadManager.getInstance(context()) + + fun appUpdater(): AppUpdater = AppUpdater.getInstance(context()) + + fun deviceId(): String = DeviceUtils.getDeviceId(context()) + + fun deviceInfo() = DeviceUtils.getDeviceInfo(context()) +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt new file mode 100644 index 0000000..34c2994 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheManager.kt @@ -0,0 +1,131 @@ +package com.xuqm.sdk.cache + +import android.content.Context +import android.content.ContentValues +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +class SharedCacheManager private constructor(context: Context) { + + companion object { + const val AUTHORITY_SUFFIX = ".sdk.cache.provider" + const val PATH_CACHE = "cache" + + @Volatile + private var instance: SharedCacheManager? = null + + fun getInstance(context: Context): SharedCacheManager { + return instance ?: synchronized(this) { + instance ?: SharedCacheManager(context.applicationContext).also { instance = it } + } + } + } + + private val appContext = context.applicationContext + private val memoryCache = ConcurrentHashMap() + + data class CacheEntry( + val key: String, + val value: String, + val timestamp: Long, + val ttl: Long, + ) { + fun isExpired(): Boolean = System.currentTimeMillis() - timestamp > ttl + } + + fun put(key: String, value: String, ttl: Long = 10 * 60 * 1000) { + val entry = CacheEntry(key, value, System.currentTimeMillis(), ttl) + memoryCache[key] = entry + writeToDisk(entry) + } + + suspend fun get(key: String, appPackageName: String? = null): String? = withContext(Dispatchers.IO) { + getSync(key, appPackageName) + } + + fun getSync(key: String, appPackageName: String? = null): String? { + memoryCache[key]?.let { + if (!it.isExpired()) return it.value + memoryCache.remove(key) + } + + return if (appPackageName != null && appPackageName != appContext.packageName) { + getFromProvider(key, appPackageName) + } else { + readFromDisk(key) + } + } + + fun remove(key: String) { + memoryCache.remove(key) + File(cacheDir(), "$key.cache").delete() + } + + fun putRemote(key: String, value: String, ttl: Long = 10 * 60 * 1000, appPackageName: String): Boolean { + val uri = Uri.parse("content://$appPackageName$AUTHORITY_SUFFIX/$PATH_CACHE/$key") + return runCatching { + appContext.contentResolver.update( + uri, + ContentValues().apply { + put("key", key) + put("value", value) + put("timestamp", System.currentTimeMillis()) + put("ttl", ttl) + }, + null, + null, + ) + }.isSuccess + } + + private fun getFromProvider(key: String, appPackageName: String): String? { + val uri = Uri.parse("content://$appPackageName$AUTHORITY_SUFFIX/$PATH_CACHE/$key") + val cursor = runCatching { appContext.contentResolver.query(uri, null, null, null, null) }.getOrNull() + ?: return null + return cursor.use { + if (!it.moveToFirst()) return null + val value = it.getString(it.getColumnIndexOrThrow("value")) + val timestamp = it.getLong(it.getColumnIndexOrThrow("timestamp")) + val ttl = it.getLong(it.getColumnIndexOrThrow("ttl")) + if (System.currentTimeMillis() - timestamp > ttl) null else value + } + } + + private fun writeToDisk(entry: CacheEntry) { + val file = File(cacheDir(), "${entry.key}.cache") + val json = JSONObject().apply { + put("key", entry.key) + put("value", entry.value) + put("timestamp", entry.timestamp) + put("ttl", entry.ttl) + } + file.writeText(json.toString()) + } + + private fun readFromDisk(key: String): String? { + val file = File(cacheDir(), "$key.cache") + if (!file.exists()) return null + return runCatching { + val json = JSONObject(file.readText()) + val timestamp = json.getLong("timestamp") + val ttl = json.getLong("ttl") + if (System.currentTimeMillis() - timestamp > ttl) { + file.delete() + null + } else { + json.getString("value") + } + }.getOrNull() + } + + private fun cacheDir(): File = File(appContext.cacheDir, "shared_cache").apply { mkdirs() } +} + +object CacheKeys { + const val CURRENT_USER = "current_user" + const val LOGIN_SESSION = "login_session" +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt new file mode 100644 index 0000000..5bc934a --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/SharedCacheProvider.kt @@ -0,0 +1,83 @@ +package com.xuqm.sdk.cache + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import org.json.JSONObject +import java.io.File + +class SharedCacheProvider : ContentProvider() { + + companion object { + private const val CODE_CACHE = 1 + private const val CODE_CACHE_ITEM = 2 + + private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply { + addURI("*", SharedCacheManager.PATH_CACHE, CODE_CACHE) + addURI("*", "${SharedCacheManager.PATH_CACHE}/*", CODE_CACHE_ITEM) + } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + if (uriMatcher.match(uri) != CODE_CACHE_ITEM) return null + val key = uri.lastPathSegment ?: return null + val cacheDir = File(requireNotNull(context).cacheDir, "shared_cache") + val file = File(cacheDir, "$key.cache") + if (!file.exists()) return null + val json = JSONObject(file.readText()) + return MatrixCursor(arrayOf("key", "value", "timestamp", "ttl")).apply { + addRow( + arrayOf( + json.getString("key"), + json.getString("value"), + json.getLong("timestamp"), + json.getLong("ttl"), + ), + ) + } + } + + override fun getType(uri: Uri): String? = "vnd.android.cursor.item/cache" + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return if (writeCache(uri, values)) uri else null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val key = uri.lastPathSegment ?: return 0 + val file = File(File(requireNotNull(context).cacheDir, "shared_cache"), "$key.cache") + return if (file.delete()) 1 else 0 + } + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + return if (writeCache(uri, values)) 1 else 0 + } + + private fun writeCache(uri: Uri, values: ContentValues?): Boolean { + if (uriMatcher.match(uri) != CODE_CACHE_ITEM || values == null) return false + val key = values.getAsString("key") ?: uri.lastPathSegment ?: return false + val value = values.getAsString("value") ?: return false + val timestamp = values.getAsLong("timestamp") ?: System.currentTimeMillis() + val ttl = values.getAsLong("ttl") ?: 10 * 60 * 1000 + val json = JSONObject().apply { + put("key", key) + put("value", value) + put("timestamp", timestamp) + put("ttl", ttl) + } + val cacheDir = File(requireNotNull(context).cacheDir, "shared_cache").apply { mkdirs() } + File(cacheDir, "$key.cache").writeText(json.toString()) + return true + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt new file mode 100644 index 0000000..47972dd --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/EventBus.kt @@ -0,0 +1,22 @@ +package com.xuqm.sdk.communication + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +data class Event(val topic: String, val payload: Any? = null) + +object EventBus { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val eventsFlow = MutableSharedFlow(extraBufferCapacity = 32) + + val events: SharedFlow = eventsFlow + + fun post(topic: String, payload: Any? = null) { + scope.launch { eventsFlow.emit(Event(topic, payload)) } + } +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt new file mode 100644 index 0000000..052b02e --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/HttpResult.kt @@ -0,0 +1,10 @@ +package com.xuqm.sdk.network + +data class HttpResult( + val code: Int? = null, + val status: String? = null, + val data: T? = null, + val message: String? = null, +) { + fun isSuccess(): Boolean = status == "0" || status == "200" || code == 200 +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt new file mode 100644 index 0000000..4db33d5 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt @@ -0,0 +1,86 @@ +package com.xuqm.sdk.network + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +data class HttpConfig( + val connectTimeout: Long = 30, + val readTimeout: Long = 30, + val writeTimeout: Long = 30, + val debugMode: Boolean = false, + val interceptors: List = emptyList(), + val networkInterceptors: List = emptyList(), +) + +class RetrofitManager private constructor() { + companion object { + @Volatile private var instance: RetrofitManager? = null + fun getInstance(): RetrofitManager = instance ?: synchronized(this) { + instance ?: RetrofitManager().also { instance = it } + } + } + + private val retrofitMap = ConcurrentHashMap() + private val serviceCache = ConcurrentHashMap() + private var globalConfig: HttpConfig = HttpConfig() + + fun init(config: HttpConfig = HttpConfig()) { + globalConfig = config + } + + fun getService(baseUrl: String, serviceClass: Class, config: HttpConfig? = null): T { + val key = "${baseUrl}_${serviceClass.name}" + @Suppress("UNCHECKED_CAST") + return serviceCache.getOrPut(key) { + createRetrofit(baseUrl, config ?: globalConfig).create(serviceClass) + } as T + } + + fun clear() { + retrofitMap.clear() + serviceCache.clear() + } + + private fun createRetrofit(baseUrl: String, config: HttpConfig): Retrofit { + val client = OkHttpClient.Builder() + .connectTimeout(config.connectTimeout, TimeUnit.SECONDS) + .readTimeout(config.readTimeout, TimeUnit.SECONDS) + .writeTimeout(config.writeTimeout, TimeUnit.SECONDS) + .apply { + config.interceptors.forEach(::addInterceptor) + config.networkInterceptors.forEach(::addNetworkInterceptor) + if (config.debugMode) { + addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }, + ) + } + } + .build() + + return retrofitMap.getOrPut(baseUrl) { + Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + } +} + +object HttpManager { + fun init(config: HttpConfig = HttpConfig()) { + RetrofitManager.getInstance().init(config) + } + + fun getService(baseUrl: String, serviceClass: Class, config: HttpConfig? = null): T { + return RetrofitManager.getInstance().getService(baseUrl, serviceClass, config) + } +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt new file mode 100644 index 0000000..6e212c3 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt @@ -0,0 +1,245 @@ +package com.xuqm.sdk.plugin + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import com.xuqm.sdk.cache.CacheKeys +import com.xuqm.sdk.cache.SharedCacheManager +import com.xuqm.sdk.update.DownloadDecision +import com.xuqm.sdk.update.DownloadManager +import com.xuqm.sdk.update.DownloadRequest +import com.xuqm.sdk.update.StoragePath +import com.xuqm.sdk.update.VersionCheckResult +import com.xuqm.sdk.update.VersionCheckStrategy +import com.xuqm.sdk.update.VersionComparator +import com.xuqm.sdk.update.VersionInfo +import org.json.JSONObject +import java.io.File + +class PluginPackageManager private constructor(private val context: Context) { + + data class PluginUpdateInfo( + val packageName: String, + val versionCode: Long = 0L, + val versionName: String = "", + val downloadUrl: String, + val entryActivity: String? = null, + val extras: Map = emptyMap(), + ) + + companion object { + @Volatile + private var instance: PluginPackageManager? = null + + fun getInstance(context: Context): PluginPackageManager { + return instance ?: synchronized(this) { + instance ?: PluginPackageManager(context.applicationContext).also { instance = it } + } + } + } + + private val cacheManager = SharedCacheManager.getInstance(context) + private val downloadManager = DownloadManager.getInstance(context) + + fun cacheCurrentUser( + userId: String, + sessionId: String, + clientId: String, + extraData: Map = emptyMap(), + ) { + val json = JSONObject().apply { + put("userId", userId) + put("sessionId", sessionId) + put("clientId", clientId) + put("timestamp", System.currentTimeMillis()) + extraData.forEach { (key, value) -> put(key, value) } + } + cacheManager.put(CacheKeys.CURRENT_USER, json.toString(), 10 * 60 * 1000) + } + + fun getCachedUser(appPackageName: String? = null): String? { + return cacheManager.getSync(CacheKeys.CURRENT_USER, appPackageName) + } + + fun isPluginInstalled(packageName: String): Boolean { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(packageName, 0) + } + }.isSuccess + } + + fun getLocalPluginInfo(packageName: String): PluginInfo? { + return runCatching { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(packageName, 0) + } + PluginInfo( + packageName = packageName, + versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong(), + versionName = info.versionName.orEmpty(), + isInstalled = true, + ) + }.getOrNull() + } + + fun compareVersion(packageName: String, remoteVersionCode: Long): Int { + val local = getLocalPluginInfo(packageName) ?: return 1 + return remoteVersionCode.compareTo(local.versionCode) + } + + fun shouldDownloadPlugin( + packageName: String, + remoteVersionCode: Long? = null, + remoteVersionName: String? = null, + ): Boolean { + return checkPluginUpdate( + packageName = packageName, + remoteVersionCode = remoteVersionCode, + remoteVersionName = remoteVersionName, + ) is VersionCheckResult.NeedUpdate + } + + fun checkPluginUpdate( + packageName: String, + remoteVersionCode: Long? = null, + remoteVersionName: String? = null, + strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + ): VersionCheckResult { + val local = getLocalPluginInfo(packageName) + val current = VersionInfo( + versionCode = local?.versionCode ?: 0L, + versionName = local?.versionName.orEmpty(), + ) + val remote = VersionInfo( + versionCode = remoteVersionCode ?: 0L, + versionName = remoteVersionName.orEmpty(), + ) + return if (local == null) { + VersionCheckResult.NeedUpdate(current = current, remote = remote, strategy = strategy) + } else { + VersionComparator.check(current = current, remote = remote, strategy = strategy) + } + } + + fun downloadPlugin( + updateInfo: PluginUpdateInfo, + fileName: String = "${updateInfo.packageName}.apk", + storagePath: StoragePath = StoragePath.CACHE, + customPath: String? = null, + ): String { + return downloadManager.start( + DownloadRequest( + url = updateInfo.downloadUrl, + fileName = fileName, + storagePath = storagePath, + customPath = customPath, + ), + ) + } + + fun downloadPluginIfNeeded( + updateInfo: PluginUpdateInfo, + strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + fileName: String = "${updateInfo.packageName}.apk", + storagePath: StoragePath = StoragePath.CACHE, + customPath: String? = null, + ): DownloadDecision { + val checkResult = checkPluginUpdate( + packageName = updateInfo.packageName, + remoteVersionCode = updateInfo.versionCode, + remoteVersionName = updateInfo.versionName, + strategy = strategy, + ) + if (checkResult is VersionCheckResult.UpToDate) { + val local = getLocalPluginInfo(updateInfo.packageName) + return DownloadDecision.Skipped( + reason = if (local == null) { + "当前插件无需下载" + } else { + "当前已安装相同或更新版本 ${local.versionName}(${local.versionCode})" + }, + ) + } + + return DownloadDecision.Started( + taskId = downloadPlugin( + updateInfo = updateInfo, + fileName = fileName, + storagePath = storagePath, + customPath = customPath, + ), + ) + } + + fun startPlugin( + packageName: String, + entryActivity: String? = null, + extras: Map = emptyMap(), + ): Boolean { + if (!isPluginInstalled(packageName)) return false + val explicitIntent = entryActivity?.let { className -> + Intent().apply { + setClassName(packageName, className) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + extras.forEach { (key, value) -> putExtra(key, value) } + } + } + + if (explicitIntent != null && runCatching { context.startActivity(explicitIntent) }.isSuccess) { + return true + } + + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + extras.forEach { (key, value) -> putExtra(key, value) } + } ?: return false + + return runCatching { context.startActivity(launchIntent) }.isSuccess + } + + fun installPlugin(apkFile: File): Boolean { + return runCatching { + val authority = "${context.packageName}.sdk.fileprovider" + val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile(context, authority, apkFile) + } else { + Uri.fromFile(apkFile) + } + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + }.isSuccess + } + + fun loadPlugin(apkFile: File): Boolean = installPlugin(apkFile) + + fun reloadPlugin( + packageName: String, + entryActivity: String? = null, + extras: Map = emptyMap(), + ): Boolean = startPlugin(packageName = packageName, entryActivity = entryActivity, extras = extras) + + fun goToDownload(downloadUrl: String): Boolean { + return downloadUrl.isNotBlank() + } +} + +data class PluginInfo( + val packageName: String, + val versionCode: Long, + val versionName: String, + val isInstalled: Boolean, +) diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt new file mode 100644 index 0000000..021ff2d --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt @@ -0,0 +1,20 @@ +package com.xuqm.sdk.ui + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast + +object ToastCenter { + private val handler = Handler(Looper.getMainLooper()) + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + + fun show(message: String) { + val context = appContext ?: return + handler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt new file mode 100644 index 0000000..6e4ee8f --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt @@ -0,0 +1,128 @@ +package com.xuqm.sdk.update + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import kotlinx.coroutines.flow.StateFlow + +data class UpdateInfo( + val versionCode: Int, + val versionName: String, + val title: String = "发现新版本", + val changelog: String = "", + val downloadUrl: String, + val forceUpdate: Boolean = false, +) + +class AppUpdater private constructor(private val context: Context) { + + companion object { + @Volatile private var instance: AppUpdater? = null + + fun getInstance(context: Context): AppUpdater { + return instance ?: synchronized(this) { + instance ?: AppUpdater(context.applicationContext).also { instance = it } + } + } + + fun compareVersionCode(currentVersion: Int, newVersion: Int): Int { + return VersionComparator.compareVersionCode(currentVersion.toLong(), newVersion.toLong()) + } + + fun compareVersionName(currentVersion: String, newVersion: String): Int { + return VersionComparator.compareVersionName(currentVersion, newVersion) + } + } + + private val downloadManager = DownloadManager.getInstance(context) + + fun getCurrentVersion(): VersionInfo { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + return VersionInfo( + versionCode = versionCode, + versionName = packageInfo.versionName.orEmpty(), + ) + } + + fun shouldDownload(updateInfo: UpdateInfo): Boolean { + return checkUpdate(updateInfo) is VersionCheckResult.NeedUpdate + } + + fun checkUpdate( + updateInfo: UpdateInfo, + strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + ): VersionCheckResult { + return VersionComparator.check( + current = getCurrentVersion(), + remote = VersionInfo( + versionCode = updateInfo.versionCode.toLong(), + versionName = updateInfo.versionName, + ), + strategy = strategy, + ) + } + + fun downloadUpdate( + updateInfo: UpdateInfo, + fileName: String = "app_update.apk", + storagePath: StoragePath = StoragePath.CACHE, + customPath: String? = null, + ): String { + return downloadManager.start( + DownloadRequest( + url = updateInfo.downloadUrl, + fileName = fileName, + storagePath = storagePath, + customPath = customPath, + ), + ) + } + + fun downloadUpdateIfNeeded( + updateInfo: UpdateInfo, + strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + fileName: String = "app_update.apk", + storagePath: StoragePath = StoragePath.CACHE, + customPath: String? = null, + ): DownloadDecision { + val checkResult = checkUpdate(updateInfo, strategy) + if (checkResult is VersionCheckResult.UpToDate) { + return DownloadDecision.Skipped( + reason = "当前已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})", + ) + } + + return DownloadDecision.Started( + taskId = downloadUpdate( + updateInfo = updateInfo, + fileName = fileName, + storagePath = storagePath, + customPath = customPath, + ), + ) + } + + fun observe(taskId: String): StateFlow? = downloadManager.observe(taskId) + + fun cancel(taskId: String): Boolean = downloadManager.cancel(taskId) + + fun clear(taskId: String): Boolean = downloadManager.clear(taskId) + + fun installApk(file: java.io.File): Boolean = downloadManager.installApk(file) + + fun installFromTask(taskId: String): Boolean { + val file = downloadManager.getDownloadedFile(taskId) ?: return false + return installApk(file) + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt new file mode 100644 index 0000000..9aba442 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt @@ -0,0 +1,188 @@ +package com.xuqm.sdk.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.content.FileProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +sealed class DownloadState { + data object Idle : DownloadState() + data object Starting : DownloadState() + data class Progress( + val progress: Int, + val downloadedBytes: Long, + val totalBytes: Long, + ) : DownloadState() + data class Success(val file: File) : DownloadState() + data object Cancelled : DownloadState() + data class Error(val message: String) : DownloadState() +} + +enum class StoragePath { DOWNLOADS, CACHE, EXTERNAL_CACHE, FILES, EXTERNAL_FILES, CUSTOM } + +data class DownloadRequest( + val url: String, + val fileName: String, + val storagePath: StoragePath = StoragePath.CACHE, + val customPath: String? = null, +) + +sealed class DownloadDecision { + data class Started(val taskId: String) : DownloadDecision() + data class Skipped(val reason: String) : DownloadDecision() +} + +class DownloadManager private constructor(private val context: Context) { + + private data class DownloadTask( + val state: MutableStateFlow, + var file: File? = null, + var call: Call? = null, + var job: Job? = null, + ) + + companion object { + @Volatile private var instance: DownloadManager? = null + + fun getInstance(context: Context): DownloadManager { + return instance ?: synchronized(this) { + instance ?: DownloadManager(context.applicationContext).also { instance = it } + } + } + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val okHttpClient = OkHttpClient.Builder().build() + private val tasks = ConcurrentHashMap() + + fun start(request: DownloadRequest): String { + val taskId = UUID.randomUUID().toString() + val task = DownloadTask(state = MutableStateFlow(DownloadState.Idle)) + tasks[taskId] = task + + task.job = scope.launch { + task.state.value = DownloadState.Starting + val targetFile = File(resolvePath(request.storagePath, request.customPath), request.fileName).apply { + parentFile?.mkdirs() + if (exists()) delete() + } + task.file = targetFile + + runCatching { + val httpRequest = Request.Builder().url(request.url).build() + val call = okHttpClient.newCall(httpRequest) + task.call = call + call.execute().use { response -> + if (!response.isSuccessful) error("下载失败: HTTP ${response.code}") + + val body = requireNotNull(response.body) { "下载失败: 响应体为空" } + val totalBytes = body.contentLength() + var downloadedBytes = 0L + + body.byteStream().use { input -> + targetFile.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + downloadedBytes += read + val progress = if (totalBytes > 0) { + ((downloadedBytes * 100) / totalBytes).toInt() + } else { + -1 + } + task.state.value = DownloadState.Progress( + progress = progress, + downloadedBytes = downloadedBytes, + totalBytes = totalBytes, + ) + } + output.flush() + } + } + } + }.onSuccess { + task.state.value = DownloadState.Success(targetFile) + task.call = null + }.onFailure { + task.state.value = if (it is CancellationException) { + DownloadState.Cancelled + } else { + DownloadState.Error(it.message ?: "下载失败") + } + if (targetFile.exists()) targetFile.delete() + task.call = null + } + } + + return taskId + } + + fun observe(taskId: String): StateFlow? = tasks[taskId]?.state?.asStateFlow() + + fun getState(taskId: String): DownloadState? = tasks[taskId]?.state?.value + + fun getDownloadedFile(taskId: String): File? = (tasks[taskId]?.state?.value as? DownloadState.Success)?.file + + fun cancel(taskId: String): Boolean { + val task = tasks[taskId] ?: return false + task.call?.cancel() + task.job?.cancel() + task.file?.takeIf { it.exists() }?.delete() + task.state.value = DownloadState.Cancelled + return true + } + + fun clear(taskId: String): Boolean { + val task = tasks.remove(taskId) ?: return false + task.call = null + task.job = null + return true + } + + fun installApk(file: File): Boolean { + return runCatching { + val authority = "${context.packageName}.sdk.fileprovider" + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile(context, authority, file) + } else { + Uri.fromFile(file) + } + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(uri, "application/vnd.android.package-archive") + }, + ) + }.isSuccess + } + + private fun resolvePath(storagePath: StoragePath, customPath: String?): String { + return when (storagePath) { + StoragePath.DOWNLOADS -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + StoragePath.CACHE -> context.cacheDir.absolutePath + StoragePath.EXTERNAL_CACHE -> context.externalCacheDir?.absolutePath ?: context.cacheDir.absolutePath + StoragePath.FILES -> context.filesDir.absolutePath + StoragePath.EXTERNAL_FILES -> context.getExternalFilesDir(null)?.absolutePath ?: context.filesDir.absolutePath + StoragePath.CUSTOM -> customPath ?: context.cacheDir.absolutePath + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt new file mode 100644 index 0000000..15946b9 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt @@ -0,0 +1,69 @@ +package com.xuqm.sdk.update + +data class VersionInfo( + val versionCode: Long = 0L, + val versionName: String = "", +) + +enum class VersionCheckStrategy { + VERSION_CODE, + VERSION_NAME, + VERSION_CODE_OR_NAME, +} + +sealed class VersionCheckResult { + data class NeedUpdate( + val current: VersionInfo, + val remote: VersionInfo, + val strategy: VersionCheckStrategy, + ) : VersionCheckResult() + + data class UpToDate( + val current: VersionInfo, + val remote: VersionInfo, + val strategy: VersionCheckStrategy, + ) : VersionCheckResult() +} + +object VersionComparator { + fun compareVersionCode(currentVersion: Long, newVersion: Long): Int = newVersion.compareTo(currentVersion) + + fun compareVersionName(currentVersion: String, newVersion: String): Int { + val currentParts = currentVersion.split(".") + val newParts = newVersion.split(".") + val maxLength = maxOf(currentParts.size, newParts.size) + for (index in 0 until maxLength) { + val currentPart = currentParts.getOrElse(index) { "0" }.toIntOrNull() ?: 0 + val newPart = newParts.getOrElse(index) { "0" }.toIntOrNull() ?: 0 + if (newPart != currentPart) return newPart.compareTo(currentPart) + } + return 0 + } + + fun check( + current: VersionInfo, + remote: VersionInfo, + strategy: VersionCheckStrategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + ): VersionCheckResult { + val needsUpdate = when (strategy) { + VersionCheckStrategy.VERSION_CODE -> + remote.versionCode > 0 && compareVersionCode(current.versionCode, remote.versionCode) > 0 + + VersionCheckStrategy.VERSION_NAME -> + remote.versionName.isNotBlank() && compareVersionName(current.versionName, remote.versionName) > 0 + + VersionCheckStrategy.VERSION_CODE_OR_NAME -> { + val byCode = remote.versionCode > 0 && compareVersionCode(current.versionCode, remote.versionCode) > 0 + val byName = remote.versionName.isNotBlank() && + compareVersionName(current.versionName, remote.versionName) > 0 + byCode || byName + } + } + + return if (needsUpdate) { + VersionCheckResult.NeedUpdate(current = current, remote = remote, strategy = strategy) + } else { + VersionCheckResult.UpToDate(current = current, remote = remote, strategy = strategy) + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt new file mode 100644 index 0000000..cbaea7d --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt @@ -0,0 +1,20 @@ +package com.xuqm.sdk.utils + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object DateTimeUtils { + fun format( + timeMillis: Long, + pattern: String = "yyyy-MM-dd HH:mm:ss", + timeZone: TimeZone = TimeZone.getDefault(), + locale: Locale = Locale.getDefault(), + ): String { + return SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.format(Date(timeMillis)) + } + + fun now(pattern: String = "yyyy-MM-dd HH:mm:ss"): String = format(System.currentTimeMillis(), pattern) +} + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt new file mode 100644 index 0000000..4fe424b --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DeviceUtils.kt @@ -0,0 +1,47 @@ +package com.xuqm.sdk.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.provider.Settings +import java.util.UUID + +object DeviceUtils { + private const val PREFS_NAME = "commonsdk_device_prefs" + private const val KEY_DEVICE_ID = "device_id" + + @SuppressLint("HardwareIds") + fun getDeviceId(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + var deviceId = prefs.getString(KEY_DEVICE_ID, null) + if (deviceId.isNullOrEmpty()) { + deviceId = runCatching { + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + }.getOrNull() + if (deviceId.isNullOrEmpty() || deviceId == "9774d56d682e549c") { + deviceId = UUID.randomUUID().toString().replace("-", "") + } + prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply() + } + return deviceId + } + + fun getPhoneModel(): String = Build.MODEL ?: "Unknown" + fun getPhoneVersion(): String = Build.VERSION.RELEASE ?: "Unknown" + fun getPhoneBrand(): String = Build.BRAND ?: "Unknown" + + fun getDeviceInfo(context: Context) = DeviceInfo( + deviceId = getDeviceId(context), + phoneModel = getPhoneModel(), + phoneVersion = getPhoneVersion(), + phoneBrand = getPhoneBrand(), + ) +} + +data class DeviceInfo( + val deviceId: String, + val phoneModel: String, + val phoneVersion: String, + val phoneBrand: String, +) + diff --git a/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml b/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml new file mode 100644 index 0000000..8ee28f4 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/res/xml/core_file_paths.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/AndroidLibs/docs/architecture.md b/AndroidLibs/docs/architecture.md new file mode 100644 index 0000000..a7b58ce --- /dev/null +++ b/AndroidLibs/docs/architecture.md @@ -0,0 +1,50 @@ +# AndroidLibs Architecture + +## 目标结构 + +```text +AndroidLibs/ +├── commonsdk-core/ +├── commonsdk-compose/ +├── lib-szyx/ +├── sample-app/ +├── plugins/ +│ └── plugin-ui/ +└── docs/ +``` + +## 设计说明 + +### commonsdk-core + +- 提供与业务无关的基础能力 +- 包含多 BaseUrl Retrofit 封装 +- 提供共享缓存 `SharedCacheManager` +- 提供插件安装、启动、版本比较 `PluginPackageManager` +- 提供 App 下载与安装 `AppUpdater` + +### commonsdk-compose + +- 提供 Compose 组件 +- 当前包含基础卡片与手风琴组件 + +### lib-szyx + +- 承载项目专属登录逻辑 +- 登录接口、签名算法、业务 Header 均来自 `LibsDemo` +- 登录成功后本地持久化,并同步写入共享缓存 +- 插件端支持从宿主共享缓存读取登录态 + +### sample-app + +- 宿主示例 +- 打开 `lib-szyx` 登录页 +- 缓存当前用户并启动 `plugin-ui` +- 演示插件下载与 App 更新下载 + +### plugins/plugin-ui + +- 独立 APK 插件 +- 支持单独安装运行 +- 支持宿主启动时读取共享登录态 +- 支持再次打开 `lib-szyx` 登录页并更新共享会话 diff --git a/AndroidLibs/gradle.properties b/AndroidLibs/gradle.properties new file mode 100644 index 0000000..3eb9177 --- /dev/null +++ b/AndroidLibs/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.configuration-cache=true +PUBLISH_GROUP=com.xuqm +PUBLISH_VERSION=0.1.0-SNAPSHOT diff --git a/AndroidLibs/gradle/gradle-daemon-jvm.properties b/AndroidLibs/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/AndroidLibs/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/AndroidLibs/gradle/libs.versions.toml b/AndroidLibs/gradle/libs.versions.toml new file mode 100644 index 0000000..20ba47c --- /dev/null +++ b/AndroidLibs/gradle/libs.versions.toml @@ -0,0 +1,71 @@ +[versions] +agp = "9.1.0" +kotlin = "2.3.10" +compileSdk = "36" +targetSdk = "36" +minSdk = "24" +coreKtx = "1.18.0" +lifecycle = "2.10.0" +activityCompose = "1.13.0" +composeBom = "2026.03.00" +coroutines = "1.10.2" +datastore = "1.1.7" +retrofit = "3.0.0" +okhttp = "5.3.2" +gson = "2.13.2" +jserialization = "1.9.0" +junit4 = "4.13.2" +androidxJunit = "1.3.0" +espresso = "3.7.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } + +[bundles] +compose = [ + "androidx-ui", + "androidx-ui-graphics", + "androidx-ui-tooling-preview", + "androidx-material3", + "androidx-material-icons-extended" +] +compose-debug = [ + "androidx-ui-tooling", + "androidx-ui-test-manifest" +] +network = [ + "retrofit", + "retrofit-converter-gson", + "okhttp", + "okhttp-logging", + "gson" +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/AndroidLibs/gradle/publishing.gradle.kts b/AndroidLibs/gradle/publishing.gradle.kts new file mode 100644 index 0000000..b8dd33f --- /dev/null +++ b/AndroidLibs/gradle/publishing.gradle.kts @@ -0,0 +1,47 @@ +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.kotlin.dsl.configure + +apply(plugin = "maven-publish") + +val publishGroup = providers.gradleProperty("PUBLISH_GROUP").getOrElse("com.xuqm") +val publishVersion = providers.gradleProperty("PUBLISH_VERSION").getOrElse("0.1.0-SNAPSHOT") + +group = publishGroup +version = publishVersion + +configure { + publications { + register("release") { + groupId = publishGroup + artifactId = project.name + version = publishVersion + + afterEvaluate { + from(components.findByName("release")) + } + } + } + + repositories { + maven { + val isSnapshot = publishVersion.endsWith("SNAPSHOT") + name = if (isSnapshot) "xuqmSnapshot" else "xuqmRelease" + url = uri( + if (isSnapshot) { + "https://nexus.xuqinmin.com/repository/android-snapshot/" + } else { + "https://nexus.xuqinmin.com/repository/android-hosted/" + }, + ) + credentials { + username = providers.gradleProperty("nexus.username") + .orElse(providers.environmentVariable("NEXUS_USERNAME")) + .orNull + password = providers.gradleProperty("nexus.password") + .orElse(providers.environmentVariable("NEXUS_PASSWORD")) + .orNull + } + } + } +} diff --git a/AndroidLibs/gradle/wrapper/gradle-wrapper.jar b/AndroidLibs/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/AndroidLibs/gradle/wrapper/gradle-wrapper.properties b/AndroidLibs/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5a1a6d4 --- /dev/null +++ b/AndroidLibs/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists + diff --git a/AndroidLibs/gradlew b/AndroidLibs/gradlew new file mode 100755 index 0000000..2920cc7 --- /dev/null +++ b/AndroidLibs/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +PRG="$0" +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" + diff --git a/AndroidLibs/gradlew.bat b/AndroidLibs/gradlew.bat new file mode 100644 index 0000000..7357d50 --- /dev/null +++ b/AndroidLibs/gradlew.bat @@ -0,0 +1,79 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega + diff --git a/AndroidLibs/lib-szyx/build.gradle.kts b/AndroidLibs/lib-szyx/build.gradle.kts new file mode 100644 index 0000000..c190685 --- /dev/null +++ b/AndroidLibs/lib-szyx/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +android { + namespace = "com.xuqm.szyx" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + consumerProguardFiles("consumer-rules.pro") + manifestPlaceholders["authProviderAuthority"] = "com.xuqm.szyx.auth" + manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.szyx.sdk.cache.provider" + manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.szyx.sdk.fileprovider" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + api(project(":commonsdk-core")) + api(project(":commonsdk-compose")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.serialization.json) + + debugImplementation(libs.bundles.compose.debug) +} diff --git a/AndroidLibs/lib-szyx/consumer-rules.pro b/AndroidLibs/lib-szyx/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/lib-szyx/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml b/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10d5d3c --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt new file mode 100644 index 0000000..1c4413c --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/SzyxSDK.kt @@ -0,0 +1,30 @@ +package com.xuqm.szyx + +import android.content.Context +import com.xuqm.sdk.CoreSDK +import com.xuqm.szyx.auth.UserSessionManager + +object SzyxSDK { + data class Config( + val baseUrl: String = "https://dev.51trust.com/", + val clientId: String = "2000111111110002", + val hostAppPackageName: String? = null, + val debugMode: Boolean = false, + ) + + private var config: Config? = null + private var appContext: Context? = null + + fun init(context: Context, config: Config = Config()) { + this.appContext = context.applicationContext + this.config = config + CoreSDK.init(context, CoreSDK.SDKConfig(debugMode = config.debugMode)) + UserSessionManager.init(context) + } + + fun isInitialized(): Boolean = appContext != null && config != null + + fun context(): Context = requireNotNull(appContext) { "SzyxSDK not initialized" } + + fun requireConfig(): Config = requireNotNull(config) { "SzyxSDK not initialized" } +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt new file mode 100644 index 0000000..e76eca3 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthApi.kt @@ -0,0 +1,14 @@ +package com.xuqm.szyx.auth + +import com.xuqm.sdk.network.HttpResult +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthApi { + @POST("am/v3/userCenter/account/getSMSVerifyCode") + suspend fun getSmsVerifyCode(@Body request: GetSmsCodeRequest): SmsCodeResult + + @POST("am/v3/userCenter/account/login") + suspend fun login(@Body request: LoginRequest): HttpResult +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt new file mode 100644 index 0000000..260873c --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthModels.kt @@ -0,0 +1,35 @@ +package com.xuqm.szyx.auth + +import com.google.gson.JsonObject +import com.xuqm.sdk.network.HttpResult + +data class GetSmsCodeRequest( + val phoneNum: String, + val type: Int = 1, + val time: Long, + val sign: String, +) + +data class LoginRequest( + val phoneNum: String, + val verifyCode: String, + val deviceType: String = "5", +) + +data class LoginModel( + val sessionId: String, + val userId: String, + val userType: Int, + val realNameStatus: Int, + val enableCert: Boolean, + val gxLeader: Boolean, + val hasBindFirm: Boolean, + val hasFaceDetect: Boolean, +) + +data class LoginSession( + val phone: String, + val loginModel: LoginModel, +) + +typealias SmsCodeResult = HttpResult diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt new file mode 100644 index 0000000..9a0c90f --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/AuthRepository.kt @@ -0,0 +1,51 @@ +package com.xuqm.szyx.auth + +import com.xuqm.sdk.network.HttpConfig +import com.xuqm.sdk.network.HttpManager +import com.xuqm.szyx.SzyxSDK +import com.xuqm.szyx.http.BusinessHeaderInterceptor +import com.xuqm.szyx.utils.SignUtil + +class AuthRepository { + private fun api(): AuthApi { + val config = SzyxSDK.requireConfig() + return HttpManager.getService( + baseUrl = config.baseUrl, + serviceClass = AuthApi::class.java, + config = HttpConfig( + debugMode = config.debugMode, + interceptors = listOf(BusinessHeaderInterceptor()), + ), + ) + } + + suspend fun getSmsCode(phone: String): Result { + val timeStamp = System.currentTimeMillis() + val response = api().getSmsVerifyCode( + GetSmsCodeRequest( + phoneNum = phone, + time = timeStamp, + sign = SignUtil.generateSign(timeStamp), + ), + ) + return if (response.isSuccess()) Result.success(Unit) else Result.failure(IllegalStateException(response.message ?: "获取验证码失败")) + } + + suspend fun login(phone: String, verifyCode: String): Result { + val response = api().login( + LoginRequest( + phoneNum = phone, + verifyCode = verifyCode, + ), + ) + val model = response.data + return if (response.isSuccess() && model != null) { + val session = LoginSession(phone = phone, loginModel = model) + UserSessionManager.save(session) + Result.success(session) + } else { + Result.failure(IllegalStateException(response.message ?: "登录失败")) + } + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt new file mode 100644 index 0000000..97165d0 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/auth/UserSessionManager.kt @@ -0,0 +1,107 @@ +package com.xuqm.szyx.auth + +import android.content.Context +import androidx.core.content.edit +import com.xuqm.sdk.cache.CacheKeys +import com.xuqm.sdk.cache.SharedCacheManager +import com.xuqm.szyx.SzyxSDK +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.Serializable +import org.json.JSONObject + +object UserSessionManager { + private const val PREF_NAME = "szyx_user_session" + private const val KEY_SESSION = "login_session" + private lateinit var appContext: Context + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + private data class SessionCache( + val phone: String, + val sessionId: String, + val userId: String, + val userType: Int, + val realNameStatus: Int, + val enableCert: Boolean, + val gxLeader: Boolean, + val hasBindFirm: Boolean, + val hasFaceDetect: Boolean, + ) + + fun init(context: Context) { + appContext = context.applicationContext + } + + fun save(session: LoginSession, syncSharedCache: Boolean = true) { + val cache = SessionCache( + phone = session.phone, + sessionId = session.loginModel.sessionId, + userId = session.loginModel.userId, + userType = session.loginModel.userType, + realNameStatus = session.loginModel.realNameStatus, + enableCert = session.loginModel.enableCert, + gxLeader = session.loginModel.gxLeader, + hasBindFirm = session.loginModel.hasBindFirm, + hasFaceDetect = session.loginModel.hasFaceDetect, + ) + prefs().edit { putString(KEY_SESSION, json.encodeToString(cache)) } + if (syncSharedCache) { + val payload = JSONObject().apply { + put("phone", session.phone) + put("userId", session.loginModel.userId) + put("sessionId", session.loginModel.sessionId) + put("clientId", SzyxSDK.requireConfig().clientId) + put("timestamp", System.currentTimeMillis()) + } + val sharedCache = SharedCacheManager.getInstance(appContext) + val hostPackageName = SzyxSDK.requireConfig().hostAppPackageName + if (!hostPackageName.isNullOrEmpty() && hostPackageName != appContext.packageName) { + sharedCache.putRemote(CacheKeys.CURRENT_USER, payload.toString(), appPackageName = hostPackageName) + } else { + sharedCache.put(CacheKeys.CURRENT_USER, payload.toString()) + } + } + } + + fun getSession(): LoginSession? { + val raw = prefs().getString(KEY_SESSION, null) ?: return null + return runCatching { json.decodeFromString(SessionCache.serializer(), raw) }.getOrNull()?.let { + LoginSession( + phone = it.phone, + loginModel = LoginModel( + sessionId = it.sessionId, + userId = it.userId, + userType = it.userType, + realNameStatus = it.realNameStatus, + enableCert = it.enableCert, + gxLeader = it.gxLeader, + hasBindFirm = it.hasBindFirm, + hasFaceDetect = it.hasFaceDetect, + ), + ) + } + } + + fun loadSharedSession(hostAppPackageName: String): LoginSession? { + val raw = SharedCacheManager.getInstance(appContext).getSync(CacheKeys.CURRENT_USER, hostAppPackageName) ?: return null + return runCatching { + val jsonObject = JSONObject(raw) + LoginSession( + phone = jsonObject.optString("phone"), + loginModel = LoginModel( + sessionId = jsonObject.getString("sessionId"), + userId = jsonObject.getString("userId"), + userType = 0, + realNameStatus = 0, + enableCert = false, + gxLeader = false, + hasBindFirm = false, + hasFaceDetect = false, + ), + ) + }.getOrNull() + } + + private fun prefs() = appContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt new file mode 100644 index 0000000..5faa671 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/http/BusinessHeaderInterceptor.kt @@ -0,0 +1,37 @@ +package com.xuqm.szyx.http + +import com.xuqm.sdk.utils.DeviceUtils +import com.xuqm.szyx.SzyxSDK +import com.xuqm.szyx.auth.UserSessionManager +import com.xuqm.szyx.utils.SignUtil +import okhttp3.Interceptor +import okhttp3.Response + +class BusinessHeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val context = SzyxSDK.context() + val config = SzyxSDK.requireConfig() + val session = UserSessionManager.getSession() + val timeStamp = System.currentTimeMillis() + val request = chain.request().newBuilder().apply { + header("clientId", config.clientId) + header("deviceType", "5") + header("version", "1.0.0") + header("deviceId", DeviceUtils.getDeviceId(context)) + header("timeStamp", timeStamp.toString()) + header("sign", SignUtil.generateSign(timeStamp)) + header("phoneModel", DeviceUtils.getPhoneModel()) + header("phoneVersion", DeviceUtils.getPhoneVersion()) + header("phoneBrand", DeviceUtils.getPhoneBrand()) + session?.loginModel?.sessionId?.let { + header("X-Access-Token", it) + header("sessionId", it) + } + if (chain.request().header("Content-Type") == null) { + header("Content-Type", "application/json") + } + }.build() + return chain.proceed(request) + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt new file mode 100644 index 0000000..6eb11cc --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt @@ -0,0 +1,119 @@ +package com.xuqm.szyx.login + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.xuqm.sdk.ui.ToastCenter +import com.xuqm.szyx.SzyxSDK +import com.xuqm.szyx.auth.AuthRepository +import com.xuqm.szyx.auth.UserSessionManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SzyxLoginActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!SzyxSDK.isInitialized()) { + SzyxSDK.init(this) + } + ToastCenter.init(this) + setContent { MaterialTheme { LoginScreen { finish() } } } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoginScreen(onSuccess: () -> Unit) { + val scope = rememberCoroutineScope() + var phone by remember { mutableStateOf("") } + var code by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + var countdown by remember { mutableStateOf(0) } + val repository = remember { AuthRepository() } + + LaunchedEffect(countdown) { + while (countdown > 0) { + delay(1000) + countdown -= 1 + } + } + + Scaffold(topBar = { TopAppBar(title = { Text("登录") }) }) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = phone, + onValueChange = { phone = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("手机号") }, + ) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("验证码") }, + ) + Button( + onClick = { + scope.launch { + loading = true + repository.getSmsCode(phone) + .onSuccess { + countdown = 60 + ToastCenter.show("验证码发送成功") + } + .onFailure { ToastCenter.show(it.message ?: "发送失败") } + loading = false + } + }, + enabled = !loading && countdown == 0, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (countdown > 0) "${countdown}s 后重试" else "获取验证码") + } + Button( + onClick = { + scope.launch { + loading = true + repository.login(phone, code) + .onSuccess { + ToastCenter.show("登录成功") + onSuccess() + } + .onFailure { ToastCenter.show(it.message ?: "登录失败") } + loading = false + } + }, + enabled = !loading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("登录") + } + + UserSessionManager.getSession()?.let { + Text("当前登录用户: ${it.phone}") + Text("userId: ${it.loginModel.userId}") + } + } + } +} diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt new file mode 100644 index 0000000..c6913a3 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/utils/SignUtil.kt @@ -0,0 +1,17 @@ +package com.xuqm.szyx.utils + +import java.security.MessageDigest + +object SignUtil { + private const val MD5_KEY = "YWQ!@#" + + fun generateSign(timeStamp: Long): String { + return md5Hex("timeStamp=${timeStamp}#$MD5_KEY") + } + + private fun md5Hex(input: String): String { + val md = MessageDigest.getInstance("MD5") + return md.digest(input.toByteArray()).joinToString("") { "%02x".format(it) } + } +} + diff --git a/AndroidLibs/lib-szyx/src/main/res/values/strings.xml b/AndroidLibs/lib-szyx/src/main/res/values/strings.xml new file mode 100644 index 0000000..3634de0 --- /dev/null +++ b/AndroidLibs/lib-szyx/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + lib-szyx + + diff --git a/AndroidLibs/plugins/plugin-ui/build.gradle.kts b/AndroidLibs/plugins/plugin-ui/build.gradle.kts new file mode 100644 index 0000000..4e9e9ee --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.xuqm.plugin.ui" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.xuqm.plugin.ui" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["authProviderAuthority"] = "com.xuqm.plugin.ui.auth" + manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.plugin.ui.sdk.cache.provider" + manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.plugin.ui.sdk.fileprovider" + buildConfigField("String", "HOST_PACKAGE", "\"com.xuqm.sample\"") + buildConfigField("String", "API_BASE_URL", "\"https://dev.51trust.com/\"") + buildConfigField("String", "CLIENT_ID", "\"2000111111110002\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(project(":commonsdk-core")) + implementation(project(":commonsdk-compose")) + implementation(project(":lib-szyx")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + + testImplementation(libs.junit4) + debugImplementation(libs.bundles.compose.debug) +} diff --git a/AndroidLibs/plugins/plugin-ui/proguard-rules.pro b/AndroidLibs/plugins/plugin-ui/proguard-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/proguard-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml b/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..92aeadb --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt new file mode 100644 index 0000000..f7a1062 --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt @@ -0,0 +1,114 @@ +package com.xuqm.plugin.ui + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.xuqm.sdk.CoreSDK +import com.xuqm.sdk.compose.components.FeatureCard +import com.xuqm.szyx.SzyxSDK +import com.xuqm.szyx.auth.LoginSession +import com.xuqm.szyx.auth.UserSessionManager +import com.xuqm.szyx.login.SzyxLoginActivity + +class PluginUiActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val hostPackageName = intent.getStringExtra("hostPackageName") + + CoreSDK.init(this, CoreSDK.SDKConfig(debugMode = true)) + SzyxSDK.init( + this, + SzyxSDK.Config( + baseUrl = BuildConfig.API_BASE_URL, + clientId = BuildConfig.CLIENT_ID, + hostAppPackageName = hostPackageName, + debugMode = true, + ), + ) + + setContent { + MaterialTheme { + PluginUiScreen( + hostPackageName = hostPackageName, + onOpenLogin = { startActivity(Intent(this, SzyxLoginActivity::class.java)) }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PluginUiScreen( + hostPackageName: String?, + onOpenLogin: () -> Unit, +) { + val localSession = remember { mutableStateOf(UserSessionManager.getSession()) } + val sharedSession = remember { mutableStateOf(hostPackageName?.let { UserSessionManager.loadSharedSession(it) }) } + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner, hostPackageName) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + localSession.value = UserSessionManager.getSession() + sharedSession.value = hostPackageName?.let { UserSessionManager.loadSharedSession(it) } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold(topBar = { TopAppBar(title = { Text("Plugin UI") }) }) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("本地登录: ${localSession.value?.phone ?: "无"}") + Text("宿主共享登录: ${sharedSession.value?.phone ?: "无"}") + Button(onClick = onOpenLogin, modifier = Modifier.fillMaxWidth()) { + Text("打开 lib-szyx 登录页") + } + } + } + + item { + FeatureCard( + title = "插件独立运行", + description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。", + ) + } + + item { + FeatureCard( + title = "共享缓存", + description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。", + ) + } + } + } +} diff --git a/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt new file mode 100644 index 0000000..ebf368c --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/service/PluginUiApiService.kt @@ -0,0 +1,12 @@ +package com.xuqm.plugin.ui.service + +import com.xuqm.sdk.network.HttpResult +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface PluginUiApiService { + @FormUrlEncoded + @POST("plugin/ui/demo") + suspend fun demo(@Field("module") module: String): HttpResult> +} diff --git a/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml b/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..7c708b8 --- /dev/null +++ b/AndroidLibs/plugins/plugin-ui/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Plugin UI + + diff --git a/AndroidLibs/sample-app/build.gradle.kts b/AndroidLibs/sample-app/build.gradle.kts new file mode 100644 index 0000000..09d3055 --- /dev/null +++ b/AndroidLibs/sample-app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.xuqm.sample" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.xuqm.sample" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["authProviderAuthority"] = "com.xuqm.sample.auth" + manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.sample.sdk.cache.provider" + manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.sample.sdk.fileprovider" + buildConfigField("String", "UPDATE_SERVER_BASE_URL", "\"http://192.168.116.9:3000/\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(project(":commonsdk-core")) + implementation(project(":commonsdk-compose")) + implementation(project(":lib-szyx")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + + testImplementation(libs.junit4) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.bundles.compose.debug) +} diff --git a/AndroidLibs/sample-app/proguard-rules.pro b/AndroidLibs/sample-app/proguard-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/sample-app/proguard-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/sample-app/src/main/AndroidManifest.xml b/AndroidLibs/sample-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4613d20 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt new file mode 100644 index 0000000..e440525 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt @@ -0,0 +1,519 @@ +package com.xuqm.sample + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.xuqm.sdk.CoreSDK +import com.xuqm.sdk.compose.components.AccordionGroup +import com.xuqm.sdk.compose.components.FeatureCard +import com.xuqm.sdk.plugin.PluginPackageManager +import com.xuqm.sdk.ui.ToastCenter +import com.xuqm.sdk.update.DownloadState +import com.xuqm.sdk.update.StoragePath +import com.xuqm.sdk.update.UpdateInfo +import com.xuqm.sdk.update.VersionCheckResult +import com.xuqm.sdk.update.VersionCheckStrategy +import com.xuqm.sdk.utils.DateTimeUtils +import com.xuqm.sample.update.UpdateRepository +import com.xuqm.szyx.SzyxSDK +import com.xuqm.szyx.auth.LoginSession +import com.xuqm.szyx.auth.UserSessionManager +import com.xuqm.szyx.login.SzyxLoginActivity +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + + companion object { + private const val PLUGIN_PACKAGE_NAME = "com.xuqm.plugin.ui" + private const val PLUGIN_ENTRY_ACTIVITY = "com.xuqm.plugin.ui.PluginUiActivity" + } + + private val sessionState = mutableStateOf(null) + private val pluginInstalledState = mutableStateOf(false) + private val pluginDownloadState = mutableStateOf(DownloadState.Idle) + private val appDownloadState = mutableStateOf(DownloadState.Idle) + private val pendingAppUpdateState = mutableStateOf(null) + + private var pluginDownloadTaskId: String? = null + private var appDownloadTaskId: String? = null + private var pluginDownloadJob: Job? = null + private var appDownloadJob: Job? = null + private var loginPromptedOnLaunch = false + private var reloadPluginAfterInstall = false + private val updateRepository by lazy { UpdateRepository(BuildConfig.UPDATE_SERVER_BASE_URL) } + private var currentPluginUpdateInfo: PluginPackageManager.PluginUpdateInfo? = null + + private val packageChangedReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val packageName = intent?.data?.schemeSpecificPart ?: return + if (packageName == PLUGIN_PACKAGE_NAME) { + refreshState() + ToastCenter.show("plugin-ui 安装状态已更新") + if (intent.action == Intent.ACTION_PACKAGE_ADDED && reloadPluginAfterInstall) { + reloadPluginAfterInstall = false + val pluginUpdateInfo = currentPluginUpdateInfo + CoreSDK.pluginPackageManager().reloadPlugin( + packageName = PLUGIN_PACKAGE_NAME, + entryActivity = pluginUpdateInfo?.entryActivity ?: PLUGIN_ENTRY_ACTIVITY, + extras = pluginUpdateInfo?.extras ?: mapOf("hostPackageName" to this@MainActivity.packageName), + ) + } + } + } + } + + private val loginLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + refreshSession() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + CoreSDK.init(this, CoreSDK.SDKConfig(debugMode = true)) + SzyxSDK.init( + this, + SzyxSDK.Config( + baseUrl = "https://dev.51trust.com/", + clientId = "2000111111110002", + hostAppPackageName = packageName, + debugMode = true, + ), + ) + ToastCenter.init(this) + refreshState() + ensureLoginOnLaunch() + registerPackageChangeReceiver() + + setContent { + MaterialTheme { + SampleHome( + session = sessionState.value, + pluginInstalled = pluginInstalledState.value, + pluginDownloadState = pluginDownloadState.value, + appDownloadState = appDownloadState.value, + pendingAppUpdate = pendingAppUpdateState.value, + onOpenLogin = ::openLogin, + onOpenPlugin = ::openPlugin, + onInstallPlugin = ::downloadPlugin, + onCancelPluginDownload = ::cancelPluginDownload, + onUpdateApp = ::downloadApp, + onConfirmAppUpdate = ::confirmDownloadAppUpdate, + onDismissAppUpdate = ::dismissAppUpdateDialog, + onCancelAppDownload = ::cancelAppDownload, + onRetryCheckPlugin = ::refreshState, + onExitApp = { finish() }, + ) + } + } + } + + override fun onDestroy() { + pluginDownloadJob?.cancel() + appDownloadJob?.cancel() + runCatching { unregisterReceiver(packageChangedReceiver) } + super.onDestroy() + } + + override fun onResume() { + super.onResume() + refreshState() + } + + private fun ensureLoginOnLaunch() { + if (sessionState.value == null && !loginPromptedOnLaunch) { + loginPromptedOnLaunch = true + openLogin() + } + } + + private fun refreshSession() { + sessionState.value = UserSessionManager.getSession() + } + + private fun refreshState() { + refreshSession() + pluginInstalledState.value = CoreSDK.pluginPackageManager().isPluginInstalled(PLUGIN_PACKAGE_NAME) + } + + private fun openLogin() { + loginLauncher.launch(Intent(this, SzyxLoginActivity::class.java)) + } + + private fun registerPackageChangeReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + ContextCompat.registerReceiver( + this, + packageChangedReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + private fun openPlugin() { + val session = sessionState.value + if (session == null) { + ToastCenter.show("请先登录") + openLogin() + return + } + + val pluginManager = CoreSDK.pluginPackageManager() + pluginManager.cacheCurrentUser( + userId = session.loginModel.userId, + sessionId = session.loginModel.sessionId, + clientId = SzyxSDK.requireConfig().clientId, + extraData = mapOf("phone" to session.phone), + ) + val launched = pluginManager.startPlugin( + packageName = PLUGIN_PACKAGE_NAME, + entryActivity = PLUGIN_ENTRY_ACTIVITY, + extras = mapOf("hostPackageName" to packageName), + ) + if (!launched) { + ToastCenter.show("未检测到已安装的 plugin-ui,请先下载安装") + } + } + + private fun downloadPlugin() { + lifecycleScope.launch { + updateRepository.fetchLatestPluginUpdate(PLUGIN_PACKAGE_NAME) + .onSuccess { remoteUpdate -> + val pluginUpdateInfo = remoteUpdate.copy( + entryActivity = remoteUpdate.entryActivity ?: PLUGIN_ENTRY_ACTIVITY, + extras = mapOf("hostPackageName" to packageName), + ) + val checkResult = CoreSDK.pluginPackageManager().checkPluginUpdate( + packageName = pluginUpdateInfo.packageName, + remoteVersionCode = pluginUpdateInfo.versionCode, + remoteVersionName = pluginUpdateInfo.versionName, + strategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + ) + if (checkResult is VersionCheckResult.UpToDate) { + ToastCenter.show("当前插件已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})") + return@onSuccess + } + currentPluginUpdateInfo = pluginUpdateInfo + val taskId = CoreSDK.pluginPackageManager().downloadPlugin( + updateInfo = pluginUpdateInfo, + fileName = "plugin-ui-release.apk", + storagePath = StoragePath.EXTERNAL_FILES, + ) + pluginDownloadTaskId = taskId + observePluginDownload(taskId) + } + .onFailure { + ToastCenter.show(it.message ?: "获取插件更新配置失败") + } + } + } + + private fun cancelPluginDownload() { + val taskId = pluginDownloadTaskId ?: return + CoreSDK.downloadManager().cancel(taskId) + } + + private fun downloadApp() { + lifecycleScope.launch { + updateRepository.fetchLatestAppUpdate(packageName) + .onSuccess { updateInfo -> + when ( + val checkResult = CoreSDK.appUpdater().checkUpdate( + updateInfo = updateInfo, + strategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, + ) + ) { + is VersionCheckResult.NeedUpdate -> { + pendingAppUpdateState.value = updateInfo + } + is VersionCheckResult.UpToDate -> { + ToastCenter.show("当前已是最新版本 ${checkResult.current.versionName}(${checkResult.current.versionCode})") + } + } + } + .onFailure { + ToastCenter.show(it.message ?: "获取 App 更新配置失败") + } + } + } + + private fun confirmDownloadAppUpdate() { + val updateInfo = pendingAppUpdateState.value ?: return + pendingAppUpdateState.value = null + val taskId = CoreSDK.appUpdater().downloadUpdate( + updateInfo = updateInfo, + fileName = "sample-app-update.apk", + storagePath = StoragePath.EXTERNAL_FILES, + ) + appDownloadTaskId = taskId + observeAppDownload(taskId) + } + + private fun cancelAppDownload() { + val taskId = appDownloadTaskId ?: return + CoreSDK.downloadManager().cancel(taskId) + } + + private fun dismissAppUpdateDialog() { + pendingAppUpdateState.value = null + } + + private fun observePluginDownload(taskId: String) { + pluginDownloadJob?.cancel() + pluginDownloadJob = lifecycleScope.launch { + CoreSDK.downloadManager().observe(taskId)?.collect { state -> + pluginDownloadState.value = state + when (state) { + is DownloadState.Success -> { + ToastCenter.show("插件下载完成,准备重新加载") + reloadPluginAfterInstall = true + if (!CoreSDK.pluginPackageManager().loadPlugin(state.file)) { + reloadPluginAfterInstall = false + ToastCenter.show("插件加载拉起失败") + } + CoreSDK.downloadManager().clear(taskId) + pluginDownloadTaskId = null + pluginDownloadJob?.cancel() + } + + is DownloadState.Error -> { + ToastCenter.show("插件下载失败: ${state.message}") + CoreSDK.downloadManager().clear(taskId) + pluginDownloadTaskId = null + pluginDownloadJob?.cancel() + } + DownloadState.Cancelled -> { + ToastCenter.show("插件下载已取消") + CoreSDK.downloadManager().clear(taskId) + pluginDownloadTaskId = null + pluginDownloadJob?.cancel() + } + else -> Unit + } + } + } + } + + private fun observeAppDownload(taskId: String) { + appDownloadJob?.cancel() + appDownloadJob = lifecycleScope.launch { + CoreSDK.downloadManager().observe(taskId)?.collect { state -> + appDownloadState.value = state + when (state) { + is DownloadState.Success -> { + ToastCenter.show("安装包下载完成,准备安装") + if (!CoreSDK.appUpdater().installApk(state.file)) { + ToastCenter.show("应用安装拉起失败") + } + CoreSDK.downloadManager().clear(taskId) + appDownloadTaskId = null + appDownloadJob?.cancel() + } + + is DownloadState.Error -> { + ToastCenter.show("应用下载失败: ${state.message}") + CoreSDK.downloadManager().clear(taskId) + appDownloadTaskId = null + appDownloadJob?.cancel() + } + DownloadState.Cancelled -> { + ToastCenter.show("应用下载已取消") + CoreSDK.downloadManager().clear(taskId) + appDownloadTaskId = null + appDownloadJob?.cancel() + } + else -> Unit + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SampleHome( + session: LoginSession?, + pluginInstalled: Boolean, + pluginDownloadState: DownloadState, + appDownloadState: DownloadState, + pendingAppUpdate: UpdateInfo?, + onOpenLogin: () -> Unit, + onOpenPlugin: () -> Unit, + onInstallPlugin: () -> Unit, + onCancelPluginDownload: () -> Unit, + onUpdateApp: () -> Unit, + onConfirmAppUpdate: () -> Unit, + onDismissAppUpdate: () -> Unit, + onCancelAppDownload: () -> Unit, + onRetryCheckPlugin: () -> Unit, + onExitApp: () -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + var lastBackPressedAt by remember { mutableLongStateOf(0L) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + onRetryCheckPlugin() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + BackHandler(enabled = session == null) { + val now = System.currentTimeMillis() + if (now - lastBackPressedAt < 2_000L) { + onExitApp() + } else { + lastBackPressedAt = now + ToastCenter.show("未登录,双击返回退出应用") + } + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Sample Host") }) }, + ) { innerPadding -> + pendingAppUpdate?.let { updateInfo -> + AlertDialog( + onDismissRequest = onDismissAppUpdate, + title = { Text(updateInfo.title) }, + text = { + Text( + "发现新版本 ${updateInfo.versionName}\n\n${updateInfo.changelog.ifBlank { "检测到可用更新,是否立即下载?" }}", + ) + }, + confirmButton = { + TextButton(onClick = onConfirmAppUpdate) { + Text("立即更新") + } + }, + dismissButton = { + TextButton(onClick = onDismissAppUpdate) { + Text("稍后再说") + } + }, + ) + } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("当前时间: ${DateTimeUtils.now()}") + Text("登录状态: ${session?.phone ?: "未登录"}") + Text("插件安装状态: ${if (pluginInstalled) "已安装" else "未安装或当前宿主不可见"}") + Text("插件下载: ${pluginDownloadState.toDisplayText()}") + Text("应用下载: ${appDownloadState.toDisplayText()}") + Button(onClick = onOpenLogin, modifier = Modifier.fillMaxWidth()) { + Text(if (session == null) "打开登录页" else "重新登录") + } + Button( + onClick = onOpenPlugin, + enabled = session != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text("启动 plugin-ui") + } + Button(onClick = onInstallPlugin, modifier = Modifier.fillMaxWidth()) { + Text("下载并安装 plugin-ui") + } + if (pluginDownloadState is DownloadState.Starting || pluginDownloadState is DownloadState.Progress) { + Button(onClick = onCancelPluginDownload, modifier = Modifier.fillMaxWidth()) { + Text("取消插件下载") + } + } + Button(onClick = onUpdateApp, modifier = Modifier.fillMaxWidth()) { + Text("检查 App 更新") + } + if (appDownloadState is DownloadState.Starting || appDownloadState is DownloadState.Progress) { + Button(onClick = onCancelAppDownload, modifier = Modifier.fillMaxWidth()) { + Text("取消应用下载") + } + } + } + } + } + + item { + AccordionGroup(title = "当前方案", initiallyExpanded = true) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("1. 未登录启动时直接进入 lib-szyx 登录页") + Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度") + Text("3. plugin-ui 通过宿主共享缓存拿到 sessionId 和 userId") + } + } + } + + item { + FeatureCard( + title = "插件结构", + description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。", + ) + } + } + } +} + +private fun DownloadState.toDisplayText(): String { + return when (this) { + DownloadState.Idle -> "未开始" + DownloadState.Starting -> "准备下载" + is DownloadState.Progress -> { + val progressText = if (progress >= 0) "$progress%" else "未知进度" + "$progressText (${downloadedBytes}/${totalBytes.coerceAtLeast(0)})" + } + is DownloadState.Success -> "已完成: ${file.name}" + DownloadState.Cancelled -> "已取消" + is DownloadState.Error -> "失败: $message" + } +} diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt new file mode 100644 index 0000000..2fa2e83 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateApi.kt @@ -0,0 +1,17 @@ +package com.xuqm.sample.update + +import com.xuqm.sdk.network.HttpResult +import retrofit2.http.GET +import retrofit2.http.Query + +interface UpdateApi { + @GET("api/v1/updates/app/latest") + suspend fun getLatestAppUpdate( + @Query("packageName") packageName: String, + ): HttpResult + + @GET("api/v1/updates/plugin/latest") + suspend fun getLatestPluginUpdate( + @Query("packageName") packageName: String, + ): HttpResult +} diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt new file mode 100644 index 0000000..bee6678 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateModels.kt @@ -0,0 +1,19 @@ +package com.xuqm.sample.update + +data class AppUpdateResponse( + val packageName: String, + val versionCode: Int, + val versionName: String, + val title: String = "发现新版本", + val changelog: String = "", + val downloadUrl: String, + val forceUpdate: Boolean = false, +) + +data class PluginUpdateResponse( + val packageName: String, + val versionCode: Long, + val versionName: String, + val downloadUrl: String, + val entryActivity: String? = null, +) diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt new file mode 100644 index 0000000..63adae6 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/update/UpdateRepository.kt @@ -0,0 +1,48 @@ +package com.xuqm.sample.update + +import com.xuqm.sdk.network.HttpManager +import com.xuqm.sdk.plugin.PluginPackageManager +import com.xuqm.sdk.update.UpdateInfo + +class UpdateRepository( + private val baseUrl: String, +) { + private val api: UpdateApi by lazy { + HttpManager.getService(baseUrl = baseUrl, serviceClass = UpdateApi::class.java) + } + + suspend fun fetchLatestAppUpdate(packageName: String): Result { + return runCatching { + val result = api.getLatestAppUpdate(packageName) + if (!result.isSuccess()) { + error(result.message ?: "获取 App 更新配置失败") + } + val data = result.data ?: error("App 更新配置为空") + UpdateInfo( + versionCode = data.versionCode, + versionName = data.versionName, + title = data.title, + changelog = data.changelog, + downloadUrl = data.downloadUrl, + forceUpdate = data.forceUpdate, + ) + } + } + + suspend fun fetchLatestPluginUpdate(packageName: String): Result { + return runCatching { + val result = api.getLatestPluginUpdate(packageName) + if (!result.isSuccess()) { + error(result.message ?: "获取插件更新配置失败") + } + val data = result.data ?: error("插件更新配置为空") + PluginPackageManager.PluginUpdateInfo( + packageName = data.packageName, + versionCode = data.versionCode, + versionName = data.versionName, + downloadUrl = data.downloadUrl, + entryActivity = data.entryActivity, + ) + } + } +} diff --git a/AndroidLibs/sample-app/src/main/res/values/strings.xml b/AndroidLibs/sample-app/src/main/res/values/strings.xml new file mode 100644 index 0000000..71f4981 --- /dev/null +++ b/AndroidLibs/sample-app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Sample Host + + diff --git a/AndroidLibs/settings.gradle.kts b/AndroidLibs/settings.gradle.kts new file mode 100644 index 0000000..4b8eb89 --- /dev/null +++ b/AndroidLibs/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + maven(url = "https://nexus.xuqinmin.com/repository/android/") + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven(url = "https://nexus.xuqinmin.com/repository/android/") + google() + mavenCentral() + } +} + +rootProject.name = "AndroidLibs" + +include(":commonsdk-core") +include(":commonsdk-compose") +include(":lib-szyx") +include(":sample-app") +include(":plugins:plugin-ui") diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e03a8d4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,31 @@ +# frontend + +当前目录新增两个 Vue 3 前端项目: + +- `ops-platform`:运营平台,提供开放注册、版本管理、插件化开关、全量/灰度发布。 +- `admin-platform`:管理平台,负责审核运营账户、禁用账户、管理子账户权限。 + +## 启动方式 + +先启动后端: + +```bash +cd server +mvn -pl version-management-service spring-boot:run +``` + +再分别启动前端: + +```bash +cd frontend/ops-platform +npm install +npm run dev +``` + +```bash +cd frontend/admin-platform +npm install +npm run dev +``` + +前端默认请求 `http://127.0.0.1:8080`,如需调整可通过 `VITE_API_BASE_URL` 覆盖。 diff --git a/frontend/admin-platform/index.html b/frontend/admin-platform/index.html new file mode 100644 index 0000000..1e7e3b2 --- /dev/null +++ b/frontend/admin-platform/index.html @@ -0,0 +1,12 @@ + + + + + + 管理平台 + + +

+ + + diff --git a/frontend/admin-platform/package.json b/frontend/admin-platform/package.json new file mode 100644 index 0000000..71706d7 --- /dev/null +++ b/frontend/admin-platform/package.json @@ -0,0 +1,22 @@ +{ + "name": "admin-platform", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinia": "^3.0.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.8.2", + "vite": "^6.2.2", + "vue-tsc": "^2.2.8" + } +} diff --git a/frontend/admin-platform/src/App.vue b/frontend/admin-platform/src/App.vue new file mode 100644 index 0000000..635663f --- /dev/null +++ b/frontend/admin-platform/src/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/admin-platform/src/api/client.ts b/frontend/admin-platform/src/api/client.ts new file mode 100644 index 0000000..2aa32bc --- /dev/null +++ b/frontend/admin-platform/src/api/client.ts @@ -0,0 +1,59 @@ +export interface ApiResponse { + code: number + status: string + data: T + message: string +} + +export interface Account { + id: string + accountName: string + contactName: string + email: string + phone: string + type: 'MAIN' | 'SUB' + status: 'PENDING' | 'ACTIVE' | 'DISABLED' + permissions: string[] + parentAccountId?: string | null + createdAt: string +} + +export interface AccountView { + mainAccount: Account + subAccounts: Account[] +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080' + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + const payload = (await response.json()) as ApiResponse + if (!response.ok) { + throw new Error(payload.message) + } + return payload.data +} + +export const api = { + listAccounts() { + return request('/api/v1/admin/accounts') + }, + updateStatus(accountId: string, status: Account['status']) { + return request(`/api/v1/admin/accounts/${accountId}/status`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }) + }, + updateSubPermissions(accountId: string, subAccountId: string, permissions: string[]) { + return request(`/api/v1/admin/accounts/${accountId}/sub-accounts/${subAccountId}/permissions`, { + method: 'PUT', + body: JSON.stringify({ permissions }), + }) + }, +} diff --git a/frontend/admin-platform/src/main.ts b/frontend/admin-platform/src/main.ts new file mode 100644 index 0000000..e18e6cc --- /dev/null +++ b/frontend/admin-platform/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './styles.css' + +createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/frontend/admin-platform/src/router/index.ts b/frontend/admin-platform/src/router/index.ts new file mode 100644 index 0000000..d6a982a --- /dev/null +++ b/frontend/admin-platform/src/router/index.ts @@ -0,0 +1,7 @@ +import { createRouter, createWebHistory } from 'vue-router' +import AccountManagementView from '../views/AccountManagementView.vue' + +export default createRouter({ + history: createWebHistory(), + routes: [{ path: '/', component: AccountManagementView }], +}) diff --git a/frontend/admin-platform/src/styles.css b/frontend/admin-platform/src/styles.css new file mode 100644 index 0000000..02bfae1 --- /dev/null +++ b/frontend/admin-platform/src/styles.css @@ -0,0 +1,145 @@ +:root { + color-scheme: light; + font-family: "PingFang SC", "Noto Sans SC", sans-serif; + color: #1c2131; + background: + linear-gradient(135deg, rgba(255, 196, 92, 0.14), transparent 42%), + linear-gradient(225deg, rgba(33, 120, 255, 0.1), transparent 38%), + #f8f6f1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +.admin-shell { + min-height: 100vh; + padding: 28px; +} + +.hero, +.panel { + max-width: 1200px; + margin: 0 auto; +} + +.hero { + margin-bottom: 20px; +} + +.panel { + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(28, 33, 49, 0.08); + border-radius: 28px; + padding: 24px; + box-shadow: 0 18px 48px rgba(43, 57, 92, 0.08); +} + +.eyebrow, +.section-tag { + margin: 0 0 8px; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #876327; +} + +.muted { + color: #667085; +} + +.section-head, +.account-card__top, +.status-actions { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: center; +} + +.account-card { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid rgba(28, 33, 49, 0.08); +} + +.permission-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 14px 0 16px; +} + +.permission-strip span, +.badge { + padding: 6px 10px; + border-radius: 999px; + background: #f3ead6; + font-size: 12px; +} + +.badge[data-status='ACTIVE'] { + background: #dff7ee; +} + +.badge[data-status='PENDING'] { + background: #fff0c2; +} + +.badge[data-status='DISABLED'] { + background: #fbdede; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 12px 10px; + text-align: left; + border-bottom: 1px solid rgba(28, 33, 49, 0.08); +} + +input { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(28, 33, 49, 0.12); +} + +button { + border: 0; + border-radius: 12px; + padding: 10px 14px; + cursor: pointer; +} + +.primary { + background: linear-gradient(135deg, #132d5a, #b97d21); + color: white; +} + +.secondary, +.ghost { + background: #f4efe4; +} + +@media (max-width: 900px) { + .section-head, + .account-card__top, + .status-actions { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/admin-platform/src/views/AccountManagementView.vue b/frontend/admin-platform/src/views/AccountManagementView.vue new file mode 100644 index 0000000..3050c7b --- /dev/null +++ b/frontend/admin-platform/src/views/AccountManagementView.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/admin-platform/src/vite-env.d.ts b/frontend/admin-platform/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/admin-platform/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/admin-platform/tsconfig.app.json b/frontend/admin-platform/tsconfig.app.json new file mode 100644 index 0000000..c9e234a --- /dev/null +++ b/frontend/admin-platform/tsconfig.app.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/admin-platform/tsconfig.json b/frontend/admin-platform/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/frontend/admin-platform/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/frontend/admin-platform/vite.config.ts b/frontend/admin-platform/vite.config.ts new file mode 100644 index 0000000..22172f4 --- /dev/null +++ b/frontend/admin-platform/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5174, + }, +}) diff --git a/frontend/ops-platform/index.html b/frontend/ops-platform/index.html new file mode 100644 index 0000000..64895ed --- /dev/null +++ b/frontend/ops-platform/index.html @@ -0,0 +1,12 @@ + + + + + + 运营平台 + + +
+ + + diff --git a/frontend/ops-platform/package.json b/frontend/ops-platform/package.json new file mode 100644 index 0000000..1f757e8 --- /dev/null +++ b/frontend/ops-platform/package.json @@ -0,0 +1,22 @@ +{ + "name": "ops-platform", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinia": "^3.0.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.8.2", + "vite": "^6.2.2", + "vue-tsc": "^2.2.8" + } +} diff --git a/frontend/ops-platform/src/App.vue b/frontend/ops-platform/src/App.vue new file mode 100644 index 0000000..eb81c62 --- /dev/null +++ b/frontend/ops-platform/src/App.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/ops-platform/src/api/client.ts b/frontend/ops-platform/src/api/client.ts new file mode 100644 index 0000000..5423f36 --- /dev/null +++ b/frontend/ops-platform/src/api/client.ts @@ -0,0 +1,135 @@ +export interface ApiResponse { + code: number + status: string + data: T + message: string +} + +export interface Account { + id: string + accountName: string + contactName: string + email: string + phone: string + status: 'PENDING' | 'ACTIVE' | 'DISABLED' + type: 'MAIN' | 'SUB' + parentAccountId?: string | null + permissions: string[] + createdAt: string +} + +export interface ReleaseRecord { + id: string + packageType: 'APP' | 'PLUGIN' + versionCode: number + versionName: string + title: string + changelog: string + downloadUrl: string + uploadedFileName: string + status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE' + publishStrategy: string + grayRule?: { + hookName: string + groupCodes: string[] + quickSelectionCodes: string[] + userIds: string[] + } | null +} + +export interface ApplicationDetail { + application: { + id: string + name: string + packageName: string + pluginPackageName: string + pluginManagementEnabled: boolean + businessModules: string[] + } + releases: ReleaseRecord[] +} + +export interface AudienceUser { + id: string + nickname: string + phone: string + email: string + region: string + groupCode: string + groupName: string + quickSelectionCodes: string[] +} + +export interface AudienceGroup { + code: string + name: string + description: string + userCount: number +} + +export interface QuickSelection { + code: string + name: string + description: string + userCount: number +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080' + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + const payload = (await response.json()) as ApiResponse + if (!response.ok) { + throw new Error(payload.message) + } + return payload.data +} + +export const api = { + registerAccount(payload: Pick) { + return request('/api/v1/open/accounts/register', { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + listApplications() { + return request('/api/v1/ops/version/applications') + }, + togglePluginManagement(appId: string, enabled: boolean) { + return request(`/api/v1/ops/version/applications/${appId}/plugin-management`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }) + }, + uploadRelease(appId: string, payload: Record) { + return request(`/api/v1/ops/version/applications/${appId}/releases/upload`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + publishRelease(appId: string, releaseId: string, payload: Record) { + return request(`/api/v1/ops/version/applications/${appId}/releases/${releaseId}/publish`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + listAudienceGroups() { + return request('/api/v1/ops/version/audiences/groups') + }, + listQuickSelections() { + return request('/api/v1/ops/version/audiences/quick-selections') + }, + listAudienceUsers(params: { keyword?: string; groupCode?: string; quickSelectionCode?: string }) { + const search = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (value) search.set(key, value) + }) + return request(`/api/v1/ops/version/audiences/users?${search.toString()}`) + }, +} diff --git a/frontend/ops-platform/src/main.ts b/frontend/ops-platform/src/main.ts new file mode 100644 index 0000000..e18e6cc --- /dev/null +++ b/frontend/ops-platform/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './styles.css' + +createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/frontend/ops-platform/src/router/index.ts b/frontend/ops-platform/src/router/index.ts new file mode 100644 index 0000000..a1fd13b --- /dev/null +++ b/frontend/ops-platform/src/router/index.ts @@ -0,0 +1,12 @@ +import { createRouter, createWebHistory } from 'vue-router' +import RegisterView from '../views/RegisterView.vue' +import VersionManagementView from '../views/VersionManagementView.vue' + +export default createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/register' }, + { path: '/register', component: RegisterView }, + { path: '/versions', component: VersionManagementView }, + ], +}) diff --git a/frontend/ops-platform/src/styles.css b/frontend/ops-platform/src/styles.css new file mode 100644 index 0000000..4e0d01a --- /dev/null +++ b/frontend/ops-platform/src/styles.css @@ -0,0 +1,221 @@ +:root { + color-scheme: light; + font-family: "PingFang SC", "Noto Sans SC", sans-serif; + line-height: 1.5; + color: #10233d; + background: + radial-gradient(circle at top left, rgba(31, 169, 255, 0.18), transparent 36%), + radial-gradient(circle at bottom right, rgba(12, 205, 180, 0.16), transparent 26%), + #f4f8fc; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.shell { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; +} + +.sidebar { + padding: 32px 24px; + background: linear-gradient(180deg, #0f2747 0%, #123863 100%); + color: white; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.content { + padding: 28px; +} + +.nav { + display: grid; + gap: 12px; +} + +.nav a { + color: rgba(255, 255, 255, 0.84); + text-decoration: none; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.08); +} + +.nav a.router-link-active { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.page { + display: grid; + gap: 20px; +} + +.stack { + grid-template-columns: 1.25fr 1fr; + align-items: start; +} + +.panel { + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(16, 35, 61, 0.08); + border-radius: 24px; + padding: 24px; + box-shadow: 0 18px 45px rgba(22, 57, 97, 0.08); + backdrop-filter: blur(16px); +} + +.panel.soft { + background: linear-gradient(160deg, rgba(217, 243, 255, 0.92), rgba(255, 255, 255, 0.9)); +} + +.eyebrow, +.section-tag { + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 12px; + color: #2d78a2; + margin: 0 0 8px; +} + +.muted { + color: #607188; +} + +.section-head, +.app-card__top, +.selected-bar, +.filters { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: center; +} + +.form-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 20px; +} + +.form-grid label, +.filters label { + display: grid; + gap: 8px; + font-size: 14px; +} + +.form-grid .full, +.filters .grow { + grid-column: 1 / -1; +} + +input, +textarea, +select { + width: 100%; + border-radius: 14px; + border: 1px solid rgba(16, 35, 61, 0.14); + background: white; + padding: 12px 14px; +} + +button { + border: 0; + border-radius: 14px; + padding: 12px 18px; + cursor: pointer; +} + +.primary { + background: linear-gradient(135deg, #0d72ff, #11b8a5); + color: white; +} + +.secondary, +.ghost, +.chip-button { + background: #eef5fb; + color: #163454; +} + +.app-card { + border-top: 1px solid rgba(16, 35, 61, 0.08); + margin-top: 20px; + padding-top: 20px; +} + +.chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chips span, +.chip-button { + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; +} + +.table th, +.table td { + text-align: left; + padding: 12px 10px; + border-bottom: 1px solid rgba(16, 35, 61, 0.08); + font-size: 14px; +} + +.actions { + display: flex; + gap: 8px; +} + +.toggle { + display: flex; + align-items: center; + gap: 8px; +} + +.plain-list { + margin: 0; + padding-left: 18px; +} + +.success-text { + color: #0f7f65; + margin-top: 12px; +} + +@media (max-width: 1100px) { + .shell, + .stack { + grid-template-columns: 1fr; + } + + .sidebar { + gap: 24px; + } +} diff --git a/frontend/ops-platform/src/views/RegisterView.vue b/frontend/ops-platform/src/views/RegisterView.vue new file mode 100644 index 0000000..ccdbc40 --- /dev/null +++ b/frontend/ops-platform/src/views/RegisterView.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/ops-platform/src/views/VersionManagementView.vue b/frontend/ops-platform/src/views/VersionManagementView.vue new file mode 100644 index 0000000..649894f --- /dev/null +++ b/frontend/ops-platform/src/views/VersionManagementView.vue @@ -0,0 +1,293 @@ + + + diff --git a/frontend/ops-platform/src/vite-env.d.ts b/frontend/ops-platform/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/ops-platform/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/ops-platform/tsconfig.app.json b/frontend/ops-platform/tsconfig.app.json new file mode 100644 index 0000000..c9e234a --- /dev/null +++ b/frontend/ops-platform/tsconfig.app.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/ops-platform/tsconfig.json b/frontend/ops-platform/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/frontend/ops-platform/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/frontend/ops-platform/vite.config.ts b/frontend/ops-platform/vite.config.ts new file mode 100644 index 0000000..7a8771e --- /dev/null +++ b/frontend/ops-platform/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + }, +}) diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d534507 --- /dev/null +++ b/server/README.md @@ -0,0 +1,36 @@ +# server + +当前目录用于承载和 Android 客户端配套的服务项目。 + +现有项目: + +- `version-service`:旧版 Node.js 示例服务,保留用于参考。 +- `version-management-service`:新版 Spring Boot 微服务,承载运营平台注册、管理平台账号管理、版本上传、全量/灰度发布,以及 Android 客户端兼容更新接口。 + +## 当前推荐服务 + +```bash +cd server +mvn -pl version-management-service spring-boot:run +``` + +默认监听 `http://127.0.0.1:8080`。 + +## 基础设施 + +- JDK:21 +- Spring Boot:3.4.4 +- 数据库:MySQL `xuqinmin.com:3306/androidLibsServer` +- 缓存:Redis `redisdev.xuqinmin.com:6379/0` + +当前版本管理、账户、灰度用户等数据使用 MySQL 持久化,灰度选人列表使用 Redis 缓存。 + +## 已实现能力 + +- 运营平台开放主账户注册,并支持主账户创建子账户 +- 管理平台查看运营平台账户、审核/禁用账户、调整子账户权限 +- 版本管理支持 App / 插件包上传、插件化开关、全量发布、灰度发布 +- 灰度发布通过用户平台钩子数据源获取脱敏用户列表,支持分组、快速选择、单选用户 +- 保留 Android 现有兼容接口: + - `GET /api/v1/updates/app/latest` + - `GET /api/v1/updates/plugin/latest` diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..bc12baa --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,33 @@ + + 4.0.0 + + com.xuqm + server-parent + 0.1.0 + pom + server-parent + Spring Boot microservices workspace for AndroidLibsGroup + + + version-management-service + + + + 21 + 3.4.4 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + diff --git a/server/settings.xml b/server/settings.xml new file mode 100644 index 0000000..e05eb69 --- /dev/null +++ b/server/settings.xml @@ -0,0 +1,12 @@ + + + + central-direct + Maven Central + https://repo1.maven.org/maven2 + central + + + diff --git a/server/version-management-service/pom.xml b/server/version-management-service/pom.xml new file mode 100644 index 0000000..ddba477 --- /dev/null +++ b/server/version-management-service/pom.xml @@ -0,0 +1,72 @@ + + 4.0.0 + + + com.xuqm + server-parent + 0.1.0 + ../pom.xml + + + version-management-service + version-management-service + Version management microservice for operator/admin platforms + + + ${java.version} + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-validation + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + maven-compiler-plugin + + ${java.version} + + + + + diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java new file mode 100644 index 0000000..4c25104 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/VersionManagementApplication.java @@ -0,0 +1,14 @@ +package com.xuqm.versionmanagement; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +@EnableCaching +@SpringBootApplication +public class VersionManagementApplication { + + public static void main(String[] args) { + SpringApplication.run(VersionManagementApplication.class, args); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java new file mode 100644 index 0000000..7e199ac --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/DataInitializer.java @@ -0,0 +1,195 @@ +package com.xuqm.versionmanagement.config; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import com.xuqm.versionmanagement.persistence.entity.HookGroupEntity; +import com.xuqm.versionmanagement.persistence.entity.HookUserEntity; +import com.xuqm.versionmanagement.persistence.entity.QuickSelectionEntity; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import com.xuqm.versionmanagement.persistence.repository.AccountRepository; +import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository; +import com.xuqm.versionmanagement.persistence.repository.HookGroupRepository; +import com.xuqm.versionmanagement.persistence.repository.HookUserRepository; +import com.xuqm.versionmanagement.persistence.repository.QuickSelectionRepository; +import com.xuqm.versionmanagement.persistence.repository.ReleaseRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DataInitializer { + + @Bean + CommandLineRunner seedVersionManagementData( + AccountRepository accountRepository, + ApplicationRepository applicationRepository, + ReleaseRepository releaseRepository, + HookUserRepository hookUserRepository, + HookGroupRepository hookGroupRepository, + QuickSelectionRepository quickSelectionRepository + ) { + return args -> { + if (applicationRepository.count() > 0) { + return; + } + + AccountEntity main = new AccountEntity(); + main.setId("ACC-1001"); + main.setAccountName("星云运营中心"); + main.setContactName("林青"); + main.setEmail("ops@nebula.example"); + main.setPhone("13800138000"); + main.setType(PlatformData.AccountType.MAIN); + main.setStatus(PlatformData.AccountStatus.ACTIVE); + main.setPermissions(List.of("version:read", "version:write", "release:publish", "subaccount:grant")); + main.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 0)); + + AccountEntity sub = new AccountEntity(); + sub.setId("SUB-2001"); + sub.setAccountName("星云发布子账号"); + sub.setContactName("苏宁"); + sub.setEmail("release@nebula.example"); + sub.setPhone("13900139000"); + sub.setType(PlatformData.AccountType.SUB); + sub.setStatus(PlatformData.AccountStatus.ACTIVE); + sub.setParentAccountId(main.getId()); + sub.setPermissions(List.of("version:read", "version:write", "release:publish")); + sub.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 10)); + accountRepository.saveAll(List.of(main, sub)); + + ApplicationEntity app = new ApplicationEntity(); + app.setId("APP-001"); + app.setName("宿主 App"); + app.setPackageName("com.xuqm.sample"); + app.setPluginPackageName("com.xuqm.plugin.ui"); + app.setPluginManagementEnabled(true); + app.setBusinessModules(List.of("IM", "PUSH", "VERSION")); + app.setCreatedAt(LocalDateTime.of(2026, 3, 27, 9, 0)); + applicationRepository.save(app); + + releaseRepository.saveAll(List.of( + release("REL-APP-001", app.getId(), PlatformData.PackageType.APP, "com.xuqm.sample", 2, "0.2.0", + "发现新版本", "1. 新增统一下载管理\n2. 新增插件版本管理\n3. 优化宿主更新流程", + "http://192.168.116.9:10223/app.apk", null, false, "sample-app-release-v0.2.0.apk", + PlatformData.ReleaseStatus.PUBLISHED, "FULL", null, List.of(), List.of(), List.of(), + LocalDateTime.of(2026, 3, 27, 9, 30), LocalDateTime.of(2026, 3, 27, 9, 45)), + release("REL-PLUGIN-001", app.getId(), PlatformData.PackageType.PLUGIN, "com.xuqm.plugin.ui", 2, "0.2.0", + "插件 UI 更新", "1. 修复插件页面展示问题\n2. 优化宿主拉起体验", + "http://192.168.116.9:10223/plugin-ui-release.apk", "com.xuqm.plugin.ui.PluginUiActivity", false, "plugin-ui-release-v0.2.0.apk", + PlatformData.ReleaseStatus.PUBLISHED, "FULL", null, List.of(), List.of(), List.of(), + LocalDateTime.of(2026, 3, 27, 9, 35), LocalDateTime.of(2026, 3, 27, 9, 50)), + release("REL-APP-002", app.getId(), PlatformData.PackageType.APP, "com.xuqm.sample", 3, "0.3.0-beta", + "0.3.0 灰度测试", "1. 新增版本平台灰度能力\n2. 支持用户分组圈选", + "http://192.168.116.9:10223/app-beta.apk", null, false, "sample-app-release-v0.3.0-beta.apk", + PlatformData.ReleaseStatus.GRAYSCALE, "GRAY", "user-platform-gray-hook", List.of("beta-core"), List.of("vip-seed"), List.of("USER-1001"), + LocalDateTime.of(2026, 3, 27, 10, 0), LocalDateTime.of(2026, 3, 27, 10, 15)) + )); + + hookGroupRepository.saveAll(List.of( + group("beta-core", "核心灰测组", "用于首批核心功能灰度发布"), + group("city-service", "城市服务组", "按业务城市运营维度组织"), + group("north-region", "北区用户组", "面向北方区域试点用户") + )); + + quickSelectionRepository.saveAll(List.of( + quick("vip-seed", "VIP 种子用户", "高活跃、高容忍度用户集合"), + quick("east-region", "东区优先", "优先面向华东区域发布"), + quick("north-pilot", "北区试点", "北区单点试运营人群") + )); + + hookUserRepository.saveAll(List.of( + hookUser("USER-1001", "星野小满", "13700001111", "xiaoman@example.com", "上海", "beta-core", "核心灰测组", List.of("vip-seed", "east-region")), + hookUser("USER-1002", "陈知远", "13600002222", "zhiyuan@example.com", "杭州", "beta-core", "核心灰测组", List.of("east-region")), + hookUser("USER-1003", "苏静", "13500003333", "sujing@example.com", "成都", "city-service", "城市服务组", List.of("vip-seed")), + hookUser("USER-1004", "王亦舟", "13400004444", "zhou@example.com", "北京", "north-region", "北区用户组", List.of("north-pilot")) + )); + }; + } + + private ReleaseEntity release( + String id, + String appId, + PlatformData.PackageType packageType, + String packageName, + int versionCode, + String versionName, + String title, + String changelog, + String downloadUrl, + String entryActivity, + boolean forceUpdate, + String uploadedFileName, + PlatformData.ReleaseStatus status, + String publishStrategy, + String hookName, + List groupCodes, + List quickSelectionCodes, + List userIds, + LocalDateTime uploadedAt, + LocalDateTime publishedAt + ) { + ReleaseEntity entity = new ReleaseEntity(); + entity.setId(id); + entity.setAppId(appId); + entity.setPackageType(packageType); + entity.setPackageName(packageName); + entity.setVersionCode(versionCode); + entity.setVersionName(versionName); + entity.setTitle(title); + entity.setChangelog(changelog); + entity.setDownloadUrl(downloadUrl); + entity.setEntryActivity(entryActivity); + entity.setForceUpdate(forceUpdate); + entity.setUploadedFileName(uploadedFileName); + entity.setStatus(status); + entity.setPublishStrategy(publishStrategy); + entity.setHookName(hookName); + entity.setGroupCodes(groupCodes); + entity.setQuickSelectionCodes(quickSelectionCodes); + entity.setUserIds(userIds); + entity.setUploadedAt(uploadedAt); + entity.setPublishedAt(publishedAt); + return entity; + } + + private HookGroupEntity group(String code, String name, String description) { + HookGroupEntity entity = new HookGroupEntity(); + entity.setCode(code); + entity.setName(name); + entity.setDescription(description); + return entity; + } + + private QuickSelectionEntity quick(String code, String name, String description) { + QuickSelectionEntity entity = new QuickSelectionEntity(); + entity.setCode(code); + entity.setName(name); + entity.setDescription(description); + return entity; + } + + private HookUserEntity hookUser( + String id, + String nickname, + String phone, + String email, + String region, + String groupCode, + String groupName, + List quickSelectionCodes + ) { + HookUserEntity entity = new HookUserEntity(); + entity.setId(id); + entity.setNickname(nickname); + entity.setPhone(phone); + entity.setEmail(email); + entity.setRegion(region); + entity.setGroupCode(groupCode); + entity.setGroupName(groupName); + entity.setQuickSelectionCodes(quickSelectionCodes); + return entity; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java new file mode 100644 index 0000000..ac6a9a5 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/RedisConfig.java @@ -0,0 +1,32 @@ +package com.xuqm.versionmanagement.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.interceptor.SimpleKeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +@Configuration +public class RedisConfig implements CachingConfigurer { + + @Bean + public RedisCacheConfiguration redisCacheConfiguration() { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + return RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)); + } + + @Bean + @Override + public SimpleKeyGenerator keyGenerator() { + return new SimpleKeyGenerator(); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java new file mode 100644 index 0000000..1b652f7 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/config/WebConfig.java @@ -0,0 +1,16 @@ +package com.xuqm.versionmanagement.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "OPTIONS"); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java new file mode 100644 index 0000000..e5fa53e --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/AdminAccountController.java @@ -0,0 +1,56 @@ +package com.xuqm.versionmanagement.controller; + +import com.xuqm.versionmanagement.model.ApiResponse; +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.service.AccountService; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/admin/accounts") +public class AdminAccountController { + + private final AccountService accountService; + + public AdminAccountController(AccountService accountService) { + this.accountService = accountService; + } + + @GetMapping + public ApiResponse> listAccounts() { + return ApiResponse.success(accountService.listAccounts()); + } + + @PatchMapping("/{accountId}/status") + public ApiResponse updateStatus( + @PathVariable String accountId, + @RequestBody @Validated UpdateStatusRequest request + ) { + return ApiResponse.success(accountService.updateStatus(accountId, request.status()), "账户状态已更新"); + } + + @PutMapping("/{accountId}/sub-accounts/{subAccountId}/permissions") + public ApiResponse updateSubAccountPermissions( + @PathVariable String accountId, + @PathVariable String subAccountId, + @RequestBody @Validated UpdatePermissionsRequest request + ) { + PlatformData.Account account = accountService.updateSubPermissions(accountId, subAccountId, request.permissions()); + return ApiResponse.success(account, "子账户权限已更新"); + } + + public record UpdateStatusRequest(PlatformData.AccountStatus status) { + } + + public record UpdatePermissionsRequest(@NotEmpty(message = "不能为空") List permissions) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java new file mode 100644 index 0000000..7b9ee65 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/ApiExceptionHandler.java @@ -0,0 +1,32 @@ +package com.xuqm.versionmanagement.controller; + +import com.xuqm.versionmanagement.model.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(new ApiResponse<>(400, "400", null, exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException exception) { + String message = exception.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .orElse("请求参数校验失败"); + return ResponseEntity.badRequest().body(new ApiResponse<>(400, "400", null, message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleOther(Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ApiResponse<>(500, "500", null, exception.getMessage())); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java new file mode 100644 index 0000000..69bfcc3 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java @@ -0,0 +1,57 @@ +package com.xuqm.versionmanagement.controller; + +import com.xuqm.versionmanagement.model.ApiResponse; +import com.xuqm.versionmanagement.model.PlatformData; +import java.util.LinkedHashMap; +import com.xuqm.versionmanagement.service.VersionManagementService; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CompatibilityUpdateController { + + private final VersionManagementService versionManagementService; + + public CompatibilityUpdateController(VersionManagementService versionManagementService) { + this.versionManagementService = versionManagementService; + } + + @GetMapping("/health") + public ApiResponse> health() { + return ApiResponse.success(Map.of("status", "UP")); + } + + @GetMapping("/api/v1/updates/app/latest") + public ApiResponse> latestApp( + @RequestParam String packageName, + @RequestParam(required = false) String userId + ) { + PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId); + Map payload = new LinkedHashMap<>(); + payload.put("packageName", release.getPackageName()); + payload.put("versionCode", release.getVersionCode()); + payload.put("versionName", release.getVersionName()); + payload.put("title", release.getTitle()); + payload.put("changelog", release.getChangelog()); + payload.put("downloadUrl", release.getDownloadUrl()); + payload.put("forceUpdate", release.isForceUpdate()); + return ApiResponse.success(payload); + } + + @GetMapping("/api/v1/updates/plugin/latest") + public ApiResponse> latestPlugin( + @RequestParam String packageName, + @RequestParam(required = false) String userId + ) { + PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId); + Map payload = new LinkedHashMap<>(); + payload.put("packageName", release.getPackageName()); + payload.put("versionCode", release.getVersionCode()); + payload.put("versionName", release.getVersionName()); + payload.put("downloadUrl", release.getDownloadUrl()); + payload.put("entryActivity", release.getEntryActivity()); + return ApiResponse.success(payload); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java new file mode 100644 index 0000000..0c9b63a --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java @@ -0,0 +1,143 @@ +package com.xuqm.versionmanagement.controller; + +import com.xuqm.versionmanagement.model.ApiResponse; +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.service.UserHookService; +import com.xuqm.versionmanagement.service.VersionManagementService; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/ops/version") +public class OpsVersionController { + + private final VersionManagementService versionManagementService; + private final UserHookService userHookService; + + public OpsVersionController(VersionManagementService versionManagementService, UserHookService userHookService) { + this.versionManagementService = versionManagementService; + this.userHookService = userHookService; + } + + @GetMapping("/applications") + public ApiResponse> listApplications() { + return ApiResponse.success(versionManagementService.listApplications()); + } + + @PutMapping("/applications/{appId}/plugin-management") + public ApiResponse togglePluginManagement( + @PathVariable String appId, + @RequestBody TogglePluginManagementRequest request + ) { + PlatformData.ApplicationConfig config = versionManagementService.togglePluginManagement(appId, request.enabled()); + return ApiResponse.success(config, "插件化能力已更新"); + } + + @PostMapping("/applications/{appId}/releases/upload") + public ApiResponse uploadRelease( + @PathVariable String appId, + @RequestBody @Validated UploadReleaseRequest request + ) { + PlatformData.ReleaseRecord release = versionManagementService.uploadRelease( + appId, + new VersionManagementService.UploadReleaseCommand( + request.packageType(), + request.versionCode(), + request.versionName(), + request.title(), + request.changelog(), + request.downloadUrl(), + request.entryActivity(), + request.forceUpdate(), + request.uploadedFileName() + ) + ); + return ApiResponse.success(release, "版本包已上传"); + } + + @PostMapping("/applications/{appId}/releases/{releaseId}/publish") + public ApiResponse publishRelease( + @PathVariable String appId, + @PathVariable String releaseId, + @RequestBody PublishReleaseRequest request + ) { + PlatformData.ReleaseRecord release = versionManagementService.publishRelease( + appId, + releaseId, + new VersionManagementService.PublishReleaseCommand( + request.grayPublish(), + request.hookName(), + request.groupCodes(), + request.quickSelectionCodes(), + request.userIds() + ) + ); + return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功"); + } + + @GetMapping("/audiences/users") + public ApiResponse> listAudienceUsers( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String groupCode, + @RequestParam(required = false) String quickSelectionCode + ) { + return ApiResponse.success(userHookService.getAudienceBundle(keyword, groupCode, quickSelectionCode).users()); + } + + @GetMapping("/audiences/groups") + public ApiResponse> listAudienceGroups() { + return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).groups()); + } + + @GetMapping("/audiences/quick-selections") + public ApiResponse> listQuickSelections() { + return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections()); + } + + public record TogglePluginManagementRequest(boolean enabled) { + } + + public record UploadReleaseRequest( + @NotNull(message = "不能为空") PlatformData.PackageType packageType, + int versionCode, + @NotBlank(message = "不能为空") String versionName, + @NotBlank(message = "不能为空") String title, + String changelog, + @NotBlank(message = "不能为空") String downloadUrl, + String entryActivity, + boolean forceUpdate, + @NotBlank(message = "不能为空") String uploadedFileName + ) { + } + + public record PublishReleaseRequest( + boolean grayPublish, + String hookName, + List groupCodes, + List quickSelectionCodes, + List userIds + ) { + public List groupCodes() { + return groupCodes == null ? List.of() : groupCodes; + } + + public List quickSelectionCodes() { + return quickSelectionCodes == null ? List.of() : quickSelectionCodes; + } + + public List userIds() { + return userIds == null ? List.of() : userIds; + } + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java new file mode 100644 index 0000000..8aa9c1d --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/PublicAccountController.java @@ -0,0 +1,74 @@ +package com.xuqm.versionmanagement.controller; + +import com.xuqm.versionmanagement.model.ApiResponse; +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.service.AccountService; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping +public class PublicAccountController { + + private final AccountService accountService; + + public PublicAccountController(AccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("/api/v1/open/accounts/register") + public ApiResponse register(@RequestBody @Validated RegisterRequest request) { + PlatformData.Account account = accountService.registerMainAccount( + new AccountService.RegisterAccountCommand( + request.accountName(), + request.contactName(), + request.email(), + request.phone() + ) + ); + return ApiResponse.success(account, "运营平台注册成功,等待管理平台审核"); + } + + @PostMapping("/api/v1/ops/accounts/{accountId}/sub-accounts") + public ApiResponse createSubAccount( + @PathVariable String accountId, + @RequestBody @Validated CreateSubAccountRequest request + ) { + PlatformData.Account account = accountService.createSubAccount( + accountId, + new AccountService.CreateSubAccountCommand( + request.accountName(), + request.contactName(), + request.email(), + request.phone(), + request.permissions() + ) + ); + return ApiResponse.success(account, "子账户创建成功"); + } + + public record RegisterRequest( + @NotBlank(message = "不能为空") String accountName, + @NotBlank(message = "不能为空") String contactName, + @Email(message = "格式不正确") String email, + @NotBlank(message = "不能为空") String phone + ) { + } + + public record CreateSubAccountRequest( + @NotBlank(message = "不能为空") String accountName, + @NotBlank(message = "不能为空") String contactName, + @Email(message = "格式不正确") String email, + @NotBlank(message = "不能为空") String phone, + List permissions + ) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java new file mode 100644 index 0000000..3582856 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/ApiResponse.java @@ -0,0 +1,12 @@ +package com.xuqm.versionmanagement.model; + +public record ApiResponse(int code, String status, T data, String message) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "0", data, "success"); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(200, "0", data, message); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java new file mode 100644 index 0000000..58a6060 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/model/PlatformData.java @@ -0,0 +1,574 @@ +package com.xuqm.versionmanagement.model; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class PlatformData { + + private List accounts = new ArrayList<>(); + private List applications = new ArrayList<>(); + private List releases = new ArrayList<>(); + private List hookUsers = new ArrayList<>(); + private List hookGroups = new ArrayList<>(); + private List hookQuickSelections = new ArrayList<>(); + + public List getAccounts() { + return accounts; + } + + public void setAccounts(List accounts) { + this.accounts = accounts; + } + + public List getApplications() { + return applications; + } + + public void setApplications(List applications) { + this.applications = applications; + } + + public List getReleases() { + return releases; + } + + public void setReleases(List releases) { + this.releases = releases; + } + + public List getHookUsers() { + return hookUsers; + } + + public void setHookUsers(List hookUsers) { + this.hookUsers = hookUsers; + } + + public List getHookGroups() { + return hookGroups; + } + + public void setHookGroups(List hookGroups) { + this.hookGroups = hookGroups; + } + + public List getHookQuickSelections() { + return hookQuickSelections; + } + + public void setHookQuickSelections(List hookQuickSelections) { + this.hookQuickSelections = hookQuickSelections; + } + + public enum AccountType { + MAIN, + SUB + } + + public enum AccountStatus { + PENDING, + ACTIVE, + DISABLED + } + + public enum PackageType { + APP, + PLUGIN + } + + public enum ReleaseStatus { + DRAFT, + PUBLISHED, + GRAYSCALE + } + + public static class Account { + private String id; + private String accountName; + private String contactName; + private String email; + private String phone; + private AccountType type; + private AccountStatus status; + private String parentAccountId; + private List permissions = new ArrayList<>(); + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public AccountType getType() { + return type; + } + + public void setType(AccountType type) { + this.type = type; + } + + public AccountStatus getStatus() { + return status; + } + + public void setStatus(AccountStatus status) { + this.status = status; + } + + public String getParentAccountId() { + return parentAccountId; + } + + public void setParentAccountId(String parentAccountId) { + this.parentAccountId = parentAccountId; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + } + + public static class ApplicationConfig { + private String id; + private String name; + private String packageName; + private String pluginPackageName; + private boolean pluginManagementEnabled; + private List businessModules = new ArrayList<>(); + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPluginPackageName() { + return pluginPackageName; + } + + public void setPluginPackageName(String pluginPackageName) { + this.pluginPackageName = pluginPackageName; + } + + public boolean isPluginManagementEnabled() { + return pluginManagementEnabled; + } + + public void setPluginManagementEnabled(boolean pluginManagementEnabled) { + this.pluginManagementEnabled = pluginManagementEnabled; + } + + public List getBusinessModules() { + return businessModules; + } + + public void setBusinessModules(List businessModules) { + this.businessModules = businessModules; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + } + + public static class ReleaseRecord { + private String id; + private String appId; + private PackageType packageType; + private String packageName; + private int versionCode; + private String versionName; + private String title; + private String changelog; + private String downloadUrl; + private String entryActivity; + private boolean forceUpdate; + private String uploadedFileName; + private ReleaseStatus status; + private String publishStrategy; + private GrayRule grayRule; + private LocalDateTime uploadedAt; + private LocalDateTime publishedAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public PackageType getPackageType() { + return packageType; + } + + public void setPackageType(PackageType packageType) { + this.packageType = packageType; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getChangelog() { + return changelog; + } + + public void setChangelog(String changelog) { + this.changelog = changelog; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public String getEntryActivity() { + return entryActivity; + } + + public void setEntryActivity(String entryActivity) { + this.entryActivity = entryActivity; + } + + public boolean isForceUpdate() { + return forceUpdate; + } + + public void setForceUpdate(boolean forceUpdate) { + this.forceUpdate = forceUpdate; + } + + public String getUploadedFileName() { + return uploadedFileName; + } + + public void setUploadedFileName(String uploadedFileName) { + this.uploadedFileName = uploadedFileName; + } + + public ReleaseStatus getStatus() { + return status; + } + + public void setStatus(ReleaseStatus status) { + this.status = status; + } + + public String getPublishStrategy() { + return publishStrategy; + } + + public void setPublishStrategy(String publishStrategy) { + this.publishStrategy = publishStrategy; + } + + public GrayRule getGrayRule() { + return grayRule; + } + + public void setGrayRule(GrayRule grayRule) { + this.grayRule = grayRule; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(LocalDateTime uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public LocalDateTime getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(LocalDateTime publishedAt) { + this.publishedAt = publishedAt; + } + } + + public static class GrayRule { + private String hookName; + private List groupCodes = new ArrayList<>(); + private List quickSelectionCodes = new ArrayList<>(); + private List userIds = new ArrayList<>(); + + public String getHookName() { + return hookName; + } + + public void setHookName(String hookName) { + this.hookName = hookName; + } + + public List getGroupCodes() { + return groupCodes; + } + + public void setGroupCodes(List groupCodes) { + this.groupCodes = groupCodes; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + + public List getUserIds() { + return userIds; + } + + public void setUserIds(List userIds) { + this.userIds = userIds; + } + } + + public static class HookUser { + private String id; + private String nickname; + private String phone; + private String email; + private String region; + private String groupCode; + private String groupName; + private List quickSelectionCodes = new ArrayList<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getGroupCode() { + return groupCode; + } + + public void setGroupCode(String groupCode) { + this.groupCode = groupCode; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + } + + public static class HookGroup { + private String code; + private String name; + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } + + public static class QuickSelection { + private String code; + private String name; + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java new file mode 100644 index 0000000..70b684e --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/AccountEntity.java @@ -0,0 +1,130 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import com.xuqm.versionmanagement.model.PlatformData; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_account") +public class AccountEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String accountName; + + @Column(nullable = false, length = 64) + private String contactName; + + @Column(nullable = false, length = 128) + private String email; + + @Column(nullable = false, length = 32) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.AccountType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.AccountStatus status; + + @Column(length = 64) + private String parentAccountId; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List permissions; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getContactName() { + return contactName; + } + + public void setContactName(String contactName) { + this.contactName = contactName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public PlatformData.AccountType getType() { + return type; + } + + public void setType(PlatformData.AccountType type) { + this.type = type; + } + + public PlatformData.AccountStatus getStatus() { + return status; + } + + public void setStatus(PlatformData.AccountStatus status) { + this.status = status; + } + + public String getParentAccountId() { + return parentAccountId; + } + + public void setParentAccountId(String parentAccountId) { + this.parentAccountId = parentAccountId; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java new file mode 100644 index 0000000..dc39ec8 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ApplicationEntity.java @@ -0,0 +1,92 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_application") +public class ApplicationEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String name; + + @Column(nullable = false, length = 128, unique = true) + private String packageName; + + @Column(length = 128) + private String pluginPackageName; + + @Column(nullable = false) + private boolean pluginManagementEnabled; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 512) + private List businessModules; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getPluginPackageName() { + return pluginPackageName; + } + + public void setPluginPackageName(String pluginPackageName) { + this.pluginPackageName = pluginPackageName; + } + + public boolean isPluginManagementEnabled() { + return pluginManagementEnabled; + } + + public void setPluginManagementEnabled(boolean pluginManagementEnabled) { + this.pluginManagementEnabled = pluginManagementEnabled; + } + + public List getBusinessModules() { + return businessModules; + } + + public void setBusinessModules(List businessModules) { + this.businessModules = businessModules; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java new file mode 100644 index 0000000..1d0012c --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookGroupEntity.java @@ -0,0 +1,44 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "vm_hook_group") +public class HookGroupEntity { + + @Id + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java new file mode 100644 index 0000000..bfa7871 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/HookUserEntity.java @@ -0,0 +1,102 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.List; + +@Entity +@Table(name = "vm_hook_user") +public class HookUserEntity { + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String nickname; + + @Column(nullable = false, length = 32) + private String phone; + + @Column(nullable = false, length = 128) + private String email; + + @Column(length = 64) + private String region; + + @Column(length = 64) + private String groupCode; + + @Column(length = 128) + private String groupName; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List quickSelectionCodes; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getGroupCode() { + return groupCode; + } + + public void setGroupCode(String groupCode) { + this.groupCode = groupCode; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java new file mode 100644 index 0000000..518f57c --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/QuickSelectionEntity.java @@ -0,0 +1,44 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "vm_quick_selection") +public class QuickSelectionEntity { + + @Id + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java new file mode 100644 index 0000000..9f56266 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/ReleaseEntity.java @@ -0,0 +1,241 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import com.xuqm.versionmanagement.model.PlatformData; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "vm_release") +public class ReleaseEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.PackageType packageType; + + @Column(nullable = false, length = 128) + private String packageName; + + @Column(nullable = false) + private int versionCode; + + @Column(nullable = false, length = 64) + private String versionName; + + @Column(nullable = false, length = 128) + private String title; + + @Column(length = 4000) + private String changelog; + + @Column(nullable = false, length = 512) + private String downloadUrl; + + @Column(length = 256) + private String entryActivity; + + @Column(nullable = false) + private boolean forceUpdate; + + @Column(nullable = false, length = 256) + private String uploadedFileName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PlatformData.ReleaseStatus status; + + @Column(nullable = false, length = 32) + private String publishStrategy; + + @Column(length = 128) + private String hookName; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List groupCodes; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 1000) + private List quickSelectionCodes; + + @Convert(converter = StringListConverter.class) + @Column(nullable = false, length = 2000) + private List userIds; + + @Column(nullable = false) + private LocalDateTime uploadedAt; + + private LocalDateTime publishedAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public PlatformData.PackageType getPackageType() { + return packageType; + } + + public void setPackageType(PlatformData.PackageType packageType) { + this.packageType = packageType; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getChangelog() { + return changelog; + } + + public void setChangelog(String changelog) { + this.changelog = changelog; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public String getEntryActivity() { + return entryActivity; + } + + public void setEntryActivity(String entryActivity) { + this.entryActivity = entryActivity; + } + + public boolean isForceUpdate() { + return forceUpdate; + } + + public void setForceUpdate(boolean forceUpdate) { + this.forceUpdate = forceUpdate; + } + + public String getUploadedFileName() { + return uploadedFileName; + } + + public void setUploadedFileName(String uploadedFileName) { + this.uploadedFileName = uploadedFileName; + } + + public PlatformData.ReleaseStatus getStatus() { + return status; + } + + public void setStatus(PlatformData.ReleaseStatus status) { + this.status = status; + } + + public String getPublishStrategy() { + return publishStrategy; + } + + public void setPublishStrategy(String publishStrategy) { + this.publishStrategy = publishStrategy; + } + + public String getHookName() { + return hookName; + } + + public void setHookName(String hookName) { + this.hookName = hookName; + } + + public List getGroupCodes() { + return groupCodes; + } + + public void setGroupCodes(List groupCodes) { + this.groupCodes = groupCodes; + } + + public List getQuickSelectionCodes() { + return quickSelectionCodes; + } + + public void setQuickSelectionCodes(List quickSelectionCodes) { + this.quickSelectionCodes = quickSelectionCodes; + } + + public List getUserIds() { + return userIds; + } + + public void setUserIds(List userIds) { + this.userIds = userIds; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(LocalDateTime uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public LocalDateTime getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(LocalDateTime publishedAt) { + this.publishedAt = publishedAt; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java new file mode 100644 index 0000000..9268694 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/StringListConverter.java @@ -0,0 +1,29 @@ +package com.xuqm.versionmanagement.persistence.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.Arrays; +import java.util.List; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return ""; + } + return String.join(",", attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return List.of(); + } + return Arrays.stream(dbData.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .toList(); + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java new file mode 100644 index 0000000..26518d3 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/AccountRepository.java @@ -0,0 +1,13 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AccountRepository extends JpaRepository { + + List findByTypeOrderByCreatedAtAsc(PlatformData.AccountType type); + + List findByParentAccountIdOrderByCreatedAtAsc(String parentAccountId); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java new file mode 100644 index 0000000..9ab953d --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ApplicationRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java new file mode 100644 index 0000000..23448ca --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookGroupRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.HookGroupEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HookGroupRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java new file mode 100644 index 0000000..12786af --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/HookUserRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.HookUserEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HookUserRepository extends JpaRepository { + + List findByIdIn(List ids); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java new file mode 100644 index 0000000..3dcdf5e --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/QuickSelectionRepository.java @@ -0,0 +1,7 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.persistence.entity.QuickSelectionEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuickSelectionRepository extends JpaRepository { +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java new file mode 100644 index 0000000..a170a36 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/ReleaseRepository.java @@ -0,0 +1,16 @@ +package com.xuqm.versionmanagement.persistence.repository; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReleaseRepository extends JpaRepository { + + List findByAppIdOrderByUploadedAtDesc(String appId); + + List findByPackageNameAndPackageType(String packageName, PlatformData.PackageType packageType); + + Optional findByIdAndAppId(String id, String appId); +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java new file mode 100644 index 0000000..b6bd169 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/AccountService.java @@ -0,0 +1,107 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import com.xuqm.versionmanagement.persistence.repository.AccountRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountService { + + private final AccountRepository accountRepository; + private final PlatformMapper platformMapper; + + public AccountService(AccountRepository accountRepository, PlatformMapper platformMapper) { + this.accountRepository = accountRepository; + this.platformMapper = platformMapper; + } + + @Transactional + public PlatformData.Account registerMainAccount(RegisterAccountCommand command) { + AccountEntity entity = new AccountEntity(); + entity.setId(nextId("ACC")); + entity.setAccountName(command.accountName()); + entity.setContactName(command.contactName()); + entity.setEmail(command.email()); + entity.setPhone(command.phone()); + entity.setType(PlatformData.AccountType.MAIN); + entity.setStatus(PlatformData.AccountStatus.PENDING); + entity.setPermissions(List.of("version:read", "version:write", "release:publish", "subaccount:grant")); + entity.setCreatedAt(LocalDateTime.now()); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + @Transactional + public PlatformData.Account createSubAccount(String parentAccountId, CreateSubAccountCommand command) { + findAccount(parentAccountId); + AccountEntity entity = new AccountEntity(); + entity.setId(nextId("SUB")); + entity.setAccountName(command.accountName()); + entity.setContactName(command.contactName()); + entity.setEmail(command.email()); + entity.setPhone(command.phone()); + entity.setType(PlatformData.AccountType.SUB); + entity.setStatus(PlatformData.AccountStatus.ACTIVE); + entity.setParentAccountId(parentAccountId); + entity.setPermissions(command.permissions()); + entity.setCreatedAt(LocalDateTime.now()); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + public List listAccounts() { + return accountRepository.findByTypeOrderByCreatedAtAsc(PlatformData.AccountType.MAIN).stream() + .map(platformMapper::toAccount) + .map(account -> new AccountView( + account, + accountRepository.findByParentAccountIdOrderByCreatedAtAsc(account.getId()).stream() + .map(platformMapper::toAccount) + .toList() + )) + .toList(); + } + + @Transactional + public PlatformData.Account updateStatus(String accountId, PlatformData.AccountStatus status) { + AccountEntity entity = findAccount(accountId); + entity.setStatus(status); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + @Transactional + public PlatformData.Account updateSubPermissions(String parentAccountId, String subAccountId, List permissions) { + findAccount(parentAccountId); + AccountEntity entity = accountRepository.findById(subAccountId) + .filter(item -> parentAccountId.equals(item.getParentAccountId())) + .orElseThrow(() -> new IllegalArgumentException("子账户不存在")); + entity.setPermissions(permissions); + return platformMapper.toAccount(accountRepository.save(entity)); + } + + private AccountEntity findAccount(String accountId) { + return accountRepository.findById(accountId) + .orElseThrow(() -> new IllegalArgumentException("账户不存在")); + } + + private String nextId(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + public record RegisterAccountCommand(String accountName, String contactName, String email, String phone) { + } + + public record CreateSubAccountCommand( + String accountName, + String contactName, + String email, + String phone, + List permissions + ) { + } + + public record AccountView(PlatformData.Account mainAccount, List subAccounts) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java new file mode 100644 index 0000000..251dc35 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/PlatformMapper.java @@ -0,0 +1,122 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.AccountEntity; +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import com.xuqm.versionmanagement.persistence.entity.HookGroupEntity; +import com.xuqm.versionmanagement.persistence.entity.HookUserEntity; +import com.xuqm.versionmanagement.persistence.entity.QuickSelectionEntity; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PlatformMapper { + + public PlatformData.Account toAccount(AccountEntity entity) { + PlatformData.Account account = new PlatformData.Account(); + account.setId(entity.getId()); + account.setAccountName(entity.getAccountName()); + account.setContactName(entity.getContactName()); + account.setEmail(entity.getEmail()); + account.setPhone(entity.getPhone()); + account.setType(entity.getType()); + account.setStatus(entity.getStatus()); + account.setParentAccountId(entity.getParentAccountId()); + account.setPermissions(entity.getPermissions()); + account.setCreatedAt(entity.getCreatedAt()); + return account; + } + + public PlatformData.ApplicationConfig toApplication(ApplicationEntity entity) { + PlatformData.ApplicationConfig config = new PlatformData.ApplicationConfig(); + config.setId(entity.getId()); + config.setName(entity.getName()); + config.setPackageName(entity.getPackageName()); + config.setPluginPackageName(entity.getPluginPackageName()); + config.setPluginManagementEnabled(entity.isPluginManagementEnabled()); + config.setBusinessModules(entity.getBusinessModules()); + config.setCreatedAt(entity.getCreatedAt()); + return config; + } + + public PlatformData.ReleaseRecord toRelease(ReleaseEntity entity) { + PlatformData.ReleaseRecord release = new PlatformData.ReleaseRecord(); + release.setId(entity.getId()); + release.setAppId(entity.getAppId()); + release.setPackageType(entity.getPackageType()); + release.setPackageName(entity.getPackageName()); + release.setVersionCode(entity.getVersionCode()); + release.setVersionName(entity.getVersionName()); + release.setTitle(entity.getTitle()); + release.setChangelog(entity.getChangelog()); + release.setDownloadUrl(entity.getDownloadUrl()); + release.setEntryActivity(entity.getEntryActivity()); + release.setForceUpdate(entity.isForceUpdate()); + release.setUploadedFileName(entity.getUploadedFileName()); + release.setStatus(entity.getStatus()); + release.setPublishStrategy(entity.getPublishStrategy()); + if (entity.getHookName() != null || !entity.getGroupCodes().isEmpty() || !entity.getQuickSelectionCodes().isEmpty() || !entity.getUserIds().isEmpty()) { + PlatformData.GrayRule grayRule = new PlatformData.GrayRule(); + grayRule.setHookName(entity.getHookName()); + grayRule.setGroupCodes(entity.getGroupCodes()); + grayRule.setQuickSelectionCodes(entity.getQuickSelectionCodes()); + grayRule.setUserIds(entity.getUserIds()); + release.setGrayRule(grayRule); + } + release.setUploadedAt(entity.getUploadedAt()); + release.setPublishedAt(entity.getPublishedAt()); + return release; + } + + public PlatformData.HookUser toHookUser(HookUserEntity entity) { + PlatformData.HookUser user = new PlatformData.HookUser(); + user.setId(entity.getId()); + user.setNickname(entity.getNickname()); + user.setPhone(entity.getPhone()); + user.setEmail(entity.getEmail()); + user.setRegion(entity.getRegion()); + user.setGroupCode(entity.getGroupCode()); + user.setGroupName(entity.getGroupName()); + user.setQuickSelectionCodes(entity.getQuickSelectionCodes()); + return user; + } + + public PlatformData.HookGroup toHookGroup(HookGroupEntity entity) { + PlatformData.HookGroup group = new PlatformData.HookGroup(); + group.setCode(entity.getCode()); + group.setName(entity.getName()); + group.setDescription(entity.getDescription()); + return group; + } + + public PlatformData.QuickSelection toQuickSelection(QuickSelectionEntity entity) { + PlatformData.QuickSelection selection = new PlatformData.QuickSelection(); + selection.setCode(entity.getCode()); + selection.setName(entity.getName()); + selection.setDescription(entity.getDescription()); + return selection; + } + + public ReleaseEntity toReleaseEntity(String appId, PlatformData.ApplicationConfig app, VersionManagementService.UploadReleaseCommand command, String id) { + ReleaseEntity entity = new ReleaseEntity(); + entity.setId(id); + entity.setAppId(appId); + entity.setPackageType(command.packageType()); + entity.setPackageName(command.packageType() == PlatformData.PackageType.PLUGIN ? app.getPluginPackageName() : app.getPackageName()); + entity.setVersionCode(command.versionCode()); + entity.setVersionName(command.versionName()); + entity.setTitle(command.title()); + entity.setChangelog(command.changelog()); + entity.setDownloadUrl(command.downloadUrl()); + entity.setEntryActivity(command.entryActivity()); + entity.setForceUpdate(command.forceUpdate()); + entity.setUploadedFileName(command.uploadedFileName()); + entity.setStatus(PlatformData.ReleaseStatus.DRAFT); + entity.setPublishStrategy("NONE"); + entity.setGroupCodes(List.of()); + entity.setQuickSelectionCodes(List.of()); + entity.setUserIds(List.of()); + return entity; + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java new file mode 100644 index 0000000..bbd4ef3 --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/UserHookService.java @@ -0,0 +1,187 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.repository.HookGroupRepository; +import com.xuqm.versionmanagement.persistence.repository.HookUserRepository; +import com.xuqm.versionmanagement.persistence.repository.QuickSelectionRepository; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class UserHookService { + + private final HookUserRepository hookUserRepository; + private final HookGroupRepository hookGroupRepository; + private final QuickSelectionRepository quickSelectionRepository; + private final PlatformMapper platformMapper; + + public UserHookService( + HookUserRepository hookUserRepository, + HookGroupRepository hookGroupRepository, + QuickSelectionRepository quickSelectionRepository, + PlatformMapper platformMapper + ) { + this.hookUserRepository = hookUserRepository; + this.hookGroupRepository = hookGroupRepository; + this.quickSelectionRepository = quickSelectionRepository; + this.platformMapper = platformMapper; + } + + @Cacheable(cacheNames = "audienceBundle", key = "T(String).valueOf(#keyword).concat('|').concat(T(String).valueOf(#groupCode)).concat('|').concat(T(String).valueOf(#quickSelectionCode))") + public AudienceBundle getAudienceBundle(String keyword, String groupCode, String quickSelectionCode) { + List sourceUsers = hookUserRepository.findAll().stream() + .map(platformMapper::toHookUser) + .toList(); + + List users = sourceUsers.stream() + .filter(user -> isBlank(groupCode) || groupCode.equalsIgnoreCase(user.getGroupCode())) + .filter(user -> isBlank(quickSelectionCode) || user.getQuickSelectionCodes().contains(quickSelectionCode)) + .filter(user -> matchKeyword(user, keyword)) + .sorted(Comparator.comparing(PlatformData.HookUser::getGroupCode).thenComparing(PlatformData.HookUser::getId)) + .map(this::mask) + .toList(); + + List groups = hookGroupRepository.findAll().stream() + .map(platformMapper::toHookGroup) + .map(group -> new GroupSummary( + group.getCode(), + group.getName(), + group.getDescription(), + sourceUsers.stream().filter(user -> Objects.equals(user.getGroupCode(), group.getCode())).count() + )) + .toList(); + + List quickSelections = quickSelectionRepository.findAll().stream() + .map(platformMapper::toQuickSelection) + .map(item -> new QuickSelectionSummary( + item.getCode(), + item.getName(), + item.getDescription(), + sourceUsers.stream().filter(user -> user.getQuickSelectionCodes().contains(item.getCode())).count() + )) + .toList(); + + return new AudienceBundle(users, groups, quickSelections); + } + + public List findUsersByIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return hookUserRepository.findByIdIn(userIds).stream() + .map(platformMapper::toHookUser) + .toList(); + } + + @CacheEvict(cacheNames = "audienceBundle", allEntries = true) + public void evictAudienceCache() { + } + + public boolean matchesGrayRule(PlatformData.HookUser user, PlatformData.GrayRule grayRule) { + if (grayRule == null || user == null) { + return false; + } + if (grayRule.getUserIds().contains(user.getId())) { + return true; + } + if (grayRule.getGroupCodes().contains(user.getGroupCode())) { + return true; + } + return grayRule.getQuickSelectionCodes().stream() + .anyMatch(code -> user.getQuickSelectionCodes().contains(code)); + } + + private boolean matchKeyword(PlatformData.HookUser user, String keyword) { + if (isBlank(keyword)) { + return true; + } + String normalized = keyword.toLowerCase(Locale.ROOT).trim(); + return Arrays.asList(user.getId(), user.getNickname(), user.getPhone(), user.getEmail(), user.getRegion()) + .stream() + .filter(Objects::nonNull) + .map(value -> value.toLowerCase(Locale.ROOT)) + .anyMatch(value -> value.contains(normalized)); + } + + private MaskedUser mask(PlatformData.HookUser user) { + return new MaskedUser( + maskId(user.getId()), + maskNickname(user.getNickname()), + maskPhone(user.getPhone()), + maskEmail(user.getEmail()), + user.getRegion(), + user.getGroupCode(), + user.getGroupName(), + user.getQuickSelectionCodes() + ); + } + + private String maskId(String id) { + if (id == null || id.length() <= 4) { + return "****"; + } + return id.substring(0, 2) + "****" + id.substring(id.length() - 2); + } + + private String maskNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + return "匿名用户"; + } + if (nickname.length() <= 2) { + return nickname.charAt(0) + "*"; + } + return nickname.charAt(0) + "**" + nickname.charAt(nickname.length() - 1); + } + + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return "***********"; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private String maskEmail(String email) { + if (email == null || !email.contains("@")) { + return "***"; + } + String[] parts = email.split("@", 2); + String name = parts[0]; + String maskedName = name.length() <= 2 ? name.charAt(0) + "*" : name.substring(0, 2) + "***"; + return maskedName + "@" + parts[1]; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + public record AudienceBundle( + List users, + List groups, + List quickSelections + ) { + } + + public record MaskedUser( + String id, + String nickname, + String phone, + String email, + String region, + String groupCode, + String groupName, + List quickSelectionCodes + ) { + } + + public record GroupSummary(String code, String name, String description, long userCount) { + } + + public record QuickSelectionSummary(String code, String name, String description, long userCount) { + } +} diff --git a/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java new file mode 100644 index 0000000..6f6c10b --- /dev/null +++ b/server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java @@ -0,0 +1,147 @@ +package com.xuqm.versionmanagement.service; + +import com.xuqm.versionmanagement.model.PlatformData; +import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity; +import com.xuqm.versionmanagement.persistence.entity.ReleaseEntity; +import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository; +import com.xuqm.versionmanagement.persistence.repository.ReleaseRepository; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class VersionManagementService { + + private final ApplicationRepository applicationRepository; + private final ReleaseRepository releaseRepository; + private final UserHookService userHookService; + private final PlatformMapper platformMapper; + + public VersionManagementService( + ApplicationRepository applicationRepository, + ReleaseRepository releaseRepository, + UserHookService userHookService, + PlatformMapper platformMapper + ) { + this.applicationRepository = applicationRepository; + this.releaseRepository = releaseRepository; + this.userHookService = userHookService; + this.platformMapper = platformMapper; + } + + public List listApplications() { + return applicationRepository.findAll().stream() + .map(platformMapper::toApplication) + .map(app -> new ApplicationDetail( + app, + releaseRepository.findByAppIdOrderByUploadedAtDesc(app.getId()).stream() + .map(platformMapper::toRelease) + .toList() + )) + .toList(); + } + + @Transactional + public PlatformData.ApplicationConfig togglePluginManagement(String appId, boolean enabled) { + ApplicationEntity entity = findApplicationEntity(appId); + entity.setPluginManagementEnabled(enabled); + return platformMapper.toApplication(applicationRepository.save(entity)); + } + + @Transactional + public PlatformData.ReleaseRecord uploadRelease(String appId, UploadReleaseCommand command) { + PlatformData.ApplicationConfig app = platformMapper.toApplication(findApplicationEntity(appId)); + ReleaseEntity entity = platformMapper.toReleaseEntity(appId, app, command, nextId("REL")); + entity.setUploadedAt(LocalDateTime.now()); + return platformMapper.toRelease(releaseRepository.save(entity)); + } + + @Transactional + public PlatformData.ReleaseRecord publishRelease(String appId, String releaseId, PublishReleaseCommand command) { + findApplicationEntity(appId); + ReleaseEntity release = findReleaseEntity(appId, releaseId); + if (command.grayPublish()) { + List matchedUsers = userHookService.findUsersByIds(command.userIds()); + if (!command.userIds().isEmpty() && matchedUsers.size() != command.userIds().size()) { + throw new IllegalArgumentException("存在未命中的灰度用户"); + } + release.setHookName(command.hookName()); + release.setGroupCodes(command.groupCodes()); + release.setQuickSelectionCodes(command.quickSelectionCodes()); + release.setUserIds(command.userIds()); + release.setStatus(PlatformData.ReleaseStatus.GRAYSCALE); + release.setPublishStrategy("GRAY"); + } else { + release.setHookName(null); + release.setGroupCodes(List.of()); + release.setQuickSelectionCodes(List.of()); + release.setUserIds(List.of()); + release.setStatus(PlatformData.ReleaseStatus.PUBLISHED); + release.setPublishStrategy("FULL"); + } + release.setPublishedAt(LocalDateTime.now()); + return platformMapper.toRelease(releaseRepository.save(release)); + } + + public PlatformData.ReleaseRecord getLatestAppRelease(String packageName, String userId) { + return selectLatestRelease(packageName, PlatformData.PackageType.APP, userId); + } + + public PlatformData.ReleaseRecord getLatestPluginRelease(String packageName, String userId) { + return selectLatestRelease(packageName, PlatformData.PackageType.PLUGIN, userId); + } + + private PlatformData.ReleaseRecord selectLatestRelease(String packageName, PlatformData.PackageType packageType, String userId) { + PlatformData.HookUser user = userId == null ? null : userHookService.findUsersByIds(List.of(userId)).stream().findFirst().orElse(null); + + return releaseRepository.findByPackageNameAndPackageType(packageName, packageType).stream() + .map(platformMapper::toRelease) + .filter(release -> release.getStatus() == PlatformData.ReleaseStatus.PUBLISHED + || (release.getStatus() == PlatformData.ReleaseStatus.GRAYSCALE && userHookService.matchesGrayRule(user, release.getGrayRule()))) + .max(Comparator.comparingInt(PlatformData.ReleaseRecord::getVersionCode) + .thenComparing(PlatformData.ReleaseRecord::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder()))) + .orElseThrow(() -> new IllegalArgumentException("版本配置不存在")); + } + + private ApplicationEntity findApplicationEntity(String appId) { + return applicationRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("应用不存在")); + } + + private ReleaseEntity findReleaseEntity(String appId, String releaseId) { + return releaseRepository.findByIdAndAppId(releaseId, appId) + .orElseThrow(() -> new IllegalArgumentException("版本不存在")); + } + + private String nextId(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + public record ApplicationDetail(PlatformData.ApplicationConfig application, List releases) { + } + + public record UploadReleaseCommand( + PlatformData.PackageType packageType, + int versionCode, + String versionName, + String title, + String changelog, + String downloadUrl, + String entryActivity, + boolean forceUpdate, + String uploadedFileName + ) { + } + + public record PublishReleaseCommand( + boolean grayPublish, + String hookName, + List groupCodes, + List quickSelectionCodes, + List userIds + ) { + } +} diff --git a/server/version-management-service/src/main/resources/application.yml b/server/version-management-service/src/main/resources/application.yml new file mode 100644 index 0000000..13440a9 --- /dev/null +++ b/server/version-management-service/src/main/resources/application.yml @@ -0,0 +1,32 @@ +server: + port: 8080 + +spring: + application: + name: version-management-service + datasource: + url: jdbc:mysql://xuqinmin.com:3306/androidLibsServer?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: androidLibsServer + password: iXc8rHydtzRpYFHJ + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + properties: + hibernate: + format_sql: true + data: + redis: + host: redisdev.xuqinmin.com + port: 6379 + database: 0 + password: xuqinmin1022 + cache: + type: redis + redis: + time-to-live: 10m + +logging: + level: + org.hibernate.SQL: info diff --git a/server/version-service/README.md b/server/version-service/README.md new file mode 100644 index 0000000..60e98ed --- /dev/null +++ b/server/version-service/README.md @@ -0,0 +1,68 @@ +# version-service + +用于给 `sample-app` 和 `plugin-ui` 提供统一的版本管理接口。 + +## 启动 + +```bash +cd __server__/version-service +npm start +``` + +默认监听: + +```text +http://0.0.0.0:3000 +``` + +## 接口 + +### 健康检查 + +```bash +curl http://127.0.0.1:3000/health +``` + +### 获取 App 最新版本 + +```bash +curl "http://127.0.0.1:3000/api/v1/updates/app/latest?packageName=com.xuqm.sample" +``` + +### 获取插件最新版本 + +```bash +curl "http://127.0.0.1:3000/api/v1/updates/plugin/latest?packageName=com.xuqm.plugin.ui" +``` + +### 更新 App 配置 + +```bash +curl --location --request PUT "http://127.0.0.1:3000/api/v1/admin/updates/app" \ +--header "Content-Type: application/json" \ +--data '{ + "packageName": "com.xuqm.sample", + "versionCode": 2, + "versionName": "0.2.0", + "title": "发现新版本", + "changelog": "更新内容", + "downloadUrl": "http://192.168.116.9:10223/app.apk", + "forceUpdate": false +}' +``` + +### 更新插件配置 + +```bash +curl --location --request PUT "http://127.0.0.1:3000/api/v1/admin/updates/plugin" \ +--header "Content-Type: application/json" \ +--data '{ + "packageName": "com.xuqm.plugin.ui", + "versionCode": 2, + "versionName": "0.2.0", + "downloadUrl": "http://192.168.116.9:10223/plugin-ui-release.apk", + "entryActivity": "com.xuqm.plugin.ui.PluginUiActivity" +}' +``` + +版本数据存放在 [`data/version-config.json`](./data/version-config.json)。 diff --git a/server/version-service/data/version-config.json b/server/version-service/data/version-config.json new file mode 100644 index 0000000..f1ad3e9 --- /dev/null +++ b/server/version-service/data/version-config.json @@ -0,0 +1,22 @@ +{ + "apps": { + "com.xuqm.sample": { + "packageName": "com.xuqm.sample", + "versionCode": 2, + "versionName": "0.2.0", + "title": "发现新版本", + "changelog": "1. 新增统一下载管理\n2. 新增插件版本管理\n3. 优化宿主更新流程", + "downloadUrl": "http://192.168.116.9:10223/app.apk", + "forceUpdate": false + } + }, + "plugins": { + "com.xuqm.plugin.ui": { + "packageName": "com.xuqm.plugin.ui", + "versionCode": 2, + "versionName": "0.2.0", + "downloadUrl": "http://192.168.116.9:10223/plugin-ui-release.apk", + "entryActivity": "com.xuqm.plugin.ui.PluginUiActivity" + } + } +} diff --git a/server/version-service/package.json b/server/version-service/package.json new file mode 100644 index 0000000..df6a709 --- /dev/null +++ b/server/version-service/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xuqm/version-service", + "version": "0.1.0", + "private": true, + "description": "Version service for Android host app and plugin update management", + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server/version-service/src/index.js b/server/version-service/src/index.js new file mode 100644 index 0000000..d19aad9 --- /dev/null +++ b/server/version-service/src/index.js @@ -0,0 +1,141 @@ +const http = require("http"); +const fs = require("fs/promises"); +const path = require("path"); +const { URL } = require("url"); + +const HOST = process.env.HOST || "0.0.0.0"; +const PORT = Number(process.env.PORT || 3000); +const DATA_FILE = path.join(__dirname, "..", "data", "version-config.json"); + +async function readConfig() { + const raw = await fs.readFile(DATA_FILE, "utf8"); + return JSON.parse(raw); +} + +async function writeConfig(config) { + await fs.writeFile(DATA_FILE, JSON.stringify(config, null, 2), "utf8"); +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + res.end(JSON.stringify(payload)); +} + +function ok(data, message = "success") { + return { code: 200, status: "0", data, message }; +} + +function fail(statusCode, message) { + return { + statusCode, + payload: { code: statusCode, status: String(statusCode), data: null, message } + }; +} + +async function parseBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +async function handleGetLatestApp(url, res) { + const packageName = url.searchParams.get("packageName"); + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const app = config.apps[packageName]; + if (!app) { + const result = fail(404, `app config not found for ${packageName}`); + return sendJson(res, result.statusCode, result.payload); + } + return sendJson(res, 200, ok(app)); +} + +async function handleGetLatestPlugin(url, res) { + const packageName = url.searchParams.get("packageName"); + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const plugin = config.plugins[packageName]; + if (!plugin) { + const result = fail(404, `plugin config not found for ${packageName}`); + return sendJson(res, result.statusCode, result.payload); + } + return sendJson(res, 200, ok(plugin)); +} + +async function handleUpdateConfig(kind, req, res) { + const body = await parseBody(req); + const packageName = body.packageName; + if (!packageName) { + const result = fail(400, "packageName is required"); + return sendJson(res, result.statusCode, result.payload); + } + + const config = await readConfig(); + const target = kind === "app" ? config.apps : config.plugins; + target[packageName] = body; + await writeConfig(config); + return sendJson(res, 200, ok(body, `${kind} config updated`)); +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + return res.end(); + } + + if (req.method === "GET" && url.pathname === "/health") { + return sendJson(res, 200, ok({ status: "UP" })); + } + + if (req.method === "GET" && url.pathname === "/api/v1/updates/app/latest") { + return handleGetLatestApp(url, res); + } + + if (req.method === "GET" && url.pathname === "/api/v1/updates/plugin/latest") { + return handleGetLatestPlugin(url, res); + } + + if (req.method === "PUT" && url.pathname === "/api/v1/admin/updates/app") { + return handleUpdateConfig("app", req, res); + } + + if (req.method === "PUT" && url.pathname === "/api/v1/admin/updates/plugin") { + return handleUpdateConfig("plugin", req, res); + } + + const result = fail(404, "route not found"); + return sendJson(res, result.statusCode, result.payload); + } catch (error) { + return sendJson(res, 500, { + code: 500, + status: "500", + data: null, + message: error.message || "internal server error" + }); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`version-service is running at http://${HOST}:${PORT}`); +});