From c51cdd46f4d9614f6fcd119714b022b358611b08 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sun, 26 Jul 2026 23:45:08 +0800 Subject: [PATCH] feat(platform): implement signed sdk config and artifacts --- CLAUDE.md | 2 - Dockerfile | 2 - Jenkinsfile | 55 ++- VERSION.license-service | 1 - common/pom.xml | 5 + .../common/security/ApiKeyAuthFilter.java | 33 +- .../common/security/ConfigFileCrypto.java | 111 +++-- .../common/security/LicenseFileCrypto.java | 112 ----- .../common/security/ApiKeyAuthFilterTest.java | 51 +++ .../common/security/ConfigFileCryptoTest.java | 32 ++ .../test/resources/xuqm-config-v2-vector.json | 6 + docs/SDK_PLATFORM_V2_HANDOFF.md | 122 ++++++ .../xuqm/im/controller/AuthController.java | 17 +- license-service/pom.xml | 85 ---- .../license/LicenseServiceApplication.java | 15 - .../xuqm/license/config/SecurityConfig.java | 75 ---- .../controller/GlobalExceptionHandler.java | 99 ----- .../controller/LicenseAdminController.java | 84 ---- .../controller/LicenseInternalController.java | 112 ----- .../LicenseOperationLogController.java | 37 -- .../controller/LicensePublicController.java | 121 ------ .../xuqm/license/entity/AppLicenseEntity.java | 85 ---- .../com/xuqm/license/entity/DeviceEntity.java | 123 ------ .../entity/LicenseOperationLogEntity.java | 69 --- .../repository/AppLicenseRepository.java | 9 - .../license/repository/DeviceRepository.java | 16 - .../LicenseOperationLogRepository.java | 10 - .../license/service/AppLicenseService.java | 100 ----- .../xuqm/license/service/DeviceService.java | 204 --------- .../license/service/LicenseAuthService.java | 48 --- .../service/LicenseOperationLogService.java | 56 --- .../LicenseSchemaMigrationService.java | 113 ----- .../src/main/resources/application.yml | 42 -- .../main/resources/db/migration/V1__init.sql | 49 --- pom.xml | 1 - .../push/controller/PushAuthController.java | 15 +- .../config/PrivateDeploymentProperties.java | 4 - .../xuqm/tenant/controller/AppController.java | 36 +- .../controller/FeatureServiceController.java | 1 - .../controller/InternalSdkController.java | 15 +- .../PrivateDeploymentController.java | 4 - .../controller/SdkBuildConfigController.java | 35 ++ .../controller/SdkConfigController.java | 14 +- .../com/xuqm/tenant/entity/AppEntity.java | 26 +- .../tenant/entity/FeatureServiceEntity.java | 2 +- .../com/xuqm/tenant/service/AppService.java | 156 +++++-- .../service/ConfigFileSigningService.java | 78 ++++ .../tenant/service/FeatureServiceManager.java | 33 +- .../tenant/service/LicenseServiceClient.java | 85 ---- .../tenant/service/SystemUpdateService.java | 59 +-- .../service/UpdatePublishConfigClient.java | 88 ++++ .../src/main/resources/application.yml | 16 +- .../db/migration/V3__sdk_config_v2.sql | 8 + .../V4__allow_platform_specific_apps.sql | 2 + .../V5__remove_obsolete_license_service.sql | 8 + .../src/main/resources/nginx/xuqm.conf | 10 - .../controller/InternalSdkControllerTest.java | 35 ++ .../UpdatePublishConfigClientTest.java | 44 ++ .../org.mockito.plugins.MockMaker | 1 + .../BOOT-INF/classes/application.yml | 52 --- .../update/config/TenantApiKeyValidator.java | 10 +- .../controller/AppVersionController.java | 12 +- .../bugcollect/config/SecurityConfig.java | 23 +- .../config/TenantApiKeyValidator.java | 73 ++++ .../controller/GlobalExceptionHandler.java | 7 + .../bugcollect/controller/LogController.java | 47 ++- .../bugcollect/dto/IssueBatchRequest.java | 3 + .../xuqm/bugcollect/dto/SourcemapInfo.java | 7 +- .../dto/SourcemapUploadResponse.java | 7 +- .../entity/LogIssueEventEntity.java | 18 + .../bugcollect/entity/LogSourcemapEntity.java | 38 +- .../repository/LogSourcemapRepository.java | 27 +- .../BugCollectAvailabilityService.java | 84 ++++ .../service/BugCollectDisabledException.java | 13 + .../xuqm/bugcollect/service/LogService.java | 20 +- .../bugcollect/service/SourcemapService.java | 392 ++++++++++-------- .../src/main/resources/application.yml | 9 +- .../V9__sourcemap_exact_bundle_identity.sql | 23 + .../config/ArtifactApiTokenFilterTest.java | 82 ++++ .../GlobalExceptionHandlerTest.java | 18 + .../service/SourcemapServiceTest.java | 74 ++++ .../org.mockito.plugins.MockMaker | 1 + 82 files changed, 1679 insertions(+), 2238 deletions(-) delete mode 100644 VERSION.license-service delete mode 100644 common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java create mode 100644 common/src/test/java/com/xuqm/common/security/ApiKeyAuthFilterTest.java create mode 100644 common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java create mode 100644 common/src/test/resources/xuqm-config-v2-vector.json create mode 100644 docs/SDK_PLATFORM_V2_HANDOFF.md delete mode 100644 license-service/pom.xml delete mode 100644 license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java delete mode 100644 license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java delete mode 100644 license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java delete mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java delete mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java delete mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicenseOperationLogController.java delete mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java delete mode 100644 license-service/src/main/java/com/xuqm/license/entity/AppLicenseEntity.java delete mode 100644 license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java delete mode 100644 license-service/src/main/java/com/xuqm/license/entity/LicenseOperationLogEntity.java delete mode 100644 license-service/src/main/java/com/xuqm/license/repository/AppLicenseRepository.java delete mode 100644 license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java delete mode 100644 license-service/src/main/java/com/xuqm/license/repository/LicenseOperationLogRepository.java delete mode 100644 license-service/src/main/java/com/xuqm/license/service/AppLicenseService.java delete mode 100644 license-service/src/main/java/com/xuqm/license/service/DeviceService.java delete mode 100644 license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java delete mode 100644 license-service/src/main/java/com/xuqm/license/service/LicenseOperationLogService.java delete mode 100644 license-service/src/main/java/com/xuqm/license/service/LicenseSchemaMigrationService.java delete mode 100644 license-service/src/main/resources/application.yml delete mode 100644 license-service/src/main/resources/db/migration/V1__init.sql create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/SdkBuildConfigController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java delete mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/UpdatePublishConfigClient.java create mode 100644 tenant-service/src/main/resources/db/migration/V3__sdk_config_v2.sql create mode 100644 tenant-service/src/main/resources/db/migration/V4__allow_platform_specific_apps.sql create mode 100644 tenant-service/src/main/resources/db/migration/V5__remove_obsolete_license_service.sql create mode 100644 tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java create mode 100644 tenant-service/src/test/java/com/xuqm/tenant/service/UpdatePublishConfigClientTest.java create mode 100644 tenant-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker delete mode 100644 update-service/BOOT-INF/classes/application.yml create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/TenantApiKeyValidator.java create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectAvailabilityService.java create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectDisabledException.java create mode 100644 xuqm-bugcollect-service/src/main/resources/db/migration/V9__sourcemap_exact_bundle_identity.sql create mode 100644 xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/config/ArtifactApiTokenFilterTest.java create mode 100644 xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/controller/GlobalExceptionHandlerTest.java create mode 100644 xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/service/SourcemapServiceTest.java create mode 100644 xuqm-bugcollect-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/CLAUDE.md b/CLAUDE.md index 9bec4c3..f983149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,6 @@ XuqmGroup-Server/ ├── im-service/ # IM 服务 :8082 ├── push-service/ # 推送服务 :8083 ├── update-service/ # 版本管理服务 :8084 -├── license-service/ # 证书授权服务 :8085 ├── file-service/ # 文件服务 ├── im-sdk/ # IM SDK(服务端封装) ├── xuqm-bugcollect-service/ # Bug 收集服务 :9006 @@ -32,7 +31,6 @@ XuqmGroup-Server/ | im-service | 8082 | IM 消息、群组、WebSocket | | push-service | 8083 | 推送 token 注册、推送发送 | | update-service | 8084 | App/RN Bundle 版本管理 | -| license-service | 8085 | 证书授权 | | xuqm-bugcollect-service | 9006 | Bug 收集、错误追踪、漏斗分析 | ## 技术栈 diff --git a/Dockerfile b/Dockerfile index 745dc2c..e4cf750 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,6 @@ COPY push-service/pom.xml ./push-service/pom.xml COPY update-service/pom.xml ./update-service/pom.xml COPY demo-service/pom.xml ./demo-service/pom.xml COPY file-service/pom.xml ./file-service/pom.xml -COPY license-service/pom.xml ./license-service/pom.xml COPY xuqm-bugcollect-service/pom.xml ./xuqm-bugcollect-service/pom.xml # Pre-download dependencies using BuildKit cache mount — persisted across builds @@ -36,7 +35,6 @@ COPY push-service ./push-service COPY update-service ./update-service COPY demo-service ./demo-service COPY file-service ./file-service -COPY license-service ./license-service COPY xuqm-bugcollect-service ./xuqm-bugcollect-service RUN --mount=type=cache,target=/root/.m2 \ diff --git a/Jenkinsfile b/Jenkinsfile index 36e2b8c..6885201 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,9 +2,14 @@ pipeline { agent any parameters { + choice( + name: 'DEPLOY_ENV', + choices: ['development', 'production'], + description: 'development 只执行验证;production 需要人工确认后发布' + ) choice( name: 'SERVICE', - choices: ['all', 'tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service', 'demo-service', 'xuqm-bugcollect-service'], + choices: ['all', 'tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'demo-service', 'xuqm-bugcollect-service'], description: '要构建的服务模块(all = 全部,每个服务独立版本号)' ) } @@ -35,16 +40,52 @@ pipeline { branches: [[name: 'main']], extensions: [[$class: 'CleanBeforeCheckout']], userRemoteConfigs: [[ - url: 'https://xuqinmin12:28115f261568d510d230727b3e82c0167414286e@xuqinmin.com/xuqmGroup/XuqmGroup-Server.git' + url: 'https://xuqinmin.com/xuqmGroup/XuqmGroup-Server.git', + credentialsId: 'XUQM_GIT_CREDENTIALS' ]] ]) } } + stage('Verify') { + steps { + bat 'mvn -B test' + } + } + + stage('Confirm Production') { + when { expression { params.DEPLOY_ENV == 'production' } } + input { + message '确认发布并部署到生产环境?' + ok '确认生产发布' + } + steps { + echo '生产发布已人工确认' + } + } + + stage('Validate Production Secrets') { + when { expression { params.DEPLOY_ENV == 'production' } } + steps { + withCredentials([ + string(credentialsId: 'SDK_CONFIG_SIGNING_KEY_ID', variable: 'CONFIG_KEY_ID'), + string(credentialsId: 'SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64', variable: 'CONFIG_PRIVATE_KEY'), + string(credentialsId: 'SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64', variable: 'CONFIG_PUBLIC_KEY') + ]) { + bat ''' + if "%CONFIG_KEY_ID%"=="" exit /b 1 + if "%CONFIG_PRIVATE_KEY%"=="" exit /b 1 + if "%CONFIG_PUBLIC_KEY%"=="" exit /b 1 + ''' + } + } + } + stage('Resolve Versions') { + when { expression { params.DEPLOY_ENV == 'production' } } steps { script { - def allServices = ['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service', 'demo-service', 'xuqm-bugcollect-service'] + def allServices = ['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'demo-service', 'xuqm-bugcollect-service'] // 支持从 job 名自动推断服务(如 xuqmgroup-update-service → update-service) def jobService = env.JOB_NAME.tokenize('/').last() @@ -71,6 +112,7 @@ pipeline { } stage('Docker Build & Push') { + when { expression { params.DEPLOY_ENV == 'production' } } steps { withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) { script { @@ -102,6 +144,7 @@ pipeline { } stage('Deploy to Production') { + when { expression { params.DEPLOY_ENV == 'production' } } steps { lock('prod-deploy') { withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) { @@ -121,7 +164,7 @@ pipeline { // 合并更新 versions.json(只改动本次构建涉及的服务,不覆盖其它服务或 web 条目) def releasedAt = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone('UTC')) def builtJson = groovy.json.JsonOutput.toJson(serviceVersions) - def allSvcsList = groovy.json.JsonOutput.toJson(['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'license-service', 'demo-service', 'xuqm-bugcollect-service']) + def allSvcsList = groovy.json.JsonOutput.toJson(['tenant-service', 'im-service', 'push-service', 'update-service', 'file-service', 'demo-service', 'xuqm-bugcollect-service']) def updateScript = """\ import json, os path = '${env.VERSIONS_FILE}' @@ -159,6 +202,7 @@ print('versions.json updated, platformVersion=' + d.get('platformVersion', '')) } stage('Commit Versions') { + when { expression { params.DEPLOY_ENV == 'production' } } steps { script { def serviceVersions = new groovy.json.JsonSlurperClassic().parseText(env.SERVICE_VERSIONS_JSON) @@ -190,7 +234,8 @@ print('versions.json updated, platformVersion=' + d.get('platformVersion', '')) script { def serviceVersions = new groovy.json.JsonSlurperClassic().parseText(env.SERVICE_VERSIONS_JSON ?: '{}') def summary = serviceVersions.collect { svc, ver -> "${svc}:${ver}" }.join(', ') - bat "chcp 65001 >nul && echo ✅ 构建部署成功 — ${summary}" + def resultText = params.DEPLOY_ENV == 'production' ? "构建部署成功 — ${summary}" : 'development 验证成功(未发布)' + bat "chcp 65001 >nul && echo ✅ ${resultText}" } } failure { bat 'chcp 65001 >nul && echo ❌ 构建失败,请检查日志' } diff --git a/VERSION.license-service b/VERSION.license-service deleted file mode 100644 index 3eefcb9..0000000 --- a/VERSION.license-service +++ /dev/null @@ -1 +0,0 @@ -1.0.0 diff --git a/common/pom.xml b/common/pom.xml index 879ee0d..69bdde8 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -42,5 +42,10 @@ jjwt-jackson runtime + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java index 1458d13..d762378 100644 --- a/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java +++ b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java @@ -8,6 +8,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; import java.io.IOException; import java.util.List; @@ -28,16 +29,46 @@ public class ApiKeyAuthFilter extends OncePerRequestFilter { public static final String HEADER_NAME = "X-API-Key"; private final ApiKeyValidator validator; + private final RequestMatcher requestMatcher; + private final boolean bearerOnly; public ApiKeyAuthFilter(ApiKeyValidator validator) { + this(validator, request -> true, false); + } + + /** + * 为构建工具提供受路径约束的 Bearer API Token 认证;Token 仍由平台 API Key + * 权威服务校验,不引入第二套密钥或签名规则。 + */ + public ApiKeyAuthFilter(ApiKeyValidator validator, RequestMatcher requestMatcher, boolean bearerOnly) { this.validator = validator; + this.requestMatcher = requestMatcher; + this.bearerOnly = bearerOnly; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String apiKey = request.getHeader(HEADER_NAME); + if (!requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + String apiKey; + if (bearerOnly) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + apiKey = authorization.substring(7).trim(); + } else { + apiKey = null; + } + } else { + apiKey = request.getHeader(HEADER_NAME); + } if (apiKey == null || apiKey.isBlank()) { + if (bearerOnly) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "API Token required"); + return; + } // 无 API Key,交给后续过滤器(JWT 等) filterChain.doFilter(request, response); return; diff --git a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java index 72a635d..ab23812 100644 --- a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java +++ b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java @@ -9,18 +9,23 @@ import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.SecureRandom; +import java.security.Signature; +import java.time.Instant; import java.util.Base64; /** - * Encrypts/decrypts the SDK init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>). - * Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations). - * This is separate from LicenseFileCrypto (device activation). + * XUQM-CONFIG-V2 配置文件编解码器。 + * + *

AES-GCM 仅用于避免配置正文直接暴露;Ed25519 签名才是配置来源可信的依据。 + * 签名私钥只允许保存在租户平台服务端,客户端与构建工具只持有公钥。

*/ public final class ConfigFileCrypto { - private static final String MAGIC = "XUQM-CONFIG-V1"; - private static final String PASSPHRASE = "xuqm-config-file-v1.2026.internal"; + public static final String MAGIC = "XUQM-CONFIG-V2"; + private static final String PASSPHRASE = "xuqm-config-file-v2.2026.internal"; private static final int SALT_BYTES = 16; private static final int IV_BYTES = 12; private static final int KEY_BITS = 256; @@ -32,7 +37,16 @@ public final class ConfigFileCrypto { private ConfigFileCrypto() { } - public static String encrypt(String plainText) { + /** + * 生成经平台私钥签名的 V2 配置。 + */ + public static String encryptAndSign(String plainText, String keyId, PrivateKey privateKey) { + if (keyId == null || keyId.isBlank()) { + throw new IllegalArgumentException("Config signing keyId is required"); + } + if (privateKey == null) { + throw new IllegalArgumentException("Config signing private key is required"); + } try { byte[] salt = randomBytes(SALT_BYTES); byte[] iv = randomBytes(IV_BYTES); @@ -40,31 +54,53 @@ public final class ConfigFileCrypto { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - return String.join(".", + String unsignedToken = String.join(".", MAGIC, - Base64.getUrlEncoder().withoutPadding().encodeToString(salt), - Base64.getUrlEncoder().withoutPadding().encodeToString(iv), - Base64.getUrlEncoder().withoutPadding().encodeToString(cipherText)); + keyId, + encode(salt), + encode(iv), + encode(cipherText)); + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(privateKey); + signer.update(unsignedToken.getBytes(StandardCharsets.UTF_8)); + return unsignedToken + "." + encode(signer.sign()); } catch (Exception e) { - throw new IllegalStateException("Failed to encrypt config file", e); + throw new IllegalStateException("Failed to generate signed config file", e); } } - public static ConfigPayload decrypt(String token) { + /** + * 验证平台签名后解密配置。绝不接受 V1 或未签名内容。 + */ + public static ConfigPayload decryptAndVerify(String token, PublicKey publicKey) { + if (publicKey == null) { + throw new IllegalArgumentException("Config signing public key is required"); + } try { String[] parts = token.split("\\."); - if (parts.length != 4 || !MAGIC.equals(parts[0])) { - throw new IllegalArgumentException("Invalid config file format"); + if (parts.length != 6 || !MAGIC.equals(parts[0])) { + throw new IllegalArgumentException("Only XUQM-CONFIG-V2 is supported"); } - byte[] salt = Base64.getUrlDecoder().decode(parts[1]); - byte[] iv = Base64.getUrlDecoder().decode(parts[2]); - byte[] cipherText = Base64.getUrlDecoder().decode(parts[3]); + String unsignedToken = String.join(".", parts[0], parts[1], parts[2], parts[3], parts[4]); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(unsignedToken.getBytes(StandardCharsets.UTF_8)); + if (!verifier.verify(decode(parts[5]))) { + throw new IllegalArgumentException("Config file signature verification failed"); + } + + byte[] salt = decode(parts[2]); + byte[] iv = decode(parts[3]); + byte[] cipherText = decode(parts[4]); SecretKeySpec key = deriveKey(salt); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); - String plainText = new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); - JsonNode node = MAPPER.readTree(plainText); + JsonNode node = MAPPER.readTree(new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8)); return new ConfigPayload( + text(node, "configId"), + node.path("revision").asLong(0), + instant(node, "issuedAt"), + instant(node, "expiresAt"), text(node, "appKey"), text(node, "appName"), text(node, "companyName"), @@ -73,11 +109,12 @@ public final class ConfigFileCrypto { text(node, "harmonyBundleName"), text(node, "baseUrl"), text(node, "serverUrl"), - text(node, "signingKey")); + text(node, "signingKey"), + parts[1]); } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { - throw new IllegalArgumentException("Failed to decrypt config file: " + e.getMessage(), e); + throw new IllegalArgumentException("Failed to read config file: " + e.getMessage(), e); } } @@ -93,12 +130,29 @@ public final class ConfigFileCrypto { return bytes; } + private static String encode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + private static byte[] decode(String value) { + return Base64.getUrlDecoder().decode(value); + } + private static String text(JsonNode node, String field) { - JsonNode v = node.path(field); - return v.isMissingNode() || v.isNull() ? null : v.asText(null); + JsonNode value = node.path(field); + return value.isMissingNode() || value.isNull() ? null : value.asText(null); + } + + private static Instant instant(JsonNode node, String field) { + String value = text(node, field); + return value == null || value.isBlank() ? null : Instant.parse(value); } public record ConfigPayload( + String configId, + long revision, + Instant issuedAt, + Instant expiresAt, String appKey, String appName, String companyName, @@ -107,7 +161,8 @@ public final class ConfigFileCrypto { String harmonyBundleName, String baseUrl, String serverUrl, - String signingKey) { + String signingKey, + String keyId) { public boolean matchesPackageName(String candidate) { if (candidate == null || candidate.isBlank()) return false; @@ -116,6 +171,12 @@ public final class ConfigFileCrypto { return candidate.equals(packageName) || candidate.equals(iosBundleId) || candidate.equals(harmonyBundleName); } - private static boolean hasText(String s) { return s != null && !s.isBlank(); } + public boolean isExpired(Instant now) { + return expiresAt != null && !expiresAt.isAfter(now); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } } } diff --git a/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java b/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java deleted file mode 100644 index e144083..0000000 --- a/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.xuqm.common.security; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import javax.crypto.Cipher; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; - -public final class LicenseFileCrypto { - - private static final String MAGIC = "XUQM-LICENSE-V1"; - private static final String PASSPHRASE = "xuqm-license-file-v1.2026.internal"; - private static final int SALT_BYTES = 16; - private static final int IV_BYTES = 12; - private static final int KEY_BITS = 256; - private static final int ITERATIONS = 120_000; - private static final int GCM_TAG_BITS = 128; - private static final SecureRandom RANDOM = new SecureRandom(); - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private LicenseFileCrypto() { - } - - public static String encrypt(String plainText) { - try { - byte[] salt = randomBytes(SALT_BYTES); - byte[] iv = randomBytes(IV_BYTES); - SecretKeySpec key = deriveKey(salt); - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); - byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - return String.join(".", - MAGIC, - Base64.getUrlEncoder().withoutPadding().encodeToString(salt), - Base64.getUrlEncoder().withoutPadding().encodeToString(iv), - Base64.getUrlEncoder().withoutPadding().encodeToString(cipherText)); - } catch (Exception e) { - throw new IllegalStateException("Failed to encrypt license file", e); - } - } - - public static LicensePayload decrypt(String token) { - try { - String[] parts = token.split("\\."); - if (parts.length != 4 || !MAGIC.equals(parts[0])) { - throw new IllegalArgumentException("Invalid license file format"); - } - byte[] salt = Base64.getUrlDecoder().decode(parts[1]); - byte[] iv = Base64.getUrlDecoder().decode(parts[2]); - byte[] cipherText = Base64.getUrlDecoder().decode(parts[3]); - SecretKeySpec key = deriveKey(salt); - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); - String plainText = new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); - JsonNode node = MAPPER.readTree(plainText); - return new LicensePayload( - text(node, "appKey"), - text(node, "appName"), - text(node, "packageName"), - text(node, "iosBundleId"), - text(node, "harmonyBundleName"), - text(node, "baseUrl"), - text(node, "serverUrl")); - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new IllegalArgumentException("Failed to decrypt license file: " + e.getMessage(), e); - } - } - - private static SecretKeySpec deriveKey(byte[] salt) throws Exception { - PBEKeySpec spec = new PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS); - byte[] encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); - return new SecretKeySpec(encoded, "AES"); - } - - private static byte[] randomBytes(int size) { - byte[] bytes = new byte[size]; - RANDOM.nextBytes(bytes); - return bytes; - } - - private static String text(JsonNode node, String field) { - JsonNode v = node.path(field); - return v.isMissingNode() || v.isNull() ? null : v.asText(null); - } - - public record LicensePayload( - String appKey, - String appName, - String packageName, - String iosBundleId, - String harmonyBundleName, - String baseUrl, - String serverUrl) { - - public boolean matchesPackageName(String candidate) { - if (candidate == null || candidate.isBlank()) return false; - boolean anyConfigured = hasText(packageName) || hasText(iosBundleId) || hasText(harmonyBundleName); - if (!anyConfigured) return true; - return candidate.equals(packageName) || candidate.equals(iosBundleId) || candidate.equals(harmonyBundleName); - } - - private static boolean hasText(String s) { return s != null && !s.isBlank(); } - } -} diff --git a/common/src/test/java/com/xuqm/common/security/ApiKeyAuthFilterTest.java b/common/src/test/java/com/xuqm/common/security/ApiKeyAuthFilterTest.java new file mode 100644 index 0000000..5df2ca4 --- /dev/null +++ b/common/src/test/java/com/xuqm/common/security/ApiKeyAuthFilterTest.java @@ -0,0 +1,51 @@ +package com.xuqm.common.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +class ApiKeyAuthFilterTest { + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void genericModeStillAuthenticatesXApiKey() throws Exception { + ApiKeyAuthFilter filter = new ApiKeyAuthFilter(token -> "ak_generic"); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/any"); + request.addHeader(ApiKeyAuthFilter.HEADER_NAME, "valid-key"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(); + + filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> chainCalled.set(true)); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(chainCalled).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()) + .isEqualTo("apikey:ak_generic"); + } + + @Test + void genericModeDoesNotInterpretBearerAsApiKey() throws Exception { + ApiKeyAuthFilter filter = new ApiKeyAuthFilter(token -> { + throw new AssertionError("Bearer 不应传给通用 API Key 验证器"); + }); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/any"); + request.addHeader("Authorization", "Bearer tenant-jwt"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(); + + filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> chainCalled.set(true)); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(chainCalled).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } +} diff --git a/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java b/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java new file mode 100644 index 0000000..70e600d --- /dev/null +++ b/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java @@ -0,0 +1,32 @@ +package com.xuqm.common.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.security.KeyFactory; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigFileCryptoTest { + + @Test + void verifiesPublishedCrossPlatformVector() throws Exception { + JsonNode vector = new ObjectMapper().readTree( + getClass().getResourceAsStream("/xuqm-config-v2-vector.json")); + var publicKey = KeyFactory.getInstance("Ed25519").generatePublic( + new X509EncodedKeySpec(Base64.getDecoder().decode( + vector.path("publicKeyX509Base64").asText()))); + + ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decryptAndVerify( + vector.path("token").asText(), publicKey); + + assertThat(payload.keyId()).isEqualTo(vector.path("keyId").asText()); + assertThat(payload.appKey()).isEqualTo("ak_vector"); + assertThat(payload.configId()).isEqualTo("00000000-0000-4000-8000-000000000001"); + assertThat(payload.revision()).isEqualTo(1); + assertThat(payload.packageName()).isEqualTo("com.xuqm.vector"); + } +} diff --git a/common/src/test/resources/xuqm-config-v2-vector.json b/common/src/test/resources/xuqm-config-v2-vector.json new file mode 100644 index 0000000..7fdfaac --- /dev/null +++ b/common/src/test/resources/xuqm-config-v2-vector.json @@ -0,0 +1,6 @@ +{ + "keyId": "xuqm-config-dev-2026-07", + "publicKeyX509Base64": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=", + "canonicalJson": "{\"appKey\":\"ak_vector\",\"appName\":\"Vector App\",\"configId\":\"00000000-0000-4000-8000-000000000001\",\"expiresAt\":\"2030-01-01T00:00:00Z\",\"issuedAt\":\"2026-07-26T00:00:00Z\",\"packageName\":\"com.xuqm.vector\",\"revision\":1,\"schemaVersion\":2,\"serverUrl\":\"https://dev.xuqinmin.com/\",\"signingKey\":\"test-signing-key\"}", + "token": "XUQM-CONFIG-V2.xuqm-config-dev-2026-07.JPlU6fEC8jLVYhSUQZgEGg.fXJwK2yKUyyVQDMe.Zqw7v9Y1ysFfbErPGKTg-rtZRgEOefn9-T44XA81YEJpsN219i_uBYiVXYDZUBKgOGg7KWAZz1ReBfiB-JOeU2N2Y-6Dm7KzDJcEDuBKiZLGqrnAKgmTd1IOqewejZLhQxmXa8Xk_YVgxy3CIZ3-F5dVIhN7hUXjfDTgUnhXNUEj56blG5keuB8x-W6Nz3AkYn-P11qLVLXELr7DJhd9BkooYOJHenJ3V62DTb5AoGXnUzn2VyT9xHZiy4kWyG7yH6daCascWP8iCKaLm9yhKresL6EoUiw4WS9rTPKR_0m1N11BaR38RO5J-0V41Y0YCF0PYCDSwjgG8e3aL_eYVMIh1_sZYaZWs7SBSubZtqe420fh2lqF68KyyF4mpIhS4jP3wWcxmFDzOW_XYvbZzSZ9Tpm0ROgOz7pb._fbWIWofH9fTEqnkEIdLr2KoXEIHZg3AmW7CQD-_TrXZHIcEU30PkRPPv8AXguBJPZxX0T-m41dag6_96YVQCw" +} diff --git a/docs/SDK_PLATFORM_V2_HANDOFF.md b/docs/SDK_PLATFORM_V2_HANDOFF.md new file mode 100644 index 0000000..2e13fab --- /dev/null +++ b/docs/SDK_PLATFORM_V2_HANDOFF.md @@ -0,0 +1,122 @@ +# SDK 平台 V2 实施与接手说明 + +> 更新时间:2026-07-26。本文记录当前源码事实与尚未执行的部署动作;不得把本文当作生产环境已发布证明。 + +## 当前边界 + +- Config 文件唯一格式为 `XUQM-CONFIG-V2`,V1 不再接受。 +- Config 文件由租户服务生成;客户端只验证签名、有效期和包名,不在线检查吊销状态。 +- BugCollect 运行时上报失败不得影响宿主业务;服务关闭时固定返回 HTTP 403,消息码为 `BUGCOLLECT_DISABLED`。 +- 构建产物上传唯一入口为 `POST /bugcollect/v1/artifacts/upload`。 +- 已废弃的 App 离线激活 license 服务已从聚合构建、租户服务、网关和 Web 入口删除;它与签名 SDK 无关。 + +## Config 文件契约 + +格式: + +```text +XUQM-CONFIG-V2..... +``` + +- 内容加密:AES-256-GCM。 +- 密钥派生:PBKDF2-HMAC-SHA256,120000 次。 +- 完整性与发布方身份:Ed25519 对前五段的 ASCII 原文签名。 +- 时间统一使用 UTC `Instant`。 +- 重新生成会产生新的 `configId` 和递增 `revision`;旧文件只在后续构建时被拦截,不影响已安装客户端运行。 +- 构建校验接口: + +```http +POST /api/sdk/build/config/validate +Content-Type: application/json + +{"content":"...","packageName":"com.example.app"} +``` + +签名密钥只能由运行环境注入: + +- `SDK_CONFIG_SIGNING_KEY_ID` +- `SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64`(PKCS#8 DER Base64) +- `SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64`(X.509 DER Base64) + +公共互操作向量位于 `common/src/test/resources/xuqm-config-v2-vector.json`。私钥不得进入仓库、日志或普通文档。 + +## SDK 动态配置 + +`GET /api/sdk/config` 的功能开关按 `appKey + platform + serviceType` 精确读取。版本更新登录要求来自 update-service 的权威字段 `allowAnonymousUpdateCheck`,对外返回 `updateRequiresLogin`。 + +租户服务访问 update-service 使用 500ms 连接超时、800ms 读取超时和 15s 小 TTL 缓存。依赖不可用时安全返回 `updateRequiresLogin=true`。 + +## BugCollect 产物契约 + +构建工具必须携带: + +```http +Authorization: Bearer +``` + +Token 复用租户平台 API Key 权威校验,不存在 BugCollect 私有密钥。认证得到的 appKey 必须和上传表单中的 appKey 相同。 +该产物端点只接受 Bearer 形式,单独传 `X-API-Key` 必须返回 401;通用业务接口原有的 +`X-API-Key` 过滤行为不变。微服务向租户服务校验 Token 时使用受内部令牌保护的 +`POST /api/internal/sdk/validate-api-key` JSON 请求体,严禁把 Token 放入 URL、查询参数或日志。 + +共同字段: + +- `artifactType` +- `appKey` +- `platform` +- `appVersion` +- `buildId` +- `file` + +`RN_SOURCEMAP` 额外要求 `moduleId`、`moduleVersion`、`bundleHash`,以完整七元组精确匹配,不允许“取最新”回退。source map 不允许内嵌 `sourcesContent`,`sources` 不允许绝对路径、Windows 盘符或 `..` 穿越。 + +`R8_MAPPING` 仅支持 Android,文件名必须为 `mapping.txt`,并要求 `artifactHash` 与文件 SHA-256 一致。R8 与 RN 产物独立保存和查询。 + +运行时 `/issues/batch`、`/events/batch` 不使用 CI API Token;它们继续遵循客户端签名与服务开关策略。 + +## 数据库迁移 + +- tenant-service:V3 Config V2 元数据、V4 平台专属 App 包名、V5 移除离线 license 服务数据和枚举值。 +- xuqm-bugcollect-service:V9 引入精确 bundle 身份和独立产物字段。 + +执行迁移前必须备份对应数据库。不得修改已在目标环境执行过的历史迁移文件。 + +## Web 与 Jenkins + +- 应用详情页展示 Config 签发元数据,并支持“长期有效”或选择 UTC 到期时间后重新生成。 +- 符号化产物页同时展示 RN sourcemap 和 R8 mapping。 +- Web 依赖管理统一使用仓库声明的 Yarn 1;不再保留不同步的 `package-lock.json`。 +- Server 与 tenant-web Jenkins 默认 `DEPLOY_ENV=development`,development 只验证,不推送、不部署、不提交版本号。 +- production 必须经过人工确认,并从 Jenkins Credentials 读取 Git 与 Config 签名凭据。 + +## 已执行验证 + +```bash +mvn -Dmaven.repo.local=/private/tmp/xuqm-m2 \ + -pl tenant-service,xuqm-bugcollect-service -am test +``` + +2026-07-26 结果:BUILD SUCCESS。common 共 3 项测试通过,tenant-service 共 3 项测试通过, +xuqm-bugcollect-service 共 7 项测试通过;覆盖 Config 向量、通用 X-API-Key 行为、 +受保护的内部 POST 请求体、更新配置失败安全缓存、BugCollect 固定错误、路径校验、 +R8 校验,以及产物 API Token 的无 Bearer、仅 X-API-Key、无效 Bearer、有效归属场景。 + +Server 全聚合 `mvn -DskipTests compile` 结果:BUILD SUCCESS,聚合构建中已不包含 license-service。 + +Web 验证命令: + +```bash +yarn install --frozen-lockfile +yarn build:tenant +yarn build:ops +yarn workspace @xuqm/docs-site build +``` + +2026-07-26 三项 Web 构建均通过。最终结果以当前任务交接记录为准。 + +## 尚未执行 + +- 未提交、未推送。 +- 未触发 Jenkins,未部署任何环境。 +- 未运行真实 MySQL 迁移。 +- Jenkins 中的 Git、Config 签名凭据以及生产主机运行环境变量必须由有权限的维护者配置后,才能进行生产发布。 diff --git a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java index 7ff038a..2281813 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java @@ -2,7 +2,6 @@ package com.xuqm.im.controller; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.model.ApiResponse; -import com.xuqm.common.security.LicenseFileCrypto; import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImAppSecretClient; import org.springframework.http.ResponseEntity; @@ -30,26 +29,18 @@ public class AuthController { @RequestParam(required = false) String appKey, @RequestParam String userId, @RequestParam String userSig, - @RequestParam String packageName, - @RequestParam(required = false) String licenseFile) { + @RequestParam String packageName) { if (userId == null || userId.isBlank()) throw new BusinessException(400, "userId 不能为空"); if (userSig == null || userSig.isBlank()) throw new BusinessException(400, "userSig 不能为空"); if (packageName == null || packageName.isBlank()) throw new BusinessException(400, "packageName 不能为空"); - String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile); + String resolvedAppKey = resolveAndValidate(appKey, packageName); ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig); return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin()))); } - private String resolveAndValidate(String appKey, String packageName, String licenseFile) { - if (hasText(licenseFile)) { - LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); - if (!payload.matchesPackageName(packageName)) { - throw new BusinessException(403, "包名与应用配置不匹配"); - } - return payload.appKey(); - } + private String resolveAndValidate(String appKey, String packageName) { if (!hasText(appKey)) { - throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + throw new BusinessException(400, "appKey 不能为空"); } ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey); boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName()); diff --git a/license-service/pom.xml b/license-service/pom.xml deleted file mode 100644 index c00203e..0000000 --- a/license-service/pom.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - 4.0.0 - - - com.xuqm - xuqmgroup-server-parent - 0.1.0-SNAPSHOT - ../pom.xml - - - license-service - license-service - Standalone PAD license server: device registration, token verification, company management - - - - com.xuqm - common - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-validation - - - io.jsonwebtoken - jjwt-api - - - io.jsonwebtoken - jjwt-impl - runtime - - - io.jsonwebtoken - jjwt-jackson - runtime - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - - - com.mysql - mysql-connector-j - runtime - - - org.flywaydb - flyway-core - - - org.flywaydb - flyway-mysql - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - diff --git a/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java b/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java deleted file mode 100644 index b225b7d..0000000 --- a/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.xuqm.license; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.scheduling.annotation.EnableAsync; - -@SpringBootApplication -@ComponentScan(basePackages = {"com.xuqm.license", "com.xuqm.common"}) -@EnableAsync -public class LicenseServiceApplication { - public static void main(String[] args) { - SpringApplication.run(LicenseServiceApplication.class, args); - } -} diff --git a/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java b/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java deleted file mode 100644 index 374dbfe..0000000 --- a/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.xuqm.license.config; - -import com.xuqm.common.security.JwtAuthFilter; -import com.xuqm.common.security.JwtUtil; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; - -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -public class SecurityConfig { - - private final JwtUtil jwtUtil; - - public SecurityConfig(JwtUtil jwtUtil) { - this.jwtUtil = jwtUtil; - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> {}) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/api/license/register", "/api/license/verify", "/api/license/app-info").permitAll() - .requestMatchers("/api/license/internal/**", "/actuator/health", "/actuator/info").permitAll() - .anyRequest().authenticated() - ) - .exceptionHandling(ex -> ex - .authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED)) - ) - .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); - return http.build(); - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowCredentials(true); - String deployMode = System.getenv("DEPLOYMENT_MODE"); - if ("PRIVATE".equalsIgnoreCase(deployMode)) { - config.setAllowedOriginPatterns(List.of("*")); - } else { - config.setAllowedOriginPatterns(List.of( - "http://localhost:*", - "http://127.0.0.1:*", - "http://*.xuqinmin.com", - "https://*.xuqinmin.com" - )); - } - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setExposedHeaders(List.of("Location")); - config.setMaxAge(3600L); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } -} diff --git a/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java b/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java deleted file mode 100644 index 1ae1156..0000000 --- a/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.xuqm.license.controller; - -import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.model.ApiResponse; -import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.security.authorization.AuthorizationDeniedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusiness(BusinessException ex, HttpServletRequest request) { - if (ex.getCode() >= 500) { - log.error("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage(), ex); - } else { - log.warn("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage()); - } - return ResponseEntity.status(resolveStatus(ex.getCode())) - .body(ApiResponse.error(ex.getCode(), ex.getMessage())); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { - String detail = ex.getBindingResult().getFieldErrors().stream() - .map(f -> f.getField() + ": " + f.getDefaultMessage()) - .reduce((a, b) -> a + "; " + b) - .orElse("参数错误"); - log.warn("[{}] {} validation failed: {}", request.getMethod(), request.getRequestURI(), detail); - return ResponseEntity.badRequest().body(ApiResponse.badRequest(detail)); - } - - @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity> handleMissingParam(MissingServletRequestParameterException ex, HttpServletRequest request) { - log.warn("[{}] {} missing param: {}", request.getMethod(), request.getRequestURI(), ex.getParameterName()); - return ResponseEntity.badRequest() - .body(ApiResponse.badRequest("缺少必填参数: " + ex.getParameterName())); - } - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) { - log.warn("[{}] {} illegal argument: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); - return ResponseEntity.badRequest().body(ApiResponse.badRequest(ex.getMessage() == null ? "参数错误" : ex.getMessage())); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleUnreadable(HttpMessageNotReadableException ex, HttpServletRequest request) { - log.warn("[{}] {} request body unreadable: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); - return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误")); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { - log.warn("[{}] {} method not supported: {}", request.getMethod(), request.getRequestURI(), ex.getMethod()); - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) - .body(ApiResponse.error(405, "请求方法不支持: " + ex.getMethod())); - } - - @ExceptionHandler(AuthorizationDeniedException.class) - public ResponseEntity> handleAuthorizationDenied(AuthorizationDeniedException ex, HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - String principal = auth == null ? "anonymous" : String.valueOf(auth.getPrincipal()); - String authorities = auth == null ? "[]" : auth.getAuthorities().toString(); - log.warn("[{}] {} authorization denied: principal={} authorities={} reason={}", - request.getMethod(), request.getRequestURI(), principal, authorities, ex.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(403, "权限不足")); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception ex, HttpServletRequest request) { - log.error("[{}] {} unhandled exception: {}", request.getMethod(), request.getRequestURI(), ex.getMessage(), ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(500, "服务器内部错误")); - } - - private HttpStatus resolveStatus(int code) { - return switch (code) { - case 400 -> HttpStatus.BAD_REQUEST; - case 401 -> HttpStatus.UNAUTHORIZED; - case 403 -> HttpStatus.FORBIDDEN; - case 404 -> HttpStatus.NOT_FOUND; - default -> HttpStatus.INTERNAL_SERVER_ERROR; - }; - } -} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java b/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java deleted file mode 100644 index 725b9a9..0000000 --- a/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.xuqm.license.controller; - -import com.xuqm.common.model.ApiResponse; -import com.xuqm.license.entity.AppLicenseEntity; -import com.xuqm.license.entity.DeviceEntity; -import com.xuqm.license.service.AppLicenseService; -import com.xuqm.license.service.DeviceService; -import com.xuqm.license.service.LicenseOperationLogService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/license/admin") -public class LicenseAdminController { - - private final AppLicenseService appLicenseService; - private final DeviceService deviceService; - private final LicenseOperationLogService opLogService; - - public LicenseAdminController(AppLicenseService appLicenseService, DeviceService deviceService, - LicenseOperationLogService opLogService) { - this.appLicenseService = appLicenseService; - this.deviceService = deviceService; - this.opLogService = opLogService; - } - - @GetMapping("/apps/{appKey}") - public ResponseEntity>> getAppLicense(@PathVariable String appKey) { - AppLicenseEntity license = appLicenseService.getByAppKey(appKey); - List devices = deviceService.listByApp(appKey); - Map data = new java.util.LinkedHashMap<>(); - data.put("license", license); - data.put("devices", devices); - return ResponseEntity.ok(ApiResponse.success(data)); - } - - @PatchMapping("/apps/{appKey}") - public ResponseEntity> updateAppLicense( - @PathVariable String appKey, - @RequestBody UpdateAppLicenseRequest req) { - if (req.maxDevices() != null && req.maxDevices() < 1) { - throw new com.xuqm.common.exception.BusinessException(400, "最大设备数必须大于0"); - } - java.time.LocalDateTime newExpiresAt = null; - boolean clearExpiresAt = false; - if (req.expiresAt() != null) { - if (req.expiresAt().isEmpty()) { - clearExpiresAt = true; - } else { - newExpiresAt = java.time.LocalDateTime.parse(req.expiresAt(), - java.time.format.DateTimeFormatter.ISO_DATE_TIME); - } - } - AppLicenseEntity updated = appLicenseService.update( - appKey, null, req.maxDevices(), newExpiresAt, clearExpiresAt, req.isActive(), req.remark(), null, null, null); - opLogService.record(appKey, "UPDATE_LICENSE", "LICENSE", appKey, "更新 " + appKey + " 的授权配置"); - return ResponseEntity.ok(ApiResponse.success(updated)); - } - - @DeleteMapping("/devices/{id}") - public ResponseEntity> revokeDevice(@PathVariable String id) { - deviceService.revoke(id); - opLogService.record(null, "REVOKE_DEVICE", "DEVICE", id, "吊销设备 " + id + " 的授权"); - return ResponseEntity.ok(ApiResponse.ok()); - } - - @PutMapping("/devices/{id}/reactivate") - public ResponseEntity> reactivateDevice(@PathVariable String id) { - deviceService.reactivate(id); - opLogService.record(null, "REACTIVATE_DEVICE", "DEVICE", id, "重新激活设备 " + id); - return ResponseEntity.ok(ApiResponse.ok()); - } - - public record UpdateAppLicenseRequest( - Integer maxDevices, - Boolean isActive, - String remark, - String expiresAt - ) {} - -} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java b/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java deleted file mode 100644 index bbcf76e..0000000 --- a/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.xuqm.license.controller; - -import com.xuqm.common.model.ApiResponse; -import com.xuqm.license.entity.AppLicenseEntity; -import com.xuqm.license.entity.DeviceEntity; -import com.xuqm.license.service.AppLicenseService; -import com.xuqm.license.service.DeviceService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/license/internal") -public class LicenseInternalController { - - private final AppLicenseService appLicenseService; - private final DeviceService deviceService; - - @Value("${license.internal-token:xuqm-license-internal-token}") - private String internalToken; - - public LicenseInternalController(AppLicenseService appLicenseService, DeviceService deviceService) { - this.appLicenseService = appLicenseService; - this.deviceService = deviceService; - } - - @GetMapping("/apps/{appKey}/status") - public ResponseEntity>> getAppLicenseStatus( - @RequestHeader(value = "X-Internal-Token", required = false) String token, - @PathVariable String appKey) { - if (!isAllowed(token)) { - return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); - } - try { - AppLicenseEntity license = appLicenseService.getByAppKey(appKey); - Map data = new HashMap<>(); - data.put("exists", true); - data.put("active", appLicenseService.isValid(license)); - data.put("maxDevices", license.getMaxDevices()); - data.put("registeredDevices", license.getRegisteredDevices()); - data.put("expiresAt", license.getExpiresAt()); - return ResponseEntity.ok(ApiResponse.success(data)); - } catch (Exception e) { - return ResponseEntity.ok(ApiResponse.success(Map.of("exists", false))); - } - } - - @GetMapping("/apps/{appKey}/devices") - public ResponseEntity>> listDevicesByApp( - @RequestHeader(value = "X-Internal-Token", required = false) String token, - @PathVariable String appKey) { - if (!isAllowed(token)) { - return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); - } - return ResponseEntity.ok(ApiResponse.success(deviceService.listByApp(appKey))); - } - - @PostMapping("/apps") - public ResponseEntity> upsertAppLicense( - @RequestHeader(value = "X-Internal-Token", required = false) String token, - @RequestBody UpsertAppLicenseRequest req) { - if (!isAllowed(token)) { - return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); - } - AppLicenseEntity license = appLicenseService.upsert( - req.id(), - req.name(), - req.maxDevices(), - req.expiresAt(), - req.isActive(), - req.remark(), - req.androidPackageName(), - req.iosBundleId(), - req.harmonyBundleName()); - return ResponseEntity.ok(ApiResponse.success(license)); - } - - @GetMapping("/devices/{deviceId}") - public ResponseEntity>> getDevice( - @RequestHeader(value = "X-Internal-Token", required = false) String token, - @PathVariable String deviceId) { - if (!isAllowed(token)) { - return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); - } - List devices = deviceService.findByDeviceId(deviceId); - if (devices.isEmpty()) { - return ResponseEntity.ok(ApiResponse.error(404, "Device not found")); - } - return ResponseEntity.ok(ApiResponse.success(devices)); - } - - private boolean isAllowed(String token) { - return token != null && internalToken.equals(token); - } - - public record UpsertAppLicenseRequest( - String id, - String name, - Integer maxDevices, - LocalDateTime expiresAt, - Boolean isActive, - String remark, - String androidPackageName, - String iosBundleId, - String harmonyBundleName - ) {} -} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicenseOperationLogController.java b/license-service/src/main/java/com/xuqm/license/controller/LicenseOperationLogController.java deleted file mode 100644 index 113cfde..0000000 --- a/license-service/src/main/java/com/xuqm/license/controller/LicenseOperationLogController.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.xuqm.license.controller; - -import com.xuqm.common.model.ApiResponse; -import com.xuqm.license.entity.LicenseOperationLogEntity; -import com.xuqm.license.service.LicenseOperationLogService; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -@RestController -@RequestMapping("/api/license/admin/operation-logs") -public class LicenseOperationLogController { - - private final LicenseOperationLogService logService; - - public LicenseOperationLogController(LicenseOperationLogService logService) { - this.logService = logService; - } - - @GetMapping - public ResponseEntity>> list( - @RequestParam String appKey, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Page result = logService.list(appKey, page, size); - return ResponseEntity.ok(ApiResponse.success(Map.of( - "content", result.getContent(), - "total", result.getTotalElements(), - "totalPages", result.getTotalPages() - ))); - } -} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java b/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java deleted file mode 100644 index ce2bb70..0000000 --- a/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.xuqm.license.controller; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.model.ApiResponse; -import com.xuqm.common.security.LicenseFileCrypto; -import com.xuqm.license.entity.AppLicenseEntity; -import com.xuqm.license.service.AppLicenseService; -import com.xuqm.license.service.DeviceService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.LinkedHashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api/license") -public class LicensePublicController { - - private final DeviceService deviceService; - private final AppLicenseService appLicenseService; - - public LicensePublicController(DeviceService deviceService, AppLicenseService appLicenseService) { - this.deviceService = deviceService; - this.appLicenseService = appLicenseService; - } - - @PostMapping("/register") - public ResponseEntity>> register(@RequestBody RegisterRequest req) { - if (req.deviceId() == null || req.deviceId().isBlank()) { - throw new BusinessException(400, "deviceId 不能为空"); - } - String resolvedAppKey = resolveAppKey(req.appKey(), req.licenseFile()); - DeviceService.RegisterResult result = deviceService.register( - resolvedAppKey, - req.deviceId(), - req.deviceName(), - req.deviceModel(), - req.deviceVendor(), - req.osVersion(), - req.userInfo()); - Map data = new LinkedHashMap<>(); - data.put("success", result.success()); - data.put("token", result.token()); - if (result.message() != null) { - data.put("message", result.message()); - } - return ResponseEntity.ok(ApiResponse.success(data)); - } - - @PostMapping("/verify") - public ResponseEntity>> verify(@RequestBody VerifyRequest req) { - if (req.deviceId() == null || req.deviceId().isBlank()) { - throw new BusinessException(400, "deviceId 不能为空"); - } - if (req.token() == null || req.token().isBlank()) { - throw new BusinessException(400, "token 不能为空"); - } - String resolvedAppKey = resolveAppKey(req.appKey(), req.licenseFile()); - DeviceService.VerifyResult result = deviceService.verify(resolvedAppKey, req.deviceId(), req.token(), req.userInfo()); - Map data = new LinkedHashMap<>(); - data.put("valid", result.valid()); - if (result.error() != null) { - data.put("error", result.error()); - } - return ResponseEntity.ok(ApiResponse.success(data)); - } - - /** - * Returns configured package names for the given appKey. - * Used by the SDK (appKey-only flow) to validate the caller's package name client-side. - */ - @GetMapping("/app-info") - public ResponseEntity>> appInfo(@RequestParam String appKey) { - AppLicenseEntity license; - try { - license = appLicenseService.getByAppKey(appKey); - } catch (BusinessException e) { - throw new BusinessException(404, "App not found"); - } - Map data = new LinkedHashMap<>(); - data.put("androidPackageName", license.getAndroidPackageName()); - data.put("iosBundleId", license.getIosBundleId()); - data.put("harmonyBundleName", license.getHarmonyBundleName()); - return ResponseEntity.ok(ApiResponse.success(data)); - } - - private static String resolveAppKey(String appKey, String licenseFile) { - if (licenseFile != null && !licenseFile.isBlank()) { - LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); - return payload.appKey(); - } - if (appKey == null || appKey.isBlank()) { - throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); - } - return appKey; - } - - public record RegisterRequest( - String appKey, - @JsonProperty("packageName") @JsonAlias("package_name") String packageName, - @JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile, - @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, - @JsonProperty("deviceName") @JsonAlias("device_name") String deviceName, - @JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel, - @JsonProperty("deviceVendor") @JsonAlias("device_vendor") String deviceVendor, - @JsonProperty("osVersion") @JsonAlias("os_version") String osVersion, - @JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo - ) {} - - public record VerifyRequest( - String appKey, - @JsonProperty("packageName") @JsonAlias("package_name") String packageName, - @JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile, - @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, - String token, - @JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo - ) {} -} diff --git a/license-service/src/main/java/com/xuqm/license/entity/AppLicenseEntity.java b/license-service/src/main/java/com/xuqm/license/entity/AppLicenseEntity.java deleted file mode 100644 index 9284bc7..0000000 --- a/license-service/src/main/java/com/xuqm/license/entity/AppLicenseEntity.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.xuqm.license.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.LocalDateTime; - -@Entity -@Table(name = "app_licenses") -public class AppLicenseEntity { - - @Id - @Column(name = "app_key", length = 64) - private String appKey; - - @Column(nullable = false, length = 255) - private String name; - - @Column(name = "android_package_name", length = 128) - private String androidPackageName; - - @Column(name = "ios_bundle_id", length = 128) - private String iosBundleId; - - @Column(name = "harmony_bundle_name", length = 128) - private String harmonyBundleName; - - @Column(nullable = false, name = "max_devices") - private Integer maxDevices = 1; - - @Column(nullable = false, name = "registered_devices") - private Integer registeredDevices = 0; - - @Column(name = "expires_at") - private LocalDateTime expiresAt; - - @Column(nullable = false, name = "is_active") - private Boolean isActive = true; - - @Column(length = 500) - private String remark; - - @Column(nullable = false, name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @Column(nullable = false, name = "updated_at") - private LocalDateTime updatedAt; - - public String getAppKey() { return appKey; } - public void setAppKey(String appKey) { this.appKey = appKey; } - - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public String getAndroidPackageName() { return androidPackageName; } - public void setAndroidPackageName(String androidPackageName) { this.androidPackageName = androidPackageName; } - - public String getIosBundleId() { return iosBundleId; } - public void setIosBundleId(String iosBundleId) { this.iosBundleId = iosBundleId; } - - public String getHarmonyBundleName() { return harmonyBundleName; } - public void setHarmonyBundleName(String harmonyBundleName) { this.harmonyBundleName = harmonyBundleName; } - - public Integer getMaxDevices() { return maxDevices; } - public void setMaxDevices(Integer maxDevices) { this.maxDevices = maxDevices; } - - public Integer getRegisteredDevices() { return registeredDevices; } - public void setRegisteredDevices(Integer registeredDevices) { this.registeredDevices = registeredDevices; } - - public LocalDateTime getExpiresAt() { return expiresAt; } - public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } - - public Boolean getIsActive() { return isActive; } - public void setIsActive(Boolean isActive) { this.isActive = isActive; } - - public String getRemark() { return remark; } - public void setRemark(String remark) { this.remark = remark; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } -} diff --git a/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java b/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java deleted file mode 100644 index 7642868..0000000 --- a/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.xuqm.license.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import java.time.LocalDateTime; - -@Entity -@Table(name = "devices", - uniqueConstraints = @UniqueConstraint(name = "uk_app_key_device_id", columnNames = {"app_key", "device_id"})) -public class DeviceEntity { - - @Id - @Column(length = 36) - private String id; - - @Column(nullable = false, name = "app_key", length = 64) - private String appKey; - - @Column(nullable = false, name = "device_id", length = 255) - private String deviceId; - - @Column(name = "device_name", length = 255) - private String deviceName; - - @Column(name = "device_model", length = 255) - private String deviceModel; - - @Column(name = "device_vendor", length = 255) - private String deviceVendor; - - @Column(name = "os_version", length = 255) - private String osVersion; - - @Column(name = "user_id", length = 255) - private String userId; - - @Column(name = "user_name", length = 255) - private String userName; - - @Column(name = "user_email", length = 255) - private String userEmail; - - @Column(name = "user_phone", length = 64) - private String userPhone; - - @Column(name = "user_info_json", columnDefinition = "TEXT") - private String userInfoJson; - - @Column(nullable = false, name = "token_hash", length = 512) - private String tokenHash; - - @Column(nullable = false, name = "registered_at", updatable = false) - private LocalDateTime registeredAt; - - @Column(name = "last_verified_at") - private LocalDateTime lastVerifiedAt; - - @Column(nullable = false, name = "is_active") - private Boolean isActive = true; - - @Column(nullable = false, name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @Column(nullable = false, name = "updated_at") - private LocalDateTime updatedAt; - - public String getId() { return id; } - public void setId(String id) { this.id = id; } - - public String getAppKey() { return appKey; } - public void setAppKey(String appKey) { this.appKey = appKey; } - - public String getDeviceId() { return deviceId; } - public void setDeviceId(String deviceId) { this.deviceId = deviceId; } - - public String getDeviceName() { return deviceName; } - public void setDeviceName(String deviceName) { this.deviceName = deviceName; } - - public String getDeviceModel() { return deviceModel; } - public void setDeviceModel(String deviceModel) { this.deviceModel = deviceModel; } - - public String getDeviceVendor() { return deviceVendor; } - public void setDeviceVendor(String deviceVendor) { this.deviceVendor = deviceVendor; } - - public String getOsVersion() { return osVersion; } - public void setOsVersion(String osVersion) { this.osVersion = osVersion; } - - public String getUserId() { return userId; } - public void setUserId(String userId) { this.userId = userId; } - - public String getUserName() { return userName; } - public void setUserName(String userName) { this.userName = userName; } - - public String getUserEmail() { return userEmail; } - public void setUserEmail(String userEmail) { this.userEmail = userEmail; } - - public String getUserPhone() { return userPhone; } - public void setUserPhone(String userPhone) { this.userPhone = userPhone; } - - public String getUserInfoJson() { return userInfoJson; } - public void setUserInfoJson(String userInfoJson) { this.userInfoJson = userInfoJson; } - - public String getTokenHash() { return tokenHash; } - public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } - - public LocalDateTime getRegisteredAt() { return registeredAt; } - public void setRegisteredAt(LocalDateTime registeredAt) { this.registeredAt = registeredAt; } - - public LocalDateTime getLastVerifiedAt() { return lastVerifiedAt; } - public void setLastVerifiedAt(LocalDateTime lastVerifiedAt) { this.lastVerifiedAt = lastVerifiedAt; } - - public Boolean getIsActive() { return isActive; } - public void setIsActive(Boolean isActive) { this.isActive = isActive; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } -} diff --git a/license-service/src/main/java/com/xuqm/license/entity/LicenseOperationLogEntity.java b/license-service/src/main/java/com/xuqm/license/entity/LicenseOperationLogEntity.java deleted file mode 100644 index f15dd1a..0000000 --- a/license-service/src/main/java/com/xuqm/license/entity/LicenseOperationLogEntity.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.xuqm.license.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.Table; -import java.time.LocalDateTime; - -@Entity -@Table(name = "license_operation_log", indexes = { - @Index(name = "idx_license_op_log_app_time", columnList = "app_key,created_at") -}) -public class LicenseOperationLogEntity { - - @Id - private String id; - - @Column(nullable = false, length = 64) - private String appKey; - - @Column(nullable = false, length = 128) - private String operator; - - @Column(nullable = false, length = 64) - private String action; - - @Column(nullable = false, length = 64) - private String resourceType; - - @Column(length = 128) - private String resourceId; - - @Column(length = 255) - private String summary; - - @Column(columnDefinition = "TEXT") - private String detailJson; - - @Column(nullable = false) - private LocalDateTime createdAt; - - public String getId() { return id; } - public void setId(String id) { this.id = id; } - - public String getAppKey() { return appKey; } - public void setAppKey(String appKey) { this.appKey = appKey; } - - public String getOperator() { return operator; } - public void setOperator(String operator) { this.operator = operator; } - - public String getAction() { return action; } - public void setAction(String action) { this.action = action; } - - public String getResourceType() { return resourceType; } - public void setResourceType(String resourceType) { this.resourceType = resourceType; } - - public String getResourceId() { return resourceId; } - public void setResourceId(String resourceId) { this.resourceId = resourceId; } - - public String getSummary() { return summary; } - public void setSummary(String summary) { this.summary = summary; } - - public String getDetailJson() { return detailJson; } - public void setDetailJson(String detailJson) { this.detailJson = detailJson; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } -} diff --git a/license-service/src/main/java/com/xuqm/license/repository/AppLicenseRepository.java b/license-service/src/main/java/com/xuqm/license/repository/AppLicenseRepository.java deleted file mode 100644 index 95fec49..0000000 --- a/license-service/src/main/java/com/xuqm/license/repository/AppLicenseRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.xuqm.license.repository; - -import com.xuqm.license.entity.AppLicenseEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface AppLicenseRepository extends JpaRepository { -} diff --git a/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java b/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java deleted file mode 100644 index 0185d0b..0000000 --- a/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.xuqm.license.repository; - -import com.xuqm.license.entity.DeviceEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface DeviceRepository extends JpaRepository { - Optional findByAppKeyAndDeviceId(String appKey, String deviceId); - List findByDeviceId(String deviceId); - List findByAppKeyOrderByRegisteredAtDesc(String appKey); - long countByAppKeyAndIsActiveTrue(String appKey); -} diff --git a/license-service/src/main/java/com/xuqm/license/repository/LicenseOperationLogRepository.java b/license-service/src/main/java/com/xuqm/license/repository/LicenseOperationLogRepository.java deleted file mode 100644 index 8ef5216..0000000 --- a/license-service/src/main/java/com/xuqm/license/repository/LicenseOperationLogRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.xuqm.license.repository; - -import com.xuqm.license.entity.LicenseOperationLogEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface LicenseOperationLogRepository extends JpaRepository { - Page findByAppKeyOrderByCreatedAtDesc(String appKey, Pageable pageable); -} diff --git a/license-service/src/main/java/com/xuqm/license/service/AppLicenseService.java b/license-service/src/main/java/com/xuqm/license/service/AppLicenseService.java deleted file mode 100644 index 5eeee2f..0000000 --- a/license-service/src/main/java/com/xuqm/license/service/AppLicenseService.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.xuqm.license.service; - -import com.xuqm.common.exception.BusinessException; -import com.xuqm.license.entity.AppLicenseEntity; -import com.xuqm.license.repository.AppLicenseRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Service -public class AppLicenseService { - - private final AppLicenseRepository repository; - - public AppLicenseService(AppLicenseRepository repository) { - this.repository = repository; - } - - public AppLicenseEntity getByAppKey(String appKey) { - return repository.findById(appKey) - .orElseThrow(() -> new BusinessException(404, "App license not found")); - } - - @Transactional - public AppLicenseEntity upsert(String appKey, String name, Integer maxDevices, - LocalDateTime expiresAt, Boolean isActive, String remark, - String androidPackageName, String iosBundleId, String harmonyBundleName) { - return repository.findById(appKey) - .map(license -> update(appKey, name, maxDevices, expiresAt, false, isActive, remark, - androidPackageName, iosBundleId, harmonyBundleName)) - .orElseGet(() -> create(appKey, name, maxDevices, expiresAt, isActive, remark, - androidPackageName, iosBundleId, harmonyBundleName)); - } - - @Transactional - public AppLicenseEntity update(String appKey, String name, Integer maxDevices, - LocalDateTime expiresAt, boolean clearExpiresAt, Boolean isActive, String remark, - String androidPackageName, String iosBundleId, String harmonyBundleName) { - AppLicenseEntity license = getByAppKey(appKey); - if (name != null) license.setName(name); - if (maxDevices != null) license.setMaxDevices(maxDevices); - if (clearExpiresAt) { - license.setExpiresAt(null); - } else if (expiresAt != null) { - license.setExpiresAt(expiresAt); - } - if (isActive != null) license.setIsActive(isActive); - if (remark != null) license.setRemark(remark); - if (androidPackageName != null) license.setAndroidPackageName(androidPackageName); - if (iosBundleId != null) license.setIosBundleId(iosBundleId); - if (harmonyBundleName != null) license.setHarmonyBundleName(harmonyBundleName); - license.setUpdatedAt(LocalDateTime.now()); - return repository.save(license); - } - - @Transactional - public void incrementRegisteredDevices(String appKey) { - AppLicenseEntity license = getByAppKey(appKey); - license.setRegisteredDevices(license.getRegisteredDevices() + 1); - license.setUpdatedAt(LocalDateTime.now()); - repository.save(license); - } - - @Transactional - public void decrementRegisteredDevices(String appKey) { - AppLicenseEntity license = getByAppKey(appKey); - if (license.getRegisteredDevices() > 0) { - license.setRegisteredDevices(license.getRegisteredDevices() - 1); - license.setUpdatedAt(LocalDateTime.now()); - repository.save(license); - } - } - - public boolean isValid(AppLicenseEntity license) { - if (license == null || !Boolean.TRUE.equals(license.getIsActive())) { - return false; - } - return license.getExpiresAt() == null || !LocalDateTime.now().isAfter(license.getExpiresAt()); - } - - private AppLicenseEntity create(String appKey, String name, Integer maxDevices, - LocalDateTime expiresAt, Boolean isActive, String remark, - String androidPackageName, String iosBundleId, String harmonyBundleName) { - AppLicenseEntity license = new AppLicenseEntity(); - license.setAppKey(appKey); - license.setName(name); - license.setMaxDevices(maxDevices != null ? maxDevices : 1); - license.setRegisteredDevices(0); - license.setExpiresAt(expiresAt); - license.setIsActive(isActive != null ? isActive : true); - license.setRemark(remark); - license.setAndroidPackageName(androidPackageName); - license.setIosBundleId(iosBundleId); - license.setHarmonyBundleName(harmonyBundleName); - license.setCreatedAt(LocalDateTime.now()); - license.setUpdatedAt(LocalDateTime.now()); - return repository.save(license); - } -} diff --git a/license-service/src/main/java/com/xuqm/license/service/DeviceService.java b/license-service/src/main/java/com/xuqm/license/service/DeviceService.java deleted file mode 100644 index c99f426..0000000 --- a/license-service/src/main/java/com/xuqm/license/service/DeviceService.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.xuqm.license.service; - -import com.xuqm.common.exception.BusinessException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.xuqm.license.entity.AppLicenseEntity; -import com.xuqm.license.entity.DeviceEntity; -import com.xuqm.license.repository.DeviceRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.LocalDateTime; -import java.util.HexFormat; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -public class DeviceService { - - private final DeviceRepository deviceRepository; - private final AppLicenseService appLicenseService; - private final LicenseAuthService licenseAuthService; - private final ObjectMapper objectMapper; - - public DeviceService(DeviceRepository deviceRepository, AppLicenseService appLicenseService, - LicenseAuthService licenseAuthService, ObjectMapper objectMapper) { - this.deviceRepository = deviceRepository; - this.appLicenseService = appLicenseService; - this.licenseAuthService = licenseAuthService; - this.objectMapper = objectMapper; - } - - public List findByDeviceId(String deviceId) { - return deviceRepository.findByDeviceId(deviceId); - } - - public List listByApp(String appKey) { - return deviceRepository.findByAppKeyOrderByRegisteredAtDesc(appKey); - } - - @Transactional - public RegisterResult register(String appKey, String deviceId, String deviceName, - String deviceModel, String deviceVendor, String osVersion, - JsonNode userInfo) { - // Check if device already registered for this app - Optional existingOpt = deviceRepository.findByAppKeyAndDeviceId(appKey, deviceId); - if (existingOpt.isPresent()) { - DeviceEntity existing = existingOpt.get(); - if (!Boolean.TRUE.equals(existing.getIsActive())) { - throw new BusinessException(403, "Device has been deactivated"); - } - // Re-issue token - String token = licenseAuthService.generateToken(appKey, deviceId, existing.getId()); - String tokenHash = hashToken(token); - String oldAppKey = existing.getAppKey(); - if (oldAppKey == null || oldAppKey.isBlank()) { - existing.setAppKey(appKey); - } else if (!oldAppKey.equals(appKey)) { - // Device switched to a different app: update appKey and adjust counters - existing.setAppKey(appKey); - appLicenseService.decrementRegisteredDevices(oldAppKey); - appLicenseService.incrementRegisteredDevices(appKey); - } - existing.setDeviceName(firstNonBlank(deviceName, existing.getDeviceName())); - existing.setDeviceModel(firstNonBlank(deviceModel, existing.getDeviceModel())); - existing.setDeviceVendor(firstNonBlank(deviceVendor, existing.getDeviceVendor())); - existing.setOsVersion(firstNonBlank(osVersion, existing.getOsVersion())); - applyUserInfo(existing, userInfo); - existing.setTokenHash(tokenHash); - existing.setLastVerifiedAt(LocalDateTime.now()); - existing.setUpdatedAt(LocalDateTime.now()); - deviceRepository.save(existing); - return new RegisterResult(true, token, "Re-registered"); - } - - // Validate company - AppLicenseEntity license = appLicenseService.getByAppKey(appKey); - if (!appLicenseService.isValid(license)) { - throw new BusinessException(403, "App license is inactive or expired"); - } - if (license.getRegisteredDevices() >= license.getMaxDevices()) { - throw new BusinessException(403, "Device limit reached. Max allowed: " + license.getMaxDevices()); - } - - // Create device record - String recordId = UUID.randomUUID().toString(); - String token = licenseAuthService.generateToken(appKey, deviceId, recordId); - String tokenHash = hashToken(token); - - DeviceEntity device = new DeviceEntity(); - device.setId(recordId); - device.setAppKey(appKey); - device.setDeviceId(deviceId); - device.setDeviceName(deviceName); - device.setDeviceModel(deviceModel); - device.setDeviceVendor(deviceVendor); - device.setOsVersion(osVersion); - applyUserInfo(device, userInfo); - device.setTokenHash(tokenHash); - device.setRegisteredAt(LocalDateTime.now()); - device.setLastVerifiedAt(LocalDateTime.now()); - device.setIsActive(true); - device.setCreatedAt(LocalDateTime.now()); - device.setUpdatedAt(LocalDateTime.now()); - deviceRepository.save(device); - - appLicenseService.incrementRegisteredDevices(appKey); - - return new RegisterResult(true, token, null); - } - - @Transactional - public VerifyResult verify(String appKey, String deviceId, String token, JsonNode userInfo) { - if (!licenseAuthService.verifyTokenPayload(token, appKey, deviceId)) { - return new VerifyResult(false, "Token mismatch"); - } - - DeviceEntity device = deviceRepository.findByAppKeyAndDeviceId(appKey, deviceId).orElse(null); - if (device == null || !Boolean.TRUE.equals(device.getIsActive())) { - return new VerifyResult(false, "Device not found or deactivated"); - } - - if (!hashToken(token).equals(device.getTokenHash())) { - return new VerifyResult(false, "Token revoked"); - } - - AppLicenseEntity license = appLicenseService.getByAppKey(appKey); - if (!appLicenseService.isValid(license)) { - return new VerifyResult(false, "App license inactive or expired"); - } - - device.setLastVerifiedAt(LocalDateTime.now()); - applyUserInfo(device, userInfo); - device.setUpdatedAt(LocalDateTime.now()); - deviceRepository.save(device); - - return new VerifyResult(true, null); - } - - @Transactional - public void revoke(String deviceId) { - DeviceEntity device = deviceRepository.findById(deviceId) - .orElseThrow(() -> new BusinessException(404, "Device not found")); - device.setIsActive(false); - device.setUpdatedAt(LocalDateTime.now()); - deviceRepository.save(device); - appLicenseService.decrementRegisteredDevices(device.getAppKey()); - } - - @Transactional - public void reactivate(String deviceId) { - DeviceEntity device = deviceRepository.findById(deviceId) - .orElseThrow(() -> new BusinessException(404, "Device not found")); - device.setIsActive(true); - device.setUpdatedAt(LocalDateTime.now()); - deviceRepository.save(device); - appLicenseService.incrementRegisteredDevices(device.getAppKey()); - } - - private static boolean hasText(String s) { - return s != null && !s.isBlank(); - } - - public static String hashToken(String token) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(hash); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 not available", e); - } - } - - private static String firstNonBlank(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private void applyUserInfo(DeviceEntity device, JsonNode userInfo) { - if (userInfo == null || userInfo.isNull() || !userInfo.isObject()) { - return; - } - device.setUserId(firstNonBlank(text(userInfo, "userId"), device.getUserId())); - device.setUserName(firstNonBlank(text(userInfo, "name"), device.getUserName())); - device.setUserEmail(firstNonBlank(text(userInfo, "email"), device.getUserEmail())); - device.setUserPhone(firstNonBlank(text(userInfo, "phone"), device.getUserPhone())); - try { - device.setUserInfoJson(objectMapper.writeValueAsString(userInfo)); - } catch (Exception ignored) { - // Ignore malformed optional metadata and keep core license check available. - } - } - - private static String text(JsonNode node, String field) { - JsonNode value = node.get(field); - return value == null || value.isNull() ? null : value.asText(null); - } - - public record RegisterResult(boolean success, String token, String message) {} - public record VerifyResult(boolean valid, String error) {} -} diff --git a/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java b/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java deleted file mode 100644 index 9789646..0000000 --- a/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.xuqm.license.service; - -import com.xuqm.common.security.JwtUtil; -import org.springframework.stereotype.Service; - -import java.util.Map; - -@Service -public class LicenseAuthService { - - private final JwtUtil jwtUtil; - - public LicenseAuthService(JwtUtil jwtUtil) { - this.jwtUtil = jwtUtil; - } - - public String generateToken(String appKey, String deviceId, String recordId) { - return jwtUtil.generate(deviceId, Map.of( - "appKey", appKey, - "deviceId", deviceId, - "recordId", recordId - )); - } - - public boolean verifyTokenPayload(String token, String appKey, String deviceId) { - try { - var claims = jwtUtil.parse(token); - String claimAppKey = claims.get("appKey", String.class); - String claimDeviceId = firstNonNull(claims.get("deviceId", String.class), claims.get("device_id", String.class)); - return appKey.equals(claimAppKey) && deviceId.equals(claimDeviceId); - } catch (Exception e) { - return false; - } - } - - public String getRecordIdFromToken(String token) { - try { - var claims = jwtUtil.parse(token); - return firstNonNull(claims.get("recordId", String.class), claims.get("record_id", String.class)); - } catch (Exception e) { - return null; - } - } - - private static String firstNonNull(String value, String fallback) { - return value != null ? value : fallback; - } -} diff --git a/license-service/src/main/java/com/xuqm/license/service/LicenseOperationLogService.java b/license-service/src/main/java/com/xuqm/license/service/LicenseOperationLogService.java deleted file mode 100644 index ed533c2..0000000 --- a/license-service/src/main/java/com/xuqm/license/service/LicenseOperationLogService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.xuqm.license.service; - -import com.xuqm.license.entity.LicenseOperationLogEntity; -import com.xuqm.license.repository.LicenseOperationLogRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Service -public class LicenseOperationLogService { - - private final LicenseOperationLogRepository repository; - - public LicenseOperationLogService(LicenseOperationLogRepository repository) { - this.repository = repository; - } - - public void record(String appKey, String action, String resourceType, - String resourceId, String summary) { - LicenseOperationLogEntity entity = new LicenseOperationLogEntity(); - entity.setId(UUID.randomUUID().toString()); - entity.setAppKey(appKey); - entity.setOperator(currentOperator()); - entity.setAction(action); - entity.setResourceType(resourceType); - entity.setResourceId(resourceId); - entity.setSummary(summary); - entity.setCreatedAt(LocalDateTime.now()); - repository.save(entity); - } - - public Page list(String appKey, int page, int size) { - int safePage = Math.max(page, 0); - int safeSize = Math.min(Math.max(size, 1), 200); - return repository.findByAppKeyOrderByCreatedAtDesc(appKey, PageRequest.of(safePage, safeSize)); - } - - private String currentOperator() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || auth.getName() == null || auth.getName().isBlank()) { - return "system"; - } - if (auth.getDetails() instanceof io.jsonwebtoken.Claims claims) { - String nickname = claims.get("nickname", String.class); - if (nickname != null && !nickname.isBlank()) return nickname; - String username = claims.get("username", String.class); - if (username != null && !username.isBlank()) return username; - } - return auth.getName(); - } -} diff --git a/license-service/src/main/java/com/xuqm/license/service/LicenseSchemaMigrationService.java b/license-service/src/main/java/com/xuqm/license/service/LicenseSchemaMigrationService.java deleted file mode 100644 index 296a256..0000000 --- a/license-service/src/main/java/com/xuqm/license/service/LicenseSchemaMigrationService.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.xuqm.license.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import javax.sql.DataSource; -import java.sql.*; -import java.util.function.Consumer; - -@Service -public class LicenseSchemaMigrationService { - - private static final Logger log = LoggerFactory.getLogger(LicenseSchemaMigrationService.class); - - private final DataSource dataSource; - - public LicenseSchemaMigrationService(DataSource dataSource) { - this.dataSource = dataSource; - } - - @EventListener(ApplicationReadyEvent.class) - public void onStartup() { - runSchemaMigrations(msg -> log.info("[license-migration] {}", msg)); - } - - public void runSchemaMigrations(Consumer emit) { - emit.accept("检查数据库迁移..."); - try { - ensureMigrationsTable(); - } catch (Exception e) { - emit.accept("[警告] 无法初始化迁移记录表: " + e.getMessage()); - return; - } - - migrate_v20260527_create_license_operation_log(emit); - - emit.accept("数据库迁移检查完成"); - } - - private void ensureMigrationsTable() throws Exception { - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement()) { - stmt.execute(""" - CREATE TABLE IF NOT EXISTS _schema_migrations ( - id VARCHAR(128) NOT NULL PRIMARY KEY, - applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - description VARCHAR(255) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """); - } - } - - private boolean migrationApplied(String id) { - try (Connection conn = dataSource.getConnection(); - PreparedStatement ps = conn.prepareStatement( - "SELECT COUNT(*) FROM _schema_migrations WHERE id = ?")) { - ps.setString(1, id); - try (ResultSet rs = ps.executeQuery()) { - return rs.next() && rs.getInt(1) > 0; - } - } catch (Exception e) { - log.warn("check migration {} failed: {}", id, e.getMessage()); - return false; - } - } - - private void recordMigration(String id, String description) { - try (Connection conn = dataSource.getConnection(); - PreparedStatement ps = conn.prepareStatement( - "INSERT IGNORE INTO _schema_migrations (id, description) VALUES (?, ?)")) { - ps.setString(1, id); - ps.setString(2, description); - ps.executeUpdate(); - } catch (Exception e) { - log.warn("record migration {} failed: {}", id, e.getMessage()); - } - } - - // ── 各版本迁移 ────────────────────────────────────────────────────────────── - - private void migrate_v20260527_create_license_operation_log(Consumer emit) { - final String id = "v20260527_create_license_operation_log"; - if (migrationApplied(id)) { - emit.accept("[已应用] " + id); - return; - } - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement()) { - stmt.execute(""" - CREATE TABLE IF NOT EXISTS license_operation_log ( - id VARCHAR(36) NOT NULL PRIMARY KEY, - app_key VARCHAR(64) NOT NULL, - operator VARCHAR(128) NOT NULL, - action VARCHAR(64) NOT NULL, - resource_type VARCHAR(64) NOT NULL, - resource_id VARCHAR(128), - summary VARCHAR(255), - detail_json TEXT, - created_at DATETIME NOT NULL, - INDEX idx_license_op_log_app_time (app_key, created_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """); - emit.accept("[已迁移] " + id + ": 创建 license_operation_log 表"); - recordMigration(id, "创建授权操作日志表"); - } catch (Exception e) { - emit.accept("[错误] " + id + ": " + e.getMessage()); - log.error("migration {} failed", id, e); - } - } -} diff --git a/license-service/src/main/resources/application.yml b/license-service/src/main/resources/application.yml deleted file mode 100644 index 9a4fbf6..0000000 --- a/license-service/src/main/resources/application.yml +++ /dev/null @@ -1,42 +0,0 @@ -server: - port: 8085 - -spring: - application: - name: license-service - datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://39.107.53.187:3306/pad_license?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true} - username: ${SPRING_DATASOURCE_USERNAME:xuqm} - password: ${SPRING_DATASOURCE_PASSWORD:Xuqm@2026} - driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - minimum-idle: 5 - maximum-pool-size: 20 - connection-timeout: 30000 - idle-timeout: 300000 - max-lifetime: 900000 - jpa: - hibernate: - ddl-auto: validate - show-sql: false - properties: - hibernate: - dialect: org.hibernate.dialect.MySQLDialect - jackson: - time-zone: UTC - serialization: - write-dates-as-timestamps: false - flyway: - enabled: true - baseline-on-migrate: true - baseline-version: 0 - validate-on-migrate: false - locations: classpath:db/migration - table: flyway_history_license - -jwt: - secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} - expiration: 3153600000000 - -license: - internal-token: ${LICENSE_INTERNAL_TOKEN:xuqm-license-internal-token} diff --git a/license-service/src/main/resources/db/migration/V1__init.sql b/license-service/src/main/resources/db/migration/V1__init.sql deleted file mode 100644 index fe2cd37..0000000 --- a/license-service/src/main/resources/db/migration/V1__init.sql +++ /dev/null @@ -1,49 +0,0 @@ -CREATE TABLE IF NOT EXISTS app_licenses ( - app_key VARCHAR(64) NOT NULL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - android_package_name VARCHAR(128), - ios_bundle_id VARCHAR(128), - harmony_bundle_name VARCHAR(128), - max_devices INT NOT NULL DEFAULT 1, - registered_devices INT NOT NULL DEFAULT 0, - expires_at DATETIME(6), - is_active BIT(1) NOT NULL DEFAULT 1, - remark VARCHAR(500), - created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS devices ( - id VARCHAR(36) NOT NULL PRIMARY KEY, - app_key VARCHAR(64) NOT NULL, - device_id VARCHAR(255) NOT NULL, - device_name VARCHAR(255), - device_model VARCHAR(255), - device_vendor VARCHAR(255), - os_version VARCHAR(255), - user_id VARCHAR(255), - user_name VARCHAR(255), - user_email VARCHAR(255), - user_phone VARCHAR(64), - user_info_json TEXT, - token_hash VARCHAR(512) NOT NULL, - registered_at DATETIME(6) NOT NULL, - last_verified_at DATETIME(6), - is_active BIT(1) NOT NULL DEFAULT 1, - created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL, - UNIQUE KEY uk_app_key_device_id (app_key, device_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS license_operation_log ( - id VARCHAR(255) NOT NULL PRIMARY KEY, - app_key VARCHAR(64) NOT NULL, - operator VARCHAR(128) NOT NULL, - action VARCHAR(64) NOT NULL, - resource_type VARCHAR(64) NOT NULL, - resource_id VARCHAR(128), - summary VARCHAR(255), - detail_json TEXT, - created_at DATETIME(6) NOT NULL, - INDEX idx_license_op_log_app_time (app_key, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/pom.xml b/pom.xml index 17d4265..7fb0299 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,6 @@ update-service demo-service file-service - license-service xuqm-bugcollect-service diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java b/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java index 69e4713..b344d06 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java @@ -2,7 +2,6 @@ package com.xuqm.push.controller; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.model.ApiResponse; -import com.xuqm.common.security.LicenseFileCrypto; import com.xuqm.push.entity.DeviceTokenEntity; import com.xuqm.push.service.PushAccountService; import com.xuqm.push.service.PushAppSecretClient; @@ -36,7 +35,6 @@ public class PushAuthController { @RequestParam String userId, @RequestParam String userSig, @RequestParam String packageName, - @RequestParam(required = false) String licenseFile, @RequestParam DeviceTokenEntity.Vendor vendor, @RequestParam String token, @RequestParam(required = false) String platform, @@ -47,7 +45,7 @@ public class PushAuthController { @RequestParam(required = false) String romVersion, @RequestParam(required = false) String appVersion) { - String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile); + String resolvedAppKey = resolveAndValidate(appKey, packageName); PushAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig); pushDispatcher.registerToken(resolvedAppKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion, romVersion); @@ -60,16 +58,9 @@ public class PushAuthController { return ResponseEntity.ok(ApiResponse.success(response)); } - private String resolveAndValidate(String appKey, String packageName, String licenseFile) { - if (hasText(licenseFile)) { - LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); - if (!payload.matchesPackageName(packageName)) { - throw new BusinessException(403, "包名与应用配置不匹配"); - } - return payload.appKey(); - } + private String resolveAndValidate(String appKey, String packageName) { if (!hasText(appKey)) { - throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + throw new BusinessException(400, "appKey 不能为空"); } if (packageName == null || packageName.isBlank()) { throw new BusinessException(403, "packageName is required"); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java index dccc3f8..955dba5 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java @@ -13,7 +13,6 @@ public class PrivateDeploymentProperties { private boolean enableIm = false; private boolean enablePush = false; private boolean enableUpdate = false; - private boolean enableLicense = false; private boolean enableFile = true; public boolean isPrivate() { @@ -42,9 +41,6 @@ public class PrivateDeploymentProperties { public boolean isEnableUpdate() { return enableUpdate; } public void setEnableUpdate(boolean enableUpdate) { this.enableUpdate = enableUpdate; } - public boolean isEnableLicense() { return enableLicense; } - public void setEnableLicense(boolean enableLicense) { this.enableLicense = enableLicense; } - public boolean isEnableFile() { return enableFile; } public void setEnableFile(boolean enableFile) { this.enableFile = enableFile; } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index bfb528e..dc4ce94 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -10,6 +10,7 @@ import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.AppUserClient; +import com.xuqm.tenant.service.ConfigFileSigningService; import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.OperationLogService; @@ -41,18 +42,21 @@ public class AppController { private final TenantRepository tenantRepository; private final FeatureServiceManager featureServiceManager; private final AppUserClient appUserClient; + private final ConfigFileSigningService configFileSigningService; public AppController(AppService appService, EmailService emailService, OperationLogService operationLogService, TenantRepository tenantRepository, FeatureServiceManager featureServiceManager, - AppUserClient appUserClient) { + AppUserClient appUserClient, + ConfigFileSigningService configFileSigningService) { this.appService = appService; this.emailService = emailService; this.operationLogService = operationLogService; this.tenantRepository = tenantRepository; this.featureServiceManager = featureServiceManager; this.appUserClient = appUserClient; + this.configFileSigningService = configFileSigningService; } @GetMapping @@ -192,10 +196,21 @@ public class AppController { } @PostMapping("/{appKey}/config-file/regenerate") - public ResponseEntity> regenerateConfigFile(@PathVariable String appKey, + public ResponseEntity>> regenerateConfigFile(@PathVariable String appKey, + @RequestBody(required = false) RegenerateConfigRequest request, @AuthenticationPrincipal String tenantId) { - appService.regenerateConfigFile(appKey, tenantId); - return ResponseEntity.ok(ApiResponse.ok()); + java.time.Instant expiresAt = request == null || request.longTerm() + ? null + : request.expiresAt(); + appService.regenerateConfigFile(appKey, tenantId, expiresAt); + AppEntity app = appService.getByAppKey(appKey, tenantId); + Map data = new java.util.LinkedHashMap<>(); + data.put("configId", app.getConfigFileId()); + data.put("revision", app.getConfigFileRevision()); + data.put("issuedAt", app.getConfigFileIssuedAt()); + data.put("expiresAt", app.getConfigFileExpiresAt()); + data.put("longTerm", app.getConfigFileExpiresAt() == null); + return ResponseEntity.ok(ApiResponse.success(data)); } /** @@ -211,7 +226,7 @@ public class AppController { throw new BusinessException("Config file content is required"); } try { - ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decrypt(content.trim()); + ConfigFileCrypto.ConfigPayload payload = configFileSigningService.verify(content.trim()); // Verify the config file belongs to the current tenant try { appService.getByAppKey(payload.appKey(), tenantId); @@ -229,6 +244,12 @@ public class AppController { data.put("harmonyBundleName", payload.harmonyBundleName()); data.put("baseUrl", payload.baseUrl()); data.put("serverUrl", payload.serverUrl()); + data.put("configId", payload.configId()); + data.put("revision", payload.revision()); + data.put("issuedAt", payload.issuedAt()); + data.put("expiresAt", payload.expiresAt()); + data.put("longTerm", payload.expiresAt() == null); + data.put("format", ConfigFileCrypto.MAGIC); return ResponseEntity.ok(ApiResponse.success(data)); } catch (BusinessException e) { throw e; @@ -239,8 +260,11 @@ public class AppController { } } + public record RegenerateConfigRequest(boolean longTerm, java.time.Instant expiresAt) { + } + private static String sanitizeFileName(String value) { - String name = value == null || value.isBlank() ? "license" : value.trim(); + String name = value == null || value.isBlank() ? "xuqm-config" : value.trim(); return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 9bb4c49..bb66135 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -113,7 +113,6 @@ public class FeatureServiceController { platform, req == null ? null : req.pushConfig()); case FILE -> featureServiceManager.buildFileConfig(appKey, platform); - case LICENSE -> "{\"maxDevices\":1}"; case BUG_COLLECT -> "{}"; }; FeatureServiceEntity saved = featureServiceManager.updateConfig( diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java index cbf12e3..ffa06ca 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java @@ -10,6 +10,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; 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.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -88,24 +90,27 @@ public class InternalSdkController { /** * 内部 API:验证 API Key 有效性。 - * 供其他微服务(update/push/im/license)调用,验证请求中的 X-API-Key。 + * 供其他微服务(update/push/im)调用,验证请求中的 X-API-Key。 * - * @param apiKey 待验证的 API Key + * API Key 放在受保护的请求体中,禁止进入 URL、代理访问日志或异常 URL。 * @return 有效的 appKey,或 401 */ - @GetMapping("/validate-api-key") + @PostMapping("/validate-api-key") public ResponseEntity>> validateApiKey( - @RequestParam String apiKey, + @RequestBody ApiKeyValidationRequest request, @RequestHeader(value = "X-Internal-Token", required = false) String token) { if (token == null || !internalToken.equals(token)) { return ResponseEntity.status(403) .body(ApiResponse.error(403, "Forbidden")); } - String appKey = apiKeyService.validateApiKey(apiKey); + String appKey = apiKeyService.validateApiKey(request == null ? null : request.apiKey()); if (appKey == null) { return ResponseEntity.status(401) .body(ApiResponse.error(401, "Invalid or disabled API Key")); } return ResponseEntity.ok(ApiResponse.success(Map.of("appKey", appKey))); } + + public record ApiKeyValidationRequest(String apiKey) { + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java index 48ca904..7dc3cdd 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java @@ -53,9 +53,6 @@ public class PrivateDeploymentController { @Value("${deployment.update-domain:}") private String updateDomain; - @Value("${deployment.license-domain:}") - private String licenseDomain; - public PrivateDeploymentController(PrivateDeploymentProperties deployProps, TenantRepository tenantRepository, AppRepository appRepository, @@ -74,7 +71,6 @@ public class PrivateDeploymentController { services.put("im", serviceStatus(deployProps.isEnableIm(), imDomain)); services.put("push", serviceStatus(deployProps.isEnablePush(), pushDomain)); services.put("update", serviceStatus(deployProps.isEnableUpdate(), updateDomain)); - services.put("license", serviceStatus(deployProps.isEnableLicense(), licenseDomain)); Map body = new LinkedHashMap<>(); body.put("mode", deployProps.getMode()); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkBuildConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkBuildConfigController.java new file mode 100644 index 0000000..2c23cd9 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkBuildConfigController.java @@ -0,0 +1,35 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.service.AppService; +import org.springframework.http.ResponseEntity; +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; + +/** + * 构建工具专用接口。运行中的 App 不调用该接口,因此平台不可用或配置重新生成 + * 不会影响已经安装的应用。 + */ +@RestController +@RequestMapping("/api/sdk/build") +public class SdkBuildConfigController { + + private final AppService appService; + + public SdkBuildConfigController(AppService appService) { + this.appService = appService; + } + + @PostMapping("/config/validate") + public ResponseEntity> validate( + @RequestBody ValidateConfigRequest request) { + AppService.BuildConfigValidation validation = + appService.validateReleaseConfig(request.content(), request.packageName()); + return ResponseEntity.ok(ApiResponse.success(validation)); + } + + public record ValidateConfigRequest(String content, String packageName) { + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java index 335c12d..d7929ce 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -9,6 +9,7 @@ import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.repository.FeatureServiceRepository; import com.xuqm.tenant.service.SdkAppProvisioningService; +import com.xuqm.tenant.service.UpdatePublishConfigClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +28,7 @@ public class SdkConfigController { private final SdkAppProvisioningService sdkAppProvisioningService; private final ObjectMapper objectMapper; private final PrivateDeploymentProperties deployProps; + private final UpdatePublishConfigClient updatePublishConfigClient; @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") private String imWsUrl; @@ -43,11 +45,13 @@ public class SdkConfigController { public SdkConfigController(FeatureServiceRepository featureServiceRepository, SdkAppProvisioningService sdkAppProvisioningService, ObjectMapper objectMapper, - PrivateDeploymentProperties deployProps) { + PrivateDeploymentProperties deployProps, + UpdatePublishConfigClient updatePublishConfigClient) { this.featureServiceRepository = featureServiceRepository; this.sdkAppProvisioningService = sdkAppProvisioningService; this.objectMapper = objectMapper; this.deployProps = deployProps; + this.updatePublishConfigClient = updatePublishConfigClient; } /** @@ -104,11 +108,11 @@ public class SdkConfigController { .map(FeatureServiceEntity::isEnabled) .orElse(false); boolean bugCollectEnabled = featureServiceRepository - .findByAppKeyAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.BUG_COLLECT) - .stream() - .findFirst() + .findByAppKeyAndPlatformAndServiceType( + app.getAppKey(), platform, FeatureServiceEntity.ServiceType.BUG_COLLECT) .map(FeatureServiceEntity::isEnabled) .orElse(false); + boolean updateRequiresLogin = updatePublishConfigClient.requiresLogin(app.getAppKey()); SdkConfigResponse response = new SdkConfigResponse( imWsUrl, @@ -122,6 +126,7 @@ public class SdkConfigController { "bugCollect", bugCollectEnabled ), updateEnabled, + updateRequiresLogin, updateConfig.path("defaultPublishMode").asText("MANUAL"), updateConfig.path("defaultPublishImmediately").asBoolean(false), updateConfig.path("defaultScheduledPublishAt").asText(""), @@ -147,6 +152,7 @@ public class SdkConfigController { String bugCollectApiUrl, Map features, boolean updateEnabled, + boolean updateRequiresLogin, String updateDefaultPublishMode, boolean updateDefaultPublishImmediately, String updateDefaultScheduledPublishAt, diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java index c96660b..e7c4e2d 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java @@ -16,7 +16,7 @@ public class AppEntity { @Column(nullable = false, length = 64) private String tenantId; - @Column(nullable = false, length = 128) + @Column(length = 128) private String packageName; @Column(length = 128) @@ -46,6 +46,18 @@ public class AppEntity { @Column(name = "license_file_content", length = 4096) private String configFileContent; + @Column(name = "config_file_id", length = 64) + private String configFileId; + + @Column(name = "config_file_revision", nullable = false) + private long configFileRevision; + + @Column(name = "config_file_issued_at") + private LocalDateTime configFileIssuedAt; + + @Column(name = "config_file_expires_at") + private LocalDateTime configFileExpiresAt; + @Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0") private boolean isDefault; @@ -93,4 +105,16 @@ public class AppEntity { public String getConfigFileContent() { return configFileContent; } public void setConfigFileContent(String configFileContent) { this.configFileContent = configFileContent; } + + public String getConfigFileId() { return configFileId; } + public void setConfigFileId(String configFileId) { this.configFileId = configFileId; } + + public long getConfigFileRevision() { return configFileRevision; } + public void setConfigFileRevision(long configFileRevision) { this.configFileRevision = configFileRevision; } + + public LocalDateTime getConfigFileIssuedAt() { return configFileIssuedAt; } + public void setConfigFileIssuedAt(LocalDateTime configFileIssuedAt) { this.configFileIssuedAt = configFileIssuedAt; } + + public LocalDateTime getConfigFileExpiresAt() { return configFileExpiresAt; } + public void setConfigFileExpiresAt(LocalDateTime configFileExpiresAt) { this.configFileExpiresAt = configFileExpiresAt; } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java index bd1c880..3a37efb 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -20,7 +20,7 @@ public class FeatureServiceEntity { private static final SecureRandom RANDOM = new SecureRandom(); public enum Platform { ANDROID, IOS, HARMONY } - public enum ServiceType { IM, PUSH, UPDATE, FILE, LICENSE, BUG_COLLECT } + public enum ServiceType { IM, PUSH, UPDATE, FILE, BUG_COLLECT } @Id private String id; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index b167103..c621411 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -10,16 +10,22 @@ import com.xuqm.tenant.repository.FeatureServiceRepository; import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; import java.util.UUID; @Service @@ -30,26 +36,28 @@ public class AppService { private final FeatureServiceRepository featureServiceRepository; private final PrivateDeploymentProperties deployProps; private final TenantRepository tenantRepository; + private final ConfigFileSigningService configFileSigningService; private static final SecureRandom random = new SecureRandom(); - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); - @Value("${license.public-base-url:https://auth.dev.xuqinmin.com/}") - private String licensePublicBaseUrl; - - /** SDK 平台 API 公开地址,SDK 用此地址拉取 /api/sdk/config。私有化部署时与 licensePublicBaseUrl 相同,公有环境需单独配置。 */ - @Value("${sdk.platform-public-base-url:${license.public-base-url:https://auth.dev.xuqinmin.com/}}") + /** SDK 平台 API 公开地址,SDK 用此地址拉取 /api/sdk/config。 */ + @Value("${sdk.platform-public-base-url:https://dev.xuqinmin.com/}") private String sdkPlatformPublicBaseUrl; public AppService(AppRepository appRepository, OperationLogService operationLogService, FeatureServiceRepository featureServiceRepository, PrivateDeploymentProperties deployProps, - TenantRepository tenantRepository) { + TenantRepository tenantRepository, + ConfigFileSigningService configFileSigningService) { this.appRepository = appRepository; this.operationLogService = operationLogService; this.featureServiceRepository = featureServiceRepository; this.deployProps = deployProps; this.tenantRepository = tenantRepository; + this.configFileSigningService = configFileSigningService; } public List listByTenant(String tenantId) { @@ -68,7 +76,8 @@ public class AppService { } public AppEntity create(String tenantId, CreateAppRequest req) { - if (appRepository.existsByPackageNameAndTenantId(req.packageName(), tenantId)) { + if (hasText(req.packageName()) + && appRepository.existsByPackageNameAndTenantId(req.packageName(), tenantId)) { throw new BusinessException("该包名下已存在同名应用"); } AppEntity app = new AppEntity(); @@ -83,12 +92,16 @@ public class AppService { app.setAppKey(generateAppKey()); app.setAppSecret(generateSecret()); app.setCreatedAt(LocalDateTime.now()); - app.setConfigFileContent(generateConfigFileContent(app)); + issueConfigFile(app, null); AppEntity saved = appRepository.save(app); autoEnableFileService(saved.getAppKey()); + Map createdDetail = new LinkedHashMap<>(); + createdDetail.put("name", saved.getName()); + createdDetail.put("packageName", saved.getPackageName()); + createdDetail.put("appKey", saved.getAppKey()); operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", "创建应用「" + saved.getName() + "」(" + saved.getPackageName() + ")", - Map.of("name", saved.getName(), "packageName", saved.getPackageName(), "appKey", saved.getAppKey())); + createdDetail); return saved; } @@ -96,39 +109,56 @@ public class AppService { public String ensureConfigFile(String appKey, String tenantId) { AppEntity app = getByAppKey(appKey, tenantId); String content = app.getConfigFileContent(); - if (content == null || content.isBlank()) { - content = generateConfigFileContent(app); - app.setConfigFileContent(content); + if (needsV2Config(app)) { + issueConfigFile(app, app.getConfigFileExpiresAt()); appRepository.save(app); + content = app.getConfigFileContent(); } return content; } @Transactional - public String regenerateConfigFile(String appKey, String tenantId) { + public String regenerateConfigFile(String appKey, String tenantId, Instant expiresAt) { AppEntity app = getByAppKey(appKey, tenantId); - String content = generateConfigFileContent(app); - app.setConfigFileContent(content); + if (expiresAt != null && !expiresAt.isAfter(Instant.now())) { + throw new BusinessException("配置有效期必须晚于当前时间"); + } + LocalDateTime storedExpiresAt = expiresAt == null + ? null + : LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC); + issueConfigFile(app, storedExpiresAt); appRepository.save(app); operationLogService.record(tenantId, "APP", "APP", appKey, "REGENERATE_CONFIG", "重新生成应用「" + app.getName() + "」的 Config 文件", - Map.of("name", app.getName())); - return content; + Map.of( + "name", app.getName(), + "configId", app.getConfigFileId(), + "revision", app.getConfigFileRevision(), + "longTerm", storedExpiresAt == null)); + return app.getConfigFileContent(); } public AppEntity update(String appKey, String tenantId, CreateAppRequest req) { AppEntity app = getByAppKey(appKey, tenantId); + boolean configFieldsChanged = + !Objects.equals(app.getName(), req.name()) + || !Objects.equals(app.getPackageName(), req.packageName()) + || !Objects.equals(app.getIosBundleId(), req.iosBundleId()) + || !Objects.equals(app.getHarmonyBundleName(), req.harmonyBundleName()); Map before = new LinkedHashMap<>(); before.put("name", app.getName()); before.put("packageName", app.getPackageName()); before.put("description", app.getDescription()); before.put("iconUrl", app.getIconUrl()); + app.setPackageName(req.packageName()); app.setIosBundleId(req.iosBundleId()); app.setHarmonyBundleName(req.harmonyBundleName()); app.setName(req.name()); app.setDescription(req.description()); app.setIconUrl(req.iconUrl()); - app.setConfigFileContent(generateConfigFileContent(app)); + if (configFieldsChanged || needsV2Config(app)) { + issueConfigFile(app, app.getConfigFileExpiresAt()); + } AppEntity saved = appRepository.save(app); Map after = new LinkedHashMap<>(); after.put("name", saved.getName()); @@ -149,10 +179,12 @@ public class AppService { Map.of("name", app.getName(), "packageName", app.getPackageName(), "appKey", app.getAppKey())); } + @Transactional public String resetSecret(String appKey, String tenantId) { AppEntity app = getByAppKey(appKey, tenantId); String newSecret = generateSecret(); app.setAppSecret(newSecret); + issueConfigFile(app, app.getConfigFileExpiresAt()); appRepository.save(app); operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET", "重置应用「" + app.getName() + "」的 AppSecret", @@ -160,6 +192,52 @@ public class AppService { return newSecret; } + public ConfigFileCrypto.ConfigPayload parseConfigFile(String content) { + return configFileSigningService.verify(content); + } + + /** + * Release 构建专用校验。该检查只阻止新包使用旧配置,不参与已安装 App 的运行时初始化。 + */ + public BuildConfigValidation validateReleaseConfig(String content, String targetPackageName) { + final ConfigFileCrypto.ConfigPayload payload; + try { + payload = parseConfigFile(content); + } catch (Exception e) { + return BuildConfigValidation.invalid("CONFIG_INVALID", e.getMessage()); + } + AppEntity app = appRepository.findByAppKey(payload.appKey()).orElse(null); + if (app == null) { + return BuildConfigValidation.invalid("APP_NOT_FOUND", "配置对应的应用不存在"); + } + if (payload.isExpired(java.time.Instant.now())) { + return BuildConfigValidation.invalid("CONFIG_EXPIRED", "配置文件已过期"); + } + if (targetPackageName == null || targetPackageName.isBlank() + || !payload.matchesPackageName(targetPackageName.trim())) { + return BuildConfigValidation.invalid("PACKAGE_MISMATCH", "构建目标包名与配置不匹配"); + } + if (!java.util.Objects.equals(app.getConfigFileId(), payload.configId()) + || app.getConfigFileRevision() != payload.revision()) { + return BuildConfigValidation.invalid("CONFIG_REVOKED", "配置文件已被重新生成,请下载最新配置"); + } + return new BuildConfigValidation( + true, null, "配置有效", payload.configId(), payload.revision(), payload.expiresAt()); + } + + public record BuildConfigValidation( + boolean valid, + String errorCode, + String message, + String configId, + long revision, + java.time.Instant expiresAt) { + + static BuildConfigValidation invalid(String errorCode, String message) { + return new BuildConfigValidation(false, errorCode, message, null, 0, null); + } + } + private void autoEnableFileService(String appKey) { for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { FeatureServiceEntity entity = new FeatureServiceEntity(); @@ -183,11 +261,24 @@ public class AppService { return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } - private String generateConfigFileContent(AppEntity app) { - Map payload = new LinkedHashMap<>(); + private void issueConfigFile(AppEntity app, LocalDateTime expiresAt) { + Instant issuedAt = Instant.now(); + LocalDateTime storedIssuedAt = LocalDateTime.ofInstant(issuedAt, ZoneOffset.UTC); + String configId = UUID.randomUUID().toString(); + long revision = Math.max(0, app.getConfigFileRevision()) + 1; + Map payload = new TreeMap<>(); + payload.put("schemaVersion", 2); + payload.put("configId", configId); + payload.put("revision", revision); + payload.put("issuedAt", issuedAt.toString()); + if (expiresAt != null) { + payload.put("expiresAt", expiresAt.toInstant(ZoneOffset.UTC).toString()); + } payload.put("appKey", app.getAppKey()); payload.put("appName", app.getName()); - payload.put("packageName", app.getPackageName()); + if (hasText(app.getPackageName())) { + payload.put("packageName", app.getPackageName()); + } if (app.getIosBundleId() != null && !app.getIosBundleId().isBlank()) { payload.put("iosBundleId", app.getIosBundleId()); } @@ -196,14 +287,31 @@ public class AppService { } payload.put("serverUrl", normalizeBaseUrl(sdkPlatformPublicBaseUrl)); payload.put("signingKey", app.getAppSecret()); - payload.put("issuedAt", java.time.Instant.now().toString()); try { - return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString()); + String canonicalJson = MAPPER.writeValueAsString(payload); + app.setConfigFileContent(configFileSigningService.sign(canonicalJson)); + app.setConfigFileId(configId); + app.setConfigFileRevision(revision); + app.setConfigFileIssuedAt(storedIssuedAt); + app.setConfigFileExpiresAt(expiresAt); } catch (Exception e) { throw new IllegalStateException("Failed to generate config file", e); } } + private static boolean needsV2Config(AppEntity app) { + String content = app.getConfigFileContent(); + return content == null + || !content.startsWith(ConfigFileCrypto.MAGIC + ".") + || !hasText(app.getConfigFileId()) + || app.getConfigFileRevision() <= 0 + || app.getConfigFileIssuedAt() == null; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private static String normalizeBaseUrl(String value) { String baseUrl = value == null || value.isBlank() ? "https://auth.dev.xuqinmin.com/" : value.trim(); return baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java new file mode 100644 index 0000000..41797f9 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java @@ -0,0 +1,78 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.security.ConfigFileCrypto; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * 集中管理租户平台配置签名密钥。 + * + *

私钥必须通过服务端密钥管理或环境变量注入,禁止写入源码、配置文件或数据库。 + * 公钥用于平台自身解析与构建期校验,客户端 SDK 使用同一公钥验签。

+ */ +@Service +public class ConfigFileSigningService { + + private final String keyId; + private final PrivateKey privateKey; + private final PublicKey publicKey; + + public ConfigFileSigningService( + @Value("${sdk.config-signing.key-id:v2-primary}") String keyId, + @Value("${sdk.config-signing.private-key-base64:}") String privateKeyBase64, + @Value("${sdk.config-signing.public-key-base64:}") String publicKeyBase64) { + this.keyId = requireText(keyId, "SDK_CONFIG_SIGNING_KEY_ID"); + this.privateKey = readPrivateKey(privateKeyBase64); + this.publicKey = readPublicKey(publicKeyBase64); + } + + public String sign(String plainText) { + return ConfigFileCrypto.encryptAndSign(plainText, keyId, privateKey); + } + + public ConfigFileCrypto.ConfigPayload verify(String content) { + ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decryptAndVerify(content, publicKey); + if (!keyId.equals(payload.keyId())) { + throw new IllegalArgumentException("Config file signing key is no longer trusted"); + } + return payload; + } + + private static PrivateKey readPrivateKey(String encoded) { + try { + return KeyFactory.getInstance("Ed25519") + .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode( + requireText(encoded, "SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64")))); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Invalid SDK config signing private key", e); + } + } + + private static PublicKey readPublicKey(String encoded) { + try { + return KeyFactory.getInstance("Ed25519") + .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode( + requireText(encoded, "SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64")))); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Invalid SDK config signing public key", e); + } + } + + private static String requireText(String value, String environmentName) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(environmentName + " is required"); + } + return value.trim(); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index a347fb6..2ef4d05 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -33,7 +33,6 @@ public class FeatureServiceManager { private final ServiceActivationRequestRepository requestRepository; private final AppRepository appRepository; private final ObjectMapper objectMapper; - private final LicenseServiceClient licenseServiceClient; private final ImPlatformEventService imPlatformEventService; private final PrivateDeploymentProperties deploymentProperties; @@ -41,14 +40,12 @@ public class FeatureServiceManager { ServiceActivationRequestRepository requestRepository, AppRepository appRepository, ObjectMapper objectMapper, - LicenseServiceClient licenseServiceClient, ImPlatformEventService imPlatformEventService, PrivateDeploymentProperties deploymentProperties) { this.repository = repository; this.requestRepository = requestRepository; this.appRepository = appRepository; this.objectMapper = objectMapper; - this.licenseServiceClient = licenseServiceClient; this.imPlatformEventService = imPlatformEventService; this.deploymentProperties = deploymentProperties; } @@ -67,7 +64,6 @@ public class FeatureServiceManager { FeatureServiceEntity.ServiceType.PUSH, FeatureServiceEntity.ServiceType.UPDATE, FeatureServiceEntity.ServiceType.FILE, - FeatureServiceEntity.ServiceType.LICENSE, FeatureServiceEntity.ServiceType.BUG_COLLECT)) { for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { services.stream() @@ -84,7 +80,7 @@ public class FeatureServiceManager { /** * Submit an activation request. Disabling is immediate; enabling requires ops approval. - * IM / PUSH / UPDATE / FILE / LICENSE are app-wide, so duplicate checks ignore platform. + * 应用级服务的开通申请忽略平台,避免同一能力产生重复申请。 */ @Transactional public ServiceActivationRequestEntity submitActivationRequest( @@ -172,37 +168,20 @@ public class FeatureServiceManager { if (isAppWideService(req.getServiceType())) { List services = repository.findByAppKeyAndServiceType(normalizedAppId, req.getServiceType()); if (services.isEmpty()) { - if (req.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE) { - // LICENSE 不分平台,只创建一条记录 + for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { FeatureServiceEntity created = new FeatureServiceEntity(); created.setId(UUID.randomUUID().toString()); created.setAppKey(normalizedAppId); - created.setPlatform(FeatureServiceEntity.Platform.ANDROID); - created.setServiceType(FeatureServiceEntity.ServiceType.LICENSE); + created.setPlatform(platform); + created.setServiceType(req.getServiceType()); created.setEnabled(true); created.setCreatedAt(LocalDateTime.now()); repository.save(created); - } else { - for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { - FeatureServiceEntity created = new FeatureServiceEntity(); - created.setId(UUID.randomUUID().toString()); - created.setAppKey(normalizedAppId); - created.setPlatform(platform); - created.setServiceType(req.getServiceType()); - created.setEnabled(true); - created.setCreatedAt(LocalDateTime.now()); - repository.save(created); - } } } else { services.forEach(service -> service.setEnabled(true)); repository.saveAll(services); } - if (req.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE) { - appRepository.findByAppKey(normalizedAppId).ifPresent(app -> - licenseServiceClient.syncAppLicense(app.getAppKey(), app.getName(), 1, expiresAt, - app.getPackageName(), app.getIosBundleId(), app.getHarmonyBundleName())); - } sendActivationImNotification(req.getAppKey(), req.getServiceType().name(), "APPROVED", reviewNote); return req; } @@ -249,7 +228,6 @@ public class FeatureServiceManager { public FeatureServiceEntity getOrFail(String appKey, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { if (serviceType == FeatureServiceEntity.ServiceType.IM - || serviceType == FeatureServiceEntity.ServiceType.LICENSE || serviceType == FeatureServiceEntity.ServiceType.BUG_COLLECT) { return repository.findByAppKeyAndServiceType(appKey, serviceType) .stream() @@ -266,7 +244,6 @@ public class FeatureServiceManager { FeatureServiceEntity.ServiceType serviceType, String config) { if (serviceType == FeatureServiceEntity.ServiceType.IM - || serviceType == FeatureServiceEntity.ServiceType.LICENSE || serviceType == FeatureServiceEntity.ServiceType.BUG_COLLECT) { List services = repository.findByAppKeyAndServiceType(appKey, serviceType); if (services.isEmpty()) { @@ -628,7 +605,6 @@ public class FeatureServiceManager { || serviceType == FeatureServiceEntity.ServiceType.PUSH || serviceType == FeatureServiceEntity.ServiceType.UPDATE || serviceType == FeatureServiceEntity.ServiceType.FILE - || serviceType == FeatureServiceEntity.ServiceType.LICENSE || serviceType == FeatureServiceEntity.ServiceType.BUG_COLLECT; } @@ -637,7 +613,6 @@ public class FeatureServiceManager { FeatureServiceEntity.ServiceType serviceType) { FeatureServiceEntity entity; if (serviceType == FeatureServiceEntity.ServiceType.IM - || serviceType == FeatureServiceEntity.ServiceType.LICENSE || serviceType == FeatureServiceEntity.ServiceType.BUG_COLLECT) { entity = repository.findByAppKeyAndServiceType(appKey, serviceType) .stream() diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java deleted file mode 100644 index e0edea7..0000000 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.xuqm.tenant.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.List; -import java.util.Map; - -@Service -public class LicenseServiceClient { - - @Value("${license.service.base-url:http://license-service:8085}") - private String licenseBaseUrl; - - @Value("${license.internal-token:xuqm-license-internal-token}") - private String internalToken; - - private final RestTemplate restTemplate = new RestTemplate(); - private final ObjectMapper objectMapper = new ObjectMapper(); - - public boolean isAppLicenseExists(String appKey) { - try { - ResponseEntity response = callInternal("/api/license/internal/apps/" + appKey + "/status", HttpMethod.GET, null); - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - JsonNode node = objectMapper.readTree(response.getBody()); - return node.path("data").path("exists").asBoolean(false); - } - } catch (Exception e) { - // ignore - } - return false; - } - - public List> listDevices(String appKey) { - try { - ResponseEntity response = callInternal("/api/license/internal/apps/" + appKey + "/devices", HttpMethod.GET, null); - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - JsonNode node = objectMapper.readTree(response.getBody()); - return objectMapper.convertValue(node.path("data"), new com.fasterxml.jackson.core.type.TypeReference<>() {}); - } - } catch (Exception e) { - // ignore - } - return List.of(); - } - - public void syncAppLicense(String appKey, String name, Integer maxDevices) { - syncAppLicense(appKey, name, maxDevices, null, null, null, null); - } - - public void syncAppLicense(String appKey, String name, Integer maxDevices, String expiresAt) { - syncAppLicense(appKey, name, maxDevices, expiresAt, null, null, null); - } - - public void syncAppLicense(String appKey, String name, Integer maxDevices, String expiresAt, - String androidPackageName, String iosBundleId, String harmonyBundleName) { - try { - Map body = new java.util.HashMap<>(); - body.put("id", appKey); - body.put("name", name); - body.put("maxDevices", maxDevices != null ? maxDevices : 1); - if (expiresAt != null && !expiresAt.isBlank()) { - body.put("expiresAt", expiresAt); - } - if (androidPackageName != null) body.put("androidPackageName", androidPackageName); - if (iosBundleId != null) body.put("iosBundleId", iosBundleId); - if (harmonyBundleName != null) body.put("harmonyBundleName", harmonyBundleName); - callInternal("/api/license/internal/apps", HttpMethod.POST, body); - } catch (Exception e) { - // ignore - } - } - - private ResponseEntity callInternal(String path, HttpMethod method, Object body) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Internal-Token", internalToken); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity entity = new HttpEntity<>(body, headers); - return restTemplate.exchange(licenseBaseUrl + path, method, entity, String.class); - } -} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java index 7e0655e..31847cb 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java @@ -46,7 +46,7 @@ public class SystemUpdateService { // nginx 最后重启,确保它能获取到其他服务修复后的配置 private static final List OTHER_SERVICES = List.of( - "file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "bugcollect-service", "nginx" + "file-service", "tenant-web", "im-service", "push-service", "update-service", "bugcollect-service", "nginx" ); // 镜像名与 compose service 名不一致的例外(历史命名遗留) @@ -66,7 +66,7 @@ public class SystemUpdateService { private static final Set ALLOWED_LOG_SERVICES = Set.of( "tenant-service", "file-service", "im-service", "push-service", - "update-service", "license-service", "bugcollect-service", "nginx", "tenant-web" + "update-service", "bugcollect-service", "nginx", "tenant-web" ); @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") @@ -633,8 +633,6 @@ public class SystemUpdateService { return; } - migrate_v20260101_drop_device_id_unique_index(emit); - migrate_v20260527_push_license_operation_logs(emit); migrate_v20260527_fix_orphan_tenant_data(emit); migrate_v20260610_gray_mode_simplify_bookmark(emit); // 新版本迁移在此追加,例如: @@ -684,59 +682,6 @@ public class SystemUpdateService { // ── 各版本迁移 ────────────────────────────────────────────────────────────── - /** - * license-service DeviceEntity 上的 column-level unique=true 在多租户场景下产生了跨 appKey 的 - * 全局唯一约束,与正确的复合唯一索引 uk_app_key_device_id(app_key, device_id) 冲突。 - * Hibernate ddl-auto:update 不删除多余约束,必须手动 ALTER TABLE。 - * 根治方案:已同步移除 DeviceEntity.deviceId 上的 unique=true 注解,新安装不再产生该约束。 - */ - private void migrate_v20260101_drop_device_id_unique_index(Consumer emit) { - final String id = "v20260101_drop_device_id_unique_index"; - if (migrationApplied(id)) { - emit.accept(" [已应用] " + id); - return; - } - try (Connection conn = dataSource.getConnection()) { - boolean exists; - try (PreparedStatement ps = conn.prepareStatement(""" - SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'devices' - AND INDEX_NAME = 'device_id' - AND NON_UNIQUE = 0 - """); - ResultSet rs = ps.executeQuery()) { - exists = rs.next() && rs.getInt(1) > 0; - } - if (exists) { - try (Statement stmt = conn.createStatement()) { - stmt.execute("ALTER TABLE devices DROP INDEX device_id"); - } - emit.accept(" [已迁移] " + id + ": 删除 devices.device_id 旧单列唯一约束"); - } else { - emit.accept(" [已迁移] " + id + ": devices.device_id 单列约束不存在,无需处理"); - } - recordMigration(id, "删除 devices 表 device_id 旧单列唯一约束"); - } catch (Exception e) { - emit.accept(" [错误] " + id + ": " + e.getMessage()); - log.error("migration {} failed", id, e); - } - } - - /** - * push-service 和 license-service 新增操作日志表(push_operation_log / license_operation_log)。 - * 实际表结构由各服务的 SchemaMigrationService 在各自数据库中创建,此处仅记录版本标记。 - */ - private void migrate_v20260527_push_license_operation_logs(Consumer emit) { - final String id = "v20260527_push_license_operation_logs"; - if (migrationApplied(id)) { - emit.accept(" [已应用] " + id); - return; - } - emit.accept(" [已迁移] " + id + ": push/license 操作日志表已由各服务自行创建"); - recordMigration(id, "push-service 和 license-service 新增操作日志表"); - } - /** * 私有化部署下修复租户数据: * 1. 多个租户 → 保留最早的,删除其余 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/UpdatePublishConfigClient.java b/tenant-service/src/main/java/com/xuqm/tenant/service/UpdatePublishConfigClient.java new file mode 100644 index 0000000..b5648c4 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/UpdatePublishConfigClient.java @@ -0,0 +1,88 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 读取版本服务的权威发布配置。读取失败时默认要求登录,避免错误放宽灰度访问边界。 + */ +@Service +public class UpdatePublishConfigClient { + + private static final Logger log = LoggerFactory.getLogger(UpdatePublishConfigClient.class); + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String updateServiceBaseUrl; + private final Duration cacheTtl; + private final Map cache = new ConcurrentHashMap<>(); + + public UpdatePublishConfigClient( + ObjectMapper objectMapper, + RestTemplateBuilder restTemplateBuilder, + @Value("${sdk.update-service-base-url:http://update-service:8084}") String updateServiceBaseUrl, + @Value("${sdk.update-service-connect-timeout:500ms}") Duration connectTimeout, + @Value("${sdk.update-service-read-timeout:800ms}") Duration readTimeout, + @Value("${sdk.update-service-cache-ttl:15s}") Duration cacheTtl) { + this.objectMapper = objectMapper; + this.restTemplate = restTemplateBuilder + .connectTimeout(connectTimeout) + .readTimeout(readTimeout) + .build(); + this.updateServiceBaseUrl = updateServiceBaseUrl; + this.cacheTtl = cacheTtl; + } + + UpdatePublishConfigClient( + ObjectMapper objectMapper, + RestTemplate restTemplate, + String updateServiceBaseUrl, + Duration cacheTtl) { + this.objectMapper = objectMapper; + this.restTemplate = restTemplate; + this.updateServiceBaseUrl = updateServiceBaseUrl; + this.cacheTtl = cacheTtl; + } + + public boolean requiresLogin(String appKey) { + Instant now = Instant.now(); + CachedRequirement cached = cache.get(appKey); + if (cached != null && now.isBefore(cached.expiresAt())) { + return cached.requiresLogin(); + } + String uri = UriComponentsBuilder + .fromHttpUrl(updateServiceBaseUrl + "/api/v1/updates/publish/config") + .queryParam("appKey", appKey) + .build() + .toUriString(); + try { + String body = restTemplate.getForObject(uri, String.class); + JsonNode data = objectMapper.readTree(body).path("data"); + JsonNode config = data.path("configJson"); + JsonNode publishConfig = config.isTextual() + ? objectMapper.readTree(config.asText()) + : config; + boolean requiresLogin = !publishConfig.path("allowAnonymousUpdateCheck").asBoolean(false); + cache.put(appKey, new CachedRequirement(requiresLogin, now.plus(cacheTtl))); + return requiresLogin; + } catch (Exception e) { + log.warn("读取更新发布配置失败,按需要登录处理: appKey={} cause={}", + appKey, e.getClass().getSimpleName()); + cache.put(appKey, new CachedRequirement(true, now.plus(cacheTtl))); + return true; + } + } + + private record CachedRequirement(boolean requiresLogin, Instant expiresAt) { + } +} diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index 1c7a673..3efd497 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -70,12 +70,6 @@ jwt: secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} expiration: 3153600000000 -license: - service: - base-url: ${LICENSE_SERVICE_BASE_URL:http://license-service:8085} - public-base-url: ${LICENSE_PUBLIC_BASE_URL:https://auth.dev.xuqinmin.com/} - internal-token: ${LICENSE_INTERNAL_TOKEN:xuqm-license-internal-token} - captcha: expire-seconds: 300 @@ -108,6 +102,14 @@ sdk: file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com} im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com} bugcollect-api-url: ${SDK_BUGCOLLECT_API_URL:https://dev.xuqinmin.com/api} + update-service-base-url: ${SDK_UPDATE_SERVICE_BASE_URL:http://update-service:8084} + update-service-connect-timeout: ${SDK_UPDATE_SERVICE_CONNECT_TIMEOUT:500ms} + update-service-read-timeout: ${SDK_UPDATE_SERVICE_READ_TIMEOUT:800ms} + update-service-cache-ttl: ${SDK_UPDATE_SERVICE_CACHE_TTL:15s} + config-signing: + key-id: ${SDK_CONFIG_SIGNING_KEY_ID:v2-primary} + private-key-base64: ${SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64:} + public-key-base64: ${SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64:} im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform} im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin} im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c} @@ -119,12 +121,10 @@ deployment: enable-im: ${ENABLE_IM:false} enable-push: ${ENABLE_PUSH:false} enable-update: ${ENABLE_UPDATE:false} - enable-license: ${ENABLE_LICENSE:false} enable-file: ${ENABLE_FILE:true} im-domain: ${IM_DOMAIN:} push-domain: ${PUSH_DOMAIN:} update-domain: ${UPDATE_DOMAIN:} - license-domain: ${LICENSE_DOMAIN:} tenant: bootstrap: diff --git a/tenant-service/src/main/resources/db/migration/V3__sdk_config_v2.sql b/tenant-service/src/main/resources/db/migration/V3__sdk_config_v2.sql new file mode 100644 index 0000000..ccc986c --- /dev/null +++ b/tenant-service/src/main/resources/db/migration/V3__sdk_config_v2.sql @@ -0,0 +1,8 @@ +-- XUQM-CONFIG-V2 的当前签发身份。旧配置只在新的 Release 构建校验时失效, +-- 已安装客户端运行时不查询这些字段。 +ALTER TABLE t_app + ADD COLUMN config_file_id VARCHAR(64) NULL AFTER license_file_content, + ADD COLUMN config_file_revision BIGINT NOT NULL DEFAULT 0 AFTER config_file_id, + ADD COLUMN config_file_issued_at DATETIME(6) NULL AFTER config_file_revision, + ADD COLUMN config_file_expires_at DATETIME(6) NULL AFTER config_file_issued_at; + diff --git a/tenant-service/src/main/resources/db/migration/V4__allow_platform_specific_apps.sql b/tenant-service/src/main/resources/db/migration/V4__allow_platform_specific_apps.sql new file mode 100644 index 0000000..472e71c --- /dev/null +++ b/tenant-service/src/main/resources/db/migration/V4__allow_platform_specific_apps.sql @@ -0,0 +1,2 @@ +-- 产品允许仅配置 iOS 或鸿蒙包名,因此 Android package_name 不能强制非空。 +ALTER TABLE t_app MODIFY COLUMN package_name VARCHAR(128) NULL; diff --git a/tenant-service/src/main/resources/db/migration/V5__remove_obsolete_license_service.sql b/tenant-service/src/main/resources/db/migration/V5__remove_obsolete_license_service.sql new file mode 100644 index 0000000..8c65e10 --- /dev/null +++ b/tenant-service/src/main/resources/db/migration/V5__remove_obsolete_license_service.sql @@ -0,0 +1,8 @@ +DELETE FROM t_service_activation_request WHERE service_type = 'LICENSE'; +DELETE FROM t_feature_service WHERE service_type = 'LICENSE'; + +ALTER TABLE t_service_activation_request + MODIFY service_type ENUM('FILE','IM','PUSH','UPDATE','BUG_COLLECT') NOT NULL; + +ALTER TABLE t_feature_service + MODIFY service_type ENUM('FILE','IM','PUSH','UPDATE','BUG_COLLECT') NOT NULL; diff --git a/tenant-service/src/main/resources/nginx/xuqm.conf b/tenant-service/src/main/resources/nginx/xuqm.conf index 0aab6bc..8254cca 100644 --- a/tenant-service/src/main/resources/nginx/xuqm.conf +++ b/tenant-service/src/main/resources/nginx/xuqm.conf @@ -83,16 +83,6 @@ server { proxy_read_timeout 60s; } - # License — 必须在通用 /api/ 之前 - location /api/license/ { - set $svc license-service; - proxy_pass http://$svc:8085; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 60s; - } - # 文件上传下载 — 必须在通用 /api/ 之前 location /api/file/ { set $svc file-service; diff --git a/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java b/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java new file mode 100644 index 0000000..28cd34d --- /dev/null +++ b/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java @@ -0,0 +1,35 @@ +package com.xuqm.tenant.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.xuqm.tenant.service.ApiKeyService; +import com.xuqm.tenant.service.FeatureServiceManager; +import com.xuqm.tenant.service.SdkAppProvisioningService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class InternalSdkControllerTest { + + @Test + void validatesApiKeyFromProtectedPostBody() { + ApiKeyService apiKeyService = mock(ApiKeyService.class); + when(apiKeyService.validateApiKey("secret-api-token")).thenReturn("ak_test"); + InternalSdkController controller = new InternalSdkController( + mock(SdkAppProvisioningService.class), + mock(FeatureServiceManager.class), + apiKeyService); + ReflectionTestUtils.setField(controller, "internalToken", "internal-only"); + + var response = controller.validateApiKey( + new InternalSdkController.ApiKeyValidationRequest("secret-api-token"), + "internal-only"); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).containsEntry("appKey", "ak_test"); + verify(apiKeyService).validateApiKey("secret-api-token"); + } +} diff --git a/tenant-service/src/test/java/com/xuqm/tenant/service/UpdatePublishConfigClientTest.java b/tenant-service/src/test/java/com/xuqm/tenant/service/UpdatePublishConfigClientTest.java new file mode 100644 index 0000000..f234e5e --- /dev/null +++ b/tenant-service/src/test/java/com/xuqm/tenant/service/UpdatePublishConfigClientTest.java @@ -0,0 +1,44 @@ +package com.xuqm.tenant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.client.RestTemplate; + +class UpdatePublishConfigClientTest { + + private static final String URL = + "http://update-service:8084/api/v1/updates/publish/config?appKey=ak_test"; + + @Test + void cachesAuthoritativeAnonymousSetting() { + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + when(restTemplate.getForObject(URL, String.class)) + .thenReturn("{\"data\":{\"configJson\":{\"allowAnonymousUpdateCheck\":true}}}"); + UpdatePublishConfigClient client = new UpdatePublishConfigClient( + new ObjectMapper(), restTemplate, "http://update-service:8084", Duration.ofMinutes(1)); + + assertThat(client.requiresLogin("ak_test")).isFalse(); + assertThat(client.requiresLogin("ak_test")).isFalse(); + verify(restTemplate, times(1)).getForObject(URL, String.class); + } + + @Test + void failureIsCachedAsSafeLoginRequirement() { + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + when(restTemplate.getForObject(URL, String.class)) + .thenThrow(new IllegalStateException("update service unavailable")); + UpdatePublishConfigClient client = new UpdatePublishConfigClient( + new ObjectMapper(), restTemplate, "http://update-service:8084", Duration.ofMinutes(1)); + + assertThat(client.requiresLogin("ak_test")).isTrue(); + assertThat(client.requiresLogin("ak_test")).isTrue(); + verify(restTemplate, times(1)).getForObject(URL, String.class); + } +} diff --git a/tenant-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/tenant-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/tenant-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/update-service/BOOT-INF/classes/application.yml b/update-service/BOOT-INF/classes/application.yml deleted file mode 100644 index 69dc77c..0000000 --- a/update-service/BOOT-INF/classes/application.yml +++ /dev/null @@ -1,52 +0,0 @@ -server: - port: 8084 - -spring: - application: - name: update-service - datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://39.107.53.187:3306/xuqm_update?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true} - username: ${SPRING_DATASOURCE_USERNAME:xuqm} - password: ${SPRING_DATASOURCE_PASSWORD:Xuqm@2026} - driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - minimum-idle: 5 - maximum-pool-size: 20 - connection-timeout: 30000 - idle-timeout: 300000 - max-lifetime: 900000 - jpa: - hibernate: - ddl-auto: validate - show-sql: false - servlet: - multipart: - max-file-size: 200MB - max-request-size: 200MB - flyway: - enabled: true - baseline-on-migrate: true - baseline-version: 0 - locations: classpath:db/migration - table: flyway_history_update - -jwt: - secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} - expiration: 3153600000000 - -update: - upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update} - base-url: ${UPDATE_BASE_URL:https://update.dev.xuqinmin.com} - -sdk: - tenant-service-url: ${SDK_TENANT_SERVICE_URL:http://xuqm-tenant-service:9001} - internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} - -management: - endpoints: - web: - exposure: - include: health,info - endpoint: - health: - show-details: always diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java index 73d9be9..e4f3c5b 100644 --- a/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java +++ b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java @@ -38,14 +38,16 @@ public class TenantApiKeyValidator implements ApiKeyValidator { if (cached != null) return cached; try { - String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key?apiKey=" + apiKey; + String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key"; var headers = new org.springframework.http.HttpHeaders(); headers.set("X-Internal-Token", internalToken); - var entity = new org.springframework.http.HttpEntity<>(headers); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + var entity = new org.springframework.http.HttpEntity<>( + java.util.Map.of("apiKey", apiKey), headers); var response = restTemplate.exchange( url, - org.springframework.http.HttpMethod.GET, + org.springframework.http.HttpMethod.POST, entity, String.class ); @@ -60,7 +62,7 @@ public class TenantApiKeyValidator implements ApiKeyValidator { return appKey; } } catch (Exception e) { - log.warn("API Key validation failed: {}", e.getMessage()); + log.warn("API Key validation failed: {}", e.getClass().getSimpleName()); } return null; } diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 5773ff3..83b9d7a 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -27,7 +27,6 @@ import com.xuqm.update.service.UpdateTenantClient; import com.xuqm.update.handler.UpdateWebSocketHandler; import com.xuqm.update.service.GrayMemberService; import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.security.LicenseFileCrypto; @RestController @RequestMapping("/api/v1/updates") @@ -76,7 +75,6 @@ public class AppVersionController { @RequestParam(required = false) String appKey, @RequestParam AppVersionEntity.Platform platform, @RequestParam int currentVersionCode, - @RequestParam(required = false) String licenseFile, @RequestParam(required = false) String userId, @RequestParam(required = false) String deviceId, @RequestParam(required = false) String model, @@ -84,7 +82,7 @@ public class AppVersionController { @RequestParam(required = false) String vendor, @RequestParam(required = false) String currentVersionName) { - String resolvedAppKey = resolveAndValidate(appKey, platform, licenseFile); + String resolvedAppKey = requireAppKey(appKey); // 异步记录设备信息(不影响响应性能) recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode, currentVersionName); @@ -547,13 +545,9 @@ public class AppVersionController { return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT"; } - private String resolveAndValidate(String appKey, AppVersionEntity.Platform platform, String licenseFile) { - if (hasText(licenseFile)) { - LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); - return payload.appKey(); - } + private String requireAppKey(String appKey) { if (!hasText(appKey)) { - throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + throw new BusinessException(400, "appKey 不能为空"); } return appKey; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/SecurityConfig.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/SecurityConfig.java index b4c8a5f..6774c42 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/SecurityConfig.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/SecurityConfig.java @@ -1,16 +1,27 @@ package com.xuqm.bugcollect.config; +import com.xuqm.common.security.ApiKeyAuthFilter; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity public class SecurityConfig { + private final TenantApiKeyValidator apiKeyValidator; + + public SecurityConfig(TenantApiKeyValidator apiKeyValidator) { + this.apiKeyValidator = apiKeyValidator; + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -18,9 +29,19 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/**").permitAll() + .requestMatchers(HttpMethod.POST, "/bugcollect/v1/artifacts/upload").authenticated() .requestMatchers("/bugcollect/**").permitAll() .anyRequest().permitAll() - ); + ) + .exceptionHandling(ex -> ex.authenticationEntryPoint( + (request, response, exception) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED))) + .addFilterBefore( + new ApiKeyAuthFilter( + apiKeyValidator, + new AntPathRequestMatcher("/bugcollect/v1/artifacts/upload", "POST"), + true), + UsernamePasswordAuthenticationFilter.class); return http.build(); } } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/TenantApiKeyValidator.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/TenantApiKeyValidator.java new file mode 100644 index 0000000..9266068 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/config/TenantApiKeyValidator.java @@ -0,0 +1,73 @@ +package com.xuqm.bugcollect.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.security.ApiKeyValidator; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +/** + * 通过租户服务验证平台 API Token,并返回其唯一归属 appKey。 + */ +@Component +public class TenantApiKeyValidator implements ApiKeyValidator { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String tenantServiceUrl; + private final String internalToken; + private final Map cache = new ConcurrentHashMap<>(); + + public TenantApiKeyValidator( + RestTemplateBuilder restTemplateBuilder, + ObjectMapper objectMapper, + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") String tenantServiceUrl, + @Value("${sdk.internal-token:}") String internalToken) { + this.restTemplate = restTemplateBuilder + .connectTimeout(Duration.ofMillis(500)) + .readTimeout(Duration.ofMillis(800)) + .build(); + this.objectMapper = objectMapper; + this.tenantServiceUrl = tenantServiceUrl; + this.internalToken = internalToken; + } + + @Override + public String resolveAppKey(String apiKey) { + CachedAppKey cached = cache.get(apiKey); + if (cached != null && Instant.now().isBefore(cached.expiresAt())) { + return cached.appKey(); + } + try { + String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + String body = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(Map.of("apiKey", apiKey), headers), + String.class).getBody(); + JsonNode root = objectMapper.readTree(body); + String appKey = root.path("data").path("appKey").asText(null); + if (appKey != null && !appKey.isBlank()) { + cache.put(apiKey, new CachedAppKey(appKey, Instant.now().plusSeconds(30))); + } + return appKey; + } catch (Exception ignored) { + return null; + } + } + + private record CachedAppKey(String appKey, Instant expiresAt) { + } +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/GlobalExceptionHandler.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/GlobalExceptionHandler.java index 81a9526..d3e2e10 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/GlobalExceptionHandler.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package com.xuqm.bugcollect.controller; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.model.ApiResponse; +import com.xuqm.bugcollect.service.BugCollectDisabledException; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,6 +24,12 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(BugCollectDisabledException.class) + public ResponseEntity> handle(BugCollectDisabledException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(403, BugCollectDisabledException.ERROR_CODE)); + } + @ExceptionHandler(BusinessException.class) public ResponseEntity> handle(BusinessException ex, HttpServletRequest request) { if (ex.getCode() >= 500) { diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java index f3077ec..b3aee06 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java @@ -6,8 +6,11 @@ import com.xuqm.bugcollect.dto.*; import com.xuqm.bugcollect.service.LogService; import com.xuqm.bugcollect.service.SourcemapService; import com.xuqm.bugcollect.service.WebhookService; +import com.xuqm.bugcollect.service.BugCollectAvailabilityService; import jakarta.validation.Valid; import org.springframework.data.domain.Page; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -21,23 +24,41 @@ public class LogController { private final LogService logService; private final SourcemapService sourcemapService; private final WebhookService webhookService; + private final BugCollectAvailabilityService availabilityService; - public LogController(LogService logService, SourcemapService sourcemapService, WebhookService webhookService) { + public LogController(LogService logService, SourcemapService sourcemapService, + WebhookService webhookService, + BugCollectAvailabilityService availabilityService) { this.logService = logService; this.sourcemapService = sourcemapService; this.webhookService = webhookService; + this.availabilityService = availabilityService; } // ── Ingestion ────────────────────────────────────────────────────────────── @PostMapping("/issues/batch") public ApiResponse ingestIssues(@Valid @RequestBody IssueBatchRequest request) { + request.events().stream() + .map(item -> item.appKey() + "\n" + item.platform()) + .distinct() + .forEach(key -> { + String[] parts = key.split("\n", 2); + availabilityService.requireEnabled(parts[0], parts.length > 1 ? parts[1] : null); + }); logService.processIssueBatch(request); return ApiResponse.ok(); } @PostMapping("/events/batch") public ApiResponse ingestEvents(@Valid @RequestBody EventBatchRequest request) { + request.events().stream() + .map(item -> item.appKey() + "\n" + item.platform()) + .distinct() + .forEach(key -> { + String[] parts = key.split("\n", 2); + availabilityService.requireEnabled(parts[0], parts.length > 1 ? parts[1] : null); + }); logService.processEventBatch(request); return ApiResponse.ok(); } @@ -192,16 +213,26 @@ public class LogController { // ── Sourcemaps ───────────────────────────────────────────────────────────── - @PostMapping("/sourcemaps/upload") - public ApiResponse uploadSourcemap( + @PostMapping("/artifacts/upload") + public ApiResponse uploadArtifact( + @RequestParam String artifactType, @RequestParam String appKey, @RequestParam String platform, @RequestParam String appVersion, - @RequestParam(required = false) String buildId, - @RequestParam(required = false) String bundleVersion, - @RequestParam(required = false, defaultValue = "index") String bundleName, - @RequestParam("file") MultipartFile file) throws IOException { - return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, buildId, bundleVersion, bundleName, file)); + @RequestParam String buildId, + @RequestParam(required = false) String moduleId, + @RequestParam(required = false) String moduleVersion, + @RequestParam(required = false) String bundleHash, + @RequestParam(required = false) String artifactHash, + @RequestParam("file") MultipartFile file, + Authentication authentication) throws IOException { + if (authentication == null || !("apikey:" + appKey).equals(authentication.getName())) { + throw new AccessDeniedException("API Token 与 appKey 不匹配"); + } + availabilityService.requireEnabled(appKey, platform); + return ApiResponse.success(sourcemapService.upload( + artifactType, appKey, platform, appVersion, buildId, + moduleId, moduleVersion, bundleHash, artifactHash, file)); } @GetMapping("/sourcemaps") diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java index 9d0ea2d..0c77215 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java @@ -29,6 +29,9 @@ public record IssueBatchRequest( @JsonProperty("device") DeviceInfo device, String release, String buildId, + String moduleId, + String moduleVersion, + String bundleHash, String environment, @JsonProperty("tags") Object tags ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapInfo.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapInfo.java index ef29050..173b422 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapInfo.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapInfo.java @@ -8,8 +8,13 @@ public record SourcemapInfo( @JsonProperty("appKey") String appKey, @JsonProperty("platform") String platform, @JsonProperty("appVersion") String appVersion, - @JsonProperty("bundleName") String bundleName, @JsonProperty("buildId") String buildId, + @JsonProperty("moduleId") String moduleId, + @JsonProperty("moduleVersion") String moduleVersion, + @JsonProperty("bundleHash") String bundleHash, + @JsonProperty("artifactType") String artifactType, + @JsonProperty("artifactHash") String artifactHash, + @JsonProperty("contentHash") String contentHash, @JsonProperty("storageKey") String storageKey, @JsonProperty("uploadedAt") LocalDateTime uploadedAt ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapUploadResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapUploadResponse.java index 36556ba..aaecca1 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapUploadResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/SourcemapUploadResponse.java @@ -7,7 +7,12 @@ public record SourcemapUploadResponse( @JsonProperty("appKey") String appKey, String platform, @JsonProperty("appVersion") String appVersion, - @JsonProperty("bundleName") String bundleName, @JsonProperty("buildId") String buildId, + @JsonProperty("moduleId") String moduleId, + @JsonProperty("moduleVersion") String moduleVersion, + @JsonProperty("bundleHash") String bundleHash, + @JsonProperty("artifactType") String artifactType, + @JsonProperty("artifactHash") String artifactHash, + @JsonProperty("contentHash") String contentHash, @JsonProperty("storageKey") String storageKey ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java index 04828d4..6c4fe9a 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java @@ -59,6 +59,15 @@ public class LogIssueEventEntity { @Column(length = 32) private String buildId; + @Column(length = 128) + private String moduleId; + + @Column(length = 32) + private String moduleVersion; + + @Column(length = 64) + private String bundleHash; + @Column(length = 50) private String environment; @@ -125,6 +134,15 @@ public class LogIssueEventEntity { public String getBuildId() { return buildId; } public void setBuildId(String buildId) { this.buildId = buildId; } + public String getModuleId() { return moduleId; } + public void setModuleId(String moduleId) { this.moduleId = moduleId; } + + public String getModuleVersion() { return moduleVersion; } + public void setModuleVersion(String moduleVersion) { this.moduleVersion = moduleVersion; } + + public String getBundleHash() { return bundleHash; } + public void setBundleHash(String bundleHash) { this.bundleHash = bundleHash; } + public String getEnvironment() { return environment; } public void setEnvironment(String environment) { this.environment = environment; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogSourcemapEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogSourcemapEntity.java index c4de050..c3d24b3 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogSourcemapEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogSourcemapEntity.java @@ -21,9 +21,24 @@ public class LogSourcemapEntity { private String appVersion; @Column(nullable = false, length = 128) - private String bundleName = "index"; + private String moduleId; - @Column(length = 32) + @Column(nullable = false, length = 32) + private String moduleVersion; + + @Column(length = 64) + private String bundleHash; + + @Column(nullable = false, length = 24) + private String artifactType; + + @Column(nullable = false, length = 64) + private String artifactHash; + + @Column(nullable = false, length = 64) + private String contentHash; + + @Column(nullable = false, length = 64) private String buildId; @Column(nullable = false, length = 512) @@ -44,8 +59,23 @@ public class LogSourcemapEntity { public String getAppVersion() { return appVersion; } public void setAppVersion(String appVersion) { this.appVersion = appVersion; } - public String getBundleName() { return bundleName; } - public void setBundleName(String bundleName) { this.bundleName = bundleName; } + public String getModuleId() { return moduleId; } + public void setModuleId(String moduleId) { this.moduleId = moduleId; } + + public String getModuleVersion() { return moduleVersion; } + public void setModuleVersion(String moduleVersion) { this.moduleVersion = moduleVersion; } + + public String getBundleHash() { return bundleHash; } + public void setBundleHash(String bundleHash) { this.bundleHash = bundleHash; } + + public String getArtifactType() { return artifactType; } + public void setArtifactType(String artifactType) { this.artifactType = artifactType; } + + public String getArtifactHash() { return artifactHash; } + public void setArtifactHash(String artifactHash) { this.artifactHash = artifactHash; } + + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } public String getBuildId() { return buildId; } public void setBuildId(String buildId) { this.buildId = buildId; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogSourcemapRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogSourcemapRepository.java index d2e0c7a..0585b5a 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogSourcemapRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogSourcemapRepository.java @@ -8,25 +8,18 @@ import java.util.Optional; public interface LogSourcemapRepository extends JpaRepository { - /** Exact match by buildId (for events that carry a buildId). */ - Optional findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName( - String appKey, String platform, String appVersion, String buildId, String bundleName); + Optional + findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion( + String appKey, String platform, String appVersion, String buildId, + String artifactType, String artifactHash, String moduleId, String moduleVersion); - /** Backward-compat: exact match on (appKey, platform, appVersion, bundleName) where buildId is NULL. */ - Optional findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName( - String appKey, String platform, String appVersion, String bundleName); + List + findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc( + String appKey, String platform, String appVersion, String moduleId); - /** Fallback: latest upload for a version regardless of buildId. */ - Optional findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc( - String appKey, String platform, String appVersion, String bundleName); - - /** All records for one version, newest first — used for per-version retention pruning. */ - List findByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc( - String appKey, String platform, String appVersion, String bundleName); - - /** All records for one (appKey, platform, bundleName), newest first — used for version-count pruning. */ - List findByAppKeyAndPlatformAndBundleNameOrderByUploadedAtDesc( - String appKey, String platform, String bundleName); + List + findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc( + String appKey, String platform, String moduleId); List findByAppKeyOrderByUploadedAtDesc(String appKey); diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectAvailabilityService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectAvailabilityService.java new file mode 100644 index 0000000..92d3209 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectAvailabilityService.java @@ -0,0 +1,84 @@ +package com.xuqm.bugcollect.service; + +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 从租户平台读取应用级 BugCollect 动态开关。 + * + *

关闭状态在短缓存周期内生效;租户平台暂时不可用时采用 fail-open,避免配置服务故障 + * 让已经启用的采集链路产生额外错误。SDK 收到 {@code BUGCOLLECT_DISABLED} 后会自行停用, + * 因此服务端不需要长期保存设备侧状态。

+ */ +@Service +public class BugCollectAvailabilityService { + + private static final Logger log = LoggerFactory.getLogger(BugCollectAvailabilityService.class); + + private final RestClient restClient; + private final Duration cacheTtl; + private final Map cache = new ConcurrentHashMap<>(); + + public BugCollectAvailabilityService( + RestClient.Builder restClientBuilder, + @Value("${bugcollect-service.tenant-sdk-config-url:http://127.0.0.1:8081/api/sdk/config}") + String sdkConfigUrl, + @Value("${bugcollect-service.availability-cache-seconds:30}") long cacheSeconds) { + this.restClient = restClientBuilder.baseUrl(sdkConfigUrl).build(); + this.cacheTtl = Duration.ofSeconds(Math.max(1, cacheSeconds)); + } + + public void requireEnabled(String appKey, String platform) { + if (!isEnabled(appKey, platform)) { + throw new BugCollectDisabledException(); + } + } + + boolean isEnabled(String appKey, String platform) { + String normalizedPlatform = normalizePlatform(platform); + String cacheKey = appKey + ":" + normalizedPlatform; + Instant now = Instant.now(); + CachedAvailability cached = cache.get(cacheKey); + if (cached != null && now.isBefore(cached.expiresAt())) { + return cached.enabled(); + } + try { + JsonNode response = restClient.get() + .uri(uriBuilder -> uriBuilder + .queryParam("appKey", appKey) + .queryParam("platform", normalizedPlatform) + .build()) + .retrieve() + .body(JsonNode.class); + boolean enabled = response != null + && response.path("data").path("features").path("bugCollect").asBoolean(false); + cache.put(cacheKey, new CachedAvailability(enabled, now.plus(cacheTtl))); + return enabled; + } catch (Exception e) { + log.warn("租户平台 BugCollect 开关查询失败,沿用有效缓存或暂时放行: appKey={} cause={}", + appKey, e.getClass().getSimpleName()); + return cached == null || cached.enabled(); + } + } + + private static String normalizePlatform(String platform) { + if (platform == null || platform.isBlank()) return "ANDROID"; + return switch (platform.trim().toUpperCase()) { + case "IOS" -> "IOS"; + case "HARMONY", "HARMONYOS" -> "HARMONY"; + default -> "ANDROID"; + }; + } + + private record CachedAvailability(boolean enabled, Instant expiresAt) { + } +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectDisabledException.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectDisabledException.java new file mode 100644 index 0000000..3dfb24f --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/BugCollectDisabledException.java @@ -0,0 +1,13 @@ +package com.xuqm.bugcollect.service; + +/** + * SDK 可稳定识别的服务关闭信号。机器码不得本地化或改写。 + */ +public final class BugCollectDisabledException extends RuntimeException { + + public static final String ERROR_CODE = "BUGCOLLECT_DISABLED"; + + public BugCollectDisabledException() { + super(ERROR_CODE); + } +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java index 4899157..9fc1689 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java @@ -140,6 +140,9 @@ public class LogService { eventEntity.setPlatform(item.platform()); eventEntity.setRelease(item.release()); eventEntity.setBuildId(item.buildId()); + eventEntity.setModuleId(item.moduleId()); + eventEntity.setModuleVersion(item.moduleVersion()); + eventEntity.setBundleHash(item.bundleHash()); eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production"); eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null); eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null); @@ -150,7 +153,8 @@ public class LogService { eventEntity = issueEventRepository.save(eventEntity); if (isNew) triggerWebhookAsync(issue); - triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), item.buildId()); + triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), + item.buildId(), item.moduleId(), item.moduleVersion(), item.bundleHash()); } @Async @@ -163,14 +167,16 @@ public class LogService { } @Async - void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, String buildId) { - log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}", - eventId, appKey, platform, release, buildId); + void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, + String buildId, String moduleId, String moduleVersion, String bundleHash) { + log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={} moduleId={} moduleVersion={} bundleHash={}", + eventId, appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash); - // 查找 sourcemap 文件:精确 buildId 匹配,兜底取最新版本 - String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index"); + String storageKey = sourcemapService.findSourceMap( + appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash); if (storageKey == null) { - log.debug("No sourcemap found for appKey={} platform={} release={} buildId={}", appKey, platform, release, buildId); + log.debug("No exact Source Map found for appKey={} platform={} release={} buildId={} moduleId={} moduleVersion={} bundleHash={}", + appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash); return; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java index 32d706b..4c6a5a1 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java @@ -1,5 +1,7 @@ package com.xuqm.bugcollect.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.bugcollect.dto.SourcemapInfo; import com.xuqm.bugcollect.dto.SourcemapUploadResponse; import com.xuqm.bugcollect.entity.LogSourcemapEntity; @@ -15,6 +17,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.MessageDigest; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; @@ -23,12 +26,15 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +/** + * Source Map 只按完整 Bundle 身份精确存取,禁止“取同版本最新文件”等不可靠兜底。 + */ @Service public class SourcemapService { private static final Logger log = LoggerFactory.getLogger(SourcemapService.class); - private static final long MAX_FILE_SIZE = 200L * 1024 * 1024; // 200 MB - private static final Set ALLOWED_EXTENSIONS = Set.of(".map", ".jsbundle.map", ".js.map"); + private static final long MAX_FILE_SIZE = 50L * 1024 * 1024; + private static final ObjectMapper MAPPER = new ObjectMapper(); private final LogSourcemapRepository sourcemapRepository; @@ -40,162 +46,205 @@ public class SourcemapService { } @Transactional - public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, - String bundleVersion, String bundleName, MultipartFile file) throws IOException { - return upload(appKey, platform, appVersion, null, bundleVersion, bundleName, file); - } - - /** - * Upload a sourcemap file. - * When buildId is provided, each unique (appKey, platform, appVersion, buildId, bundleName) - * produces a separate record — multiple builds of the same version coexist. - * When buildId is null (backward compat), the legacy single-record-per-version behaviour is kept. - */ - @Transactional - public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, - String buildId, String bundleVersion, String bundleName, - MultipartFile file) throws IOException { - if (bundleName == null || bundleName.isBlank()) { - bundleName = "index"; - } - - if (file.getSize() > MAX_FILE_SIZE) { - throw new IllegalArgumentException("File size exceeds the 200 MB limit"); - } - - String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; - if (!originalName.endsWith(".map")) { - throw new IllegalArgumentException("Only .map sourcemap files are accepted"); - } - - String filename = resolveFilename(platform, bundleName); - String versionKey = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : appVersion; - // When buildId is present, store under a subdirectory to avoid file collisions across builds - String buildDir = buildId != null && !buildId.isBlank() ? buildId : "legacy"; - - Path dir = Paths.get(storageDir, appKey, platform, versionKey, buildDir); - Files.createDirectories(dir); - Path filePath = dir.resolve(filename); - file.transferTo(filePath.toFile()); - - LogSourcemapEntity entity; - if (buildId != null && !buildId.isBlank()) { - // New path: each buildId gets its own record; no overwrite - var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName( - appKey, platform, appVersion, buildId, bundleName); - if (existing.isPresent()) { - entity = existing.get(); - entity.setStorageKey(filePath.toString()); - entity.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))); - } else { - entity = newEntity(appKey, platform, appVersion, buildId, bundleName, filePath.toString()); - } + public SourcemapUploadResponse upload( + String artifactTypeValue, + String appKey, + String platform, + String appVersion, + String buildId, + String moduleId, + String moduleVersion, + String bundleHash, + String artifactHash, + MultipartFile file) throws IOException { + ArtifactType artifactType = ArtifactType.parse(artifactTypeValue); + requireBaseIdentity(appKey, platform, appVersion, buildId); + String normalizedModuleId; + String normalizedModuleVersion; + String normalizedArtifactHash; + if (artifactType == ArtifactType.RN_SOURCEMAP) { + requireIdentity(moduleId, moduleVersion, bundleHash); + normalizedModuleId = moduleId; + normalizedModuleVersion = moduleVersion; + normalizedArtifactHash = normalizeHash(bundleHash, "bundleHash"); } else { - // Legacy path: overwrite the single record for (appKey, platform, appVersion, bundleName) - var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName( - appKey, platform, appVersion, bundleName); - if (existing.isPresent()) { - entity = existing.get(); - entity.setStorageKey(filePath.toString()); - entity.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))); - } else { - entity = newEntity(appKey, platform, appVersion, null, bundleName, filePath.toString()); + normalizedModuleId = ""; + normalizedModuleVersion = ""; + normalizedArtifactHash = normalizeHash(artifactHash, "artifactHash"); + if (!"android".equalsIgnoreCase(platform)) { + throw new IllegalArgumentException("R8_MAPPING 只支持 Android 平台"); } } - - entity = sourcemapRepository.save(entity); - log.info("Sourcemap uploaded: appKey={} platform={} version={} buildId={} bundle={}", - appKey, platform, versionKey, buildId, bundleName); - - pruneSourcemaps(appKey, platform, appVersion, bundleName); - - return new SourcemapUploadResponse( - entity.getId(), entity.getAppKey(), entity.getPlatform(), - entity.getAppVersion(), entity.getBundleName(), entity.getBuildId(), entity.getStorageKey() - ); - } - - private LogSourcemapEntity newEntity(String appKey, String platform, String appVersion, - String buildId, String bundleName, String storageKey) { - var e = new LogSourcemapEntity(); - e.setAppKey(appKey); - e.setPlatform(platform); - e.setAppVersion(appVersion); - e.setBuildId(buildId); - e.setBundleName(bundleName); - e.setStorageKey(storageKey); - e.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))); - return e; - } - - /** Produce platform-appropriate filename, e.g. index.android.bundle.map */ - private String resolveFilename(String platform, String bundleName) { - return switch (platform != null ? platform.toLowerCase() : "") { - case "android" -> bundleName + ".android.bundle.map"; - case "ios" -> bundleName + ".ios.bundle.map"; - case "harmony" -> bundleName + ".harmony.bundle.map"; - default -> bundleName + ".map"; - }; - } - - /** - * Locate a sourcemap file. - * Priority: exact (appKey, platform, appVersion, buildId, bundleName) - * → version-only latest (appKey, platform, appVersion, bundleName) - */ - public String findSourceMap(String appKey, String platform, String appVersion, - String buildId, String bundleName) { - if (buildId != null && !buildId.isBlank()) { - var exact = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName( - appKey, platform, appVersion, buildId, bundleName); - if (exact.isPresent()) return exact.get().getStorageKey(); + if (file.isEmpty() || file.getSize() > MAX_FILE_SIZE) { + throw new IllegalArgumentException("调试产物为空或超过 50 MB"); + } + String originalName = file.getOriginalFilename() == null ? "" : file.getOriginalFilename(); + if (artifactType == ArtifactType.RN_SOURCEMAP && !originalName.endsWith(".map")) { + throw new IllegalArgumentException("RN_SOURCEMAP 只接受 .map 文件"); + } + if (artifactType == ArtifactType.R8_MAPPING && !"mapping.txt".equalsIgnoreCase(originalName)) { + throw new IllegalArgumentException("R8_MAPPING 只接受 mapping.txt"); + } + + byte[] content = file.getBytes(); + String contentHash = sha256(content); + if (artifactType == ArtifactType.RN_SOURCEMAP) { + validateSanitizedSourceMap(content); + } else if (!contentHash.equals(normalizedArtifactHash)) { + throw new IllegalArgumentException("artifactHash 与 mapping.txt 内容不一致"); + } + + Path dir = Paths.get(storageDir, + safeSegment(appKey), safeSegment(platform), safeSegment(appVersion), + safeSegment(buildId), artifactType.name(), + safeSegment(normalizedModuleId.isEmpty() ? "_" : normalizedModuleId), + safeSegment(normalizedModuleVersion.isEmpty() ? "_" : normalizedModuleVersion)); + Files.createDirectories(dir); + Path filePath = dir.resolve(normalizedArtifactHash + + (artifactType == ArtifactType.R8_MAPPING ? ".txt" : ".map")); + Files.write(filePath, content); + + LogSourcemapEntity entity = sourcemapRepository + .findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion( + appKey, platform, appVersion, buildId, artifactType.name(), + normalizedArtifactHash, normalizedModuleId, normalizedModuleVersion) + .orElseGet(LogSourcemapEntity::new); + entity.setAppKey(appKey); + entity.setPlatform(platform); + entity.setAppVersion(appVersion); + entity.setBuildId(buildId); + entity.setModuleId(normalizedModuleId); + entity.setModuleVersion(normalizedModuleVersion); + entity.setBundleHash(artifactType == ArtifactType.RN_SOURCEMAP ? normalizedArtifactHash : null); + entity.setArtifactType(artifactType.name()); + entity.setArtifactHash(normalizedArtifactHash); + entity.setContentHash(contentHash); + entity.setStorageKey(filePath.toString()); + entity.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))); + entity = sourcemapRepository.save(entity); + + pruneSourcemaps(appKey, platform, appVersion, normalizedModuleId); + log.info("Debug artifact uploaded: type={} appKey={} platform={} appVersion={} buildId={} moduleId={} moduleVersion={} artifactHash={}", + artifactType, appKey, platform, appVersion, buildId, + normalizedModuleId, normalizedModuleVersion, normalizedArtifactHash); + return toUploadResponse(entity); + } + + public String findSourceMap( + String appKey, + String platform, + String appVersion, + String buildId, + String moduleId, + String moduleVersion, + String bundleHash) { + if (hasBlank(appKey, platform, appVersion, buildId, moduleId, moduleVersion, bundleHash)) { + return null; } - // Fallback: newest upload for this version return sourcemapRepository - .findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc( - appKey, platform, appVersion, bundleName) + .findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion( + appKey, platform, appVersion, buildId, ArtifactType.RN_SOURCEMAP.name(), + bundleHash.toLowerCase(), moduleId, moduleVersion) .map(LogSourcemapEntity::getStorageKey) .orElse(null); } - /** Backward-compat overload without buildId. */ - public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) { - return findSourceMap(appKey, platform, appVersion, null, bundleName); + private void validateSanitizedSourceMap(byte[] content) { + try { + JsonNode root = MAPPER.readTree(content); + if (root == null || !root.isObject() || !root.path("mappings").isTextual()) { + throw new IllegalArgumentException("Source Map 格式无效"); + } + JsonNode sourcesContent = root.path("sourcesContent"); + if (sourcesContent.isArray() && !sourcesContent.isEmpty()) { + throw new IllegalArgumentException("Source Map 必须移除 sourcesContent 后再上传"); + } + JsonNode sources = root.path("sources"); + if (sources.isArray()) { + for (JsonNode source : sources) { + String path = source.asText(""); + if (path.startsWith("/") + || path.matches("^[A-Za-z]:[\\\\/].*") + || hasParentTraversal(path)) { + throw new IllegalArgumentException("Source Map sources 只能包含项目内相对路径"); + } + } + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Source Map JSON 无法解析", e); + } + } + + private static void requireBaseIdentity(String... values) { + if (hasBlank(values)) { + throw new IllegalArgumentException("调试产物基础身份参数不能为空"); + } + } + + private static void requireIdentity(String... values) { + if (hasBlank(values)) throw new IllegalArgumentException("RN Bundle 身份参数不能为空"); + } + + private static String normalizeHash(String value, String field) { + if (value == null || !value.matches("(?i)^[0-9a-f]{64}$")) { + throw new IllegalArgumentException(field + " 必须是 SHA-256 十六进制摘要"); + } + return value.toLowerCase(); + } + + private static String sha256(byte[] content) { + try { + return java.util.HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(content)); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static boolean hasParentTraversal(String path) { + for (String segment : path.replace('\\', '/').split("/")) { + if ("..".equals(segment)) return true; + } + return false; + } + + private static boolean hasBlank(String... values) { + for (String value : values) { + if (value == null || value.isBlank()) return true; + } + return false; + } + + private static String safeSegment(String value) { + if (value.contains("/") || value.contains("\\") || value.contains("..")) { + throw new IllegalArgumentException("Source Map 身份参数包含非法路径字符"); + } + return value; } /** - * Enforce retention policy after every upload: - * - per version (appVersion): keep at most 3 records (newest by uploadedAt) - * - per (appKey, platform, bundleName): keep at most 5 distinct appVersions (newest by latest upload) + * 每个应用版本和模块最多保留 3 个构建;每个模块最多保留 5 个应用版本。 */ - private void pruneSourcemaps(String appKey, String platform, String appVersion, String bundleName) { - // 1. Per-version limit: keep newest 3 buildIds + private void pruneSourcemaps(String appKey, String platform, String appVersion, String moduleId) { List versionRecords = sourcemapRepository - .findByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc( - appKey, platform, appVersion, bundleName); + .findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc( + appKey, platform, appVersion, moduleId); if (versionRecords.size() > 3) { deleteEntities(versionRecords.subList(3, versionRecords.size())); } - // 2. Version-count limit: keep at most 5 distinct appVersions List allRecords = sourcemapRepository - .findByAppKeyAndPlatformAndBundleNameOrderByUploadedAtDesc(appKey, platform, bundleName); - - // Preserve insertion order = newest-first version order + .findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc(appKey, platform, moduleId); LinkedHashSet orderedVersions = new LinkedHashSet<>(); - for (LogSourcemapEntity r : allRecords) { - orderedVersions.add(r.getAppVersion()); - } - + allRecords.forEach(item -> orderedVersions.add(item.getAppVersion())); if (orderedVersions.size() > 5) { - List versionList = new ArrayList<>(orderedVersions); - Set versionsToDelete = new LinkedHashSet<>(versionList.subList(5, versionList.size())); - List excess = allRecords.stream() - .filter(r -> versionsToDelete.contains(r.getAppVersion())) - .toList(); - deleteEntities(excess); - log.info("Pruned {} old-version sourcemap(s) for appKey={} platform={} bundle={}", - excess.size(), appKey, platform, bundleName); + List versions = new ArrayList<>(orderedVersions); + Set expiredVersions = new LinkedHashSet<>(versions.subList(5, versions.size())); + deleteEntities(allRecords.stream() + .filter(item -> expiredVersions.contains(item.getAppVersion())) + .toList()); } } @@ -204,31 +253,25 @@ public class SourcemapService { try { Files.deleteIfExists(Paths.get(entity.getStorageKey())); } catch (IOException e) { - log.warn("Could not delete sourcemap file {}: {}", entity.getStorageKey(), e.getMessage()); + log.warn("Source Map 文件删除失败: storageKey={} cause={}", + entity.getStorageKey(), e.getClass().getSimpleName()); } sourcemapRepository.delete(entity); } } - /** One-time / admin: apply the retention policy to all existing records. Returns deleted count. */ @Transactional public int pruneAll() { List all = sourcemapRepository.findAll(); long before = all.size(); - - record VersionKey(String appKey, String platform, String bundleName, String appVersion) {} - Set versionKeys = all.stream() - .map(e -> new VersionKey(e.getAppKey(), e.getPlatform(), e.getBundleName(), e.getAppVersion())) + record VersionKey(String appKey, String platform, String moduleId, String appVersion) {} + Set keys = all.stream() + .map(item -> new VersionKey( + item.getAppKey(), item.getPlatform(), item.getModuleId(), item.getAppVersion())) .collect(Collectors.toCollection(LinkedHashSet::new)); - - for (VersionKey key : versionKeys) { - pruneSourcemaps(key.appKey(), key.platform(), key.appVersion(), key.bundleName()); - } - - long after = sourcemapRepository.count(); - int deleted = (int) (before - after); - log.info("pruneAll: scanned {} records, deleted {}", before, deleted); - return deleted; + keys.forEach(key -> pruneSourcemaps( + key.appKey(), key.platform(), key.appVersion(), key.moduleId())); + return (int) (before - sourcemapRepository.count()); } public String readSourceMapContent(String storageKey) throws IOException { @@ -237,25 +280,42 @@ public class SourcemapService { @Transactional public void deleteById(Long id) { - sourcemapRepository.findById(id).ifPresent(entity -> { - try { - Files.deleteIfExists(Paths.get(entity.getStorageKey())); - } catch (IOException e) { - log.warn("Could not delete sourcemap file {}: {}", entity.getStorageKey(), e.getMessage()); - } - sourcemapRepository.delete(entity); - log.info("Sourcemap deleted: id={} storageKey={}", id, entity.getStorageKey()); - }); + sourcemapRepository.findById(id).ifPresent(entity -> deleteEntities(List.of(entity))); } public List listByAppKey(String appKey) { return sourcemapRepository.findByAppKeyOrderByUploadedAtDesc(appKey) .stream() - .map(e -> new SourcemapInfo( - e.getId(), e.getAppKey(), e.getPlatform(), - e.getAppVersion(), e.getBundleName(), e.getBuildId(), - e.getStorageKey(), e.getUploadedAt() - )) + .map(this::toInfo) .toList(); } + + private SourcemapInfo toInfo(LogSourcemapEntity entity) { + return new SourcemapInfo( + entity.getId(), entity.getAppKey(), entity.getPlatform(), entity.getAppVersion(), + entity.getBuildId(), entity.getModuleId(), entity.getModuleVersion(), + entity.getBundleHash(), entity.getArtifactType(), entity.getArtifactHash(), + entity.getContentHash(), entity.getStorageKey(), entity.getUploadedAt()); + } + + private SourcemapUploadResponse toUploadResponse(LogSourcemapEntity entity) { + return new SourcemapUploadResponse( + entity.getId(), entity.getAppKey(), entity.getPlatform(), entity.getAppVersion(), + entity.getBuildId(), entity.getModuleId(), entity.getModuleVersion(), + entity.getBundleHash(), entity.getArtifactType(), entity.getArtifactHash(), + entity.getContentHash(), entity.getStorageKey()); + } + + public enum ArtifactType { + RN_SOURCEMAP, + R8_MAPPING; + + static ArtifactType parse(String value) { + try { + return valueOf(value == null ? "" : value.trim().toUpperCase()); + } catch (Exception e) { + throw new IllegalArgumentException("artifactType 仅支持 RN_SOURCEMAP 或 R8_MAPPING"); + } + } + } } diff --git a/xuqm-bugcollect-service/src/main/resources/application.yml b/xuqm-bugcollect-service/src/main/resources/application.yml index 5043f92..1f0a6d4 100644 --- a/xuqm-bugcollect-service/src/main/resources/application.yml +++ b/xuqm-bugcollect-service/src/main/resources/application.yml @@ -51,7 +51,13 @@ spring: max-file-size: 50MB max-request-size: 50MB +sdk: + tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:9001} + internal-token: ${SDK_INTERNAL_TOKEN:} + bugcollect-service: + tenant-sdk-config-url: ${TENANT_SDK_CONFIG_URL:http://127.0.0.1:8081/api/sdk/config} + availability-cache-seconds: ${BUGCOLLECT_AVAILABILITY_CACHE_SECONDS:30} sourcemap: storage-dir: ${LOG_SOURCEMAP_DIR:/data/bugcollect-service/sourcemaps} webhook: @@ -67,6 +73,3 @@ management: web: exposure: include: health,info - -spring.security: - enabled: false diff --git a/xuqm-bugcollect-service/src/main/resources/db/migration/V9__sourcemap_exact_bundle_identity.sql b/xuqm-bugcollect-service/src/main/resources/db/migration/V9__sourcemap_exact_bundle_identity.sql new file mode 100644 index 0000000..0eedb23 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/resources/db/migration/V9__sourcemap_exact_bundle_identity.sql @@ -0,0 +1,23 @@ +-- Source Map 必须按完整 Bundle 身份精确匹配,不允许按版本或“最新上传”兜底。 +-- 旧记录缺少 moduleVersion/bundleHash,无法可靠符号化,必须重新上传新格式产物。 +DELETE FROM log_sourcemaps; + +ALTER TABLE log_sourcemaps DROP INDEX uk_map; +ALTER TABLE log_sourcemaps + CHANGE COLUMN bundle_name module_id VARCHAR(128) NOT NULL, + ADD COLUMN module_version VARCHAR(32) NOT NULL DEFAULT '' AFTER module_id, + ADD COLUMN bundle_hash VARCHAR(64) NULL AFTER module_version, + ADD COLUMN artifact_type VARCHAR(24) NOT NULL AFTER bundle_hash, + ADD COLUMN artifact_hash VARCHAR(64) NOT NULL AFTER artifact_type, + ADD COLUMN content_hash VARCHAR(64) NOT NULL AFTER artifact_hash, + MODIFY COLUMN build_id VARCHAR(64) NOT NULL; + +ALTER TABLE log_sourcemaps + ADD UNIQUE KEY uk_map_exact ( + app_key, platform, app_version, build_id, artifact_type, artifact_hash, module_id, module_version + ); + +ALTER TABLE log_issue_events + ADD COLUMN module_id VARCHAR(128) NULL AFTER build_id, + ADD COLUMN module_version VARCHAR(32) NULL AFTER module_id, + ADD COLUMN bundle_hash VARCHAR(64) NULL AFTER module_version; diff --git a/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/config/ArtifactApiTokenFilterTest.java b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/config/ArtifactApiTokenFilterTest.java new file mode 100644 index 0000000..c3b7afa --- /dev/null +++ b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/config/ArtifactApiTokenFilterTest.java @@ -0,0 +1,82 @@ +package com.xuqm.bugcollect.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.xuqm.common.security.ApiKeyAuthFilter; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +class ArtifactApiTokenFilterTest { + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void missingBearerTokenReturns401() throws Exception { + FilterResult result = execute(null, null, token -> "ak_test"); + + assertThat(result.response().getStatus()).isEqualTo(401); + assertThat(result.chainCalled()).isFalse(); + } + + @Test + void xApiKeyWithoutBearerReturns401() throws Exception { + FilterResult result = execute(null, "legacy-api-key", token -> "ak_test"); + + assertThat(result.response().getStatus()).isEqualTo(401); + assertThat(result.chainCalled()).isFalse(); + } + + @Test + void invalidBearerTokenReturns401() throws Exception { + FilterResult result = execute("invalid", null, token -> null); + + assertThat(result.response().getStatus()).isEqualTo(401); + assertThat(result.chainCalled()).isFalse(); + } + + @Test + void validBearerTokenAuthenticatesItsOwningApp() throws Exception { + FilterResult result = execute("valid-token", null, token -> "ak_test"); + + assertThat(result.response().getStatus()).isEqualTo(200); + assertThat(result.chainCalled()).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()) + .isEqualTo("apikey:ak_test"); + } + + private FilterResult execute( + String bearerToken, + String xApiKey, + com.xuqm.common.security.ApiKeyValidator validator) + throws Exception { + ApiKeyAuthFilter filter = new ApiKeyAuthFilter( + validator, + new AntPathRequestMatcher("/bugcollect/v1/artifacts/upload", "POST"), + true); + MockHttpServletRequest request = + new MockHttpServletRequest("POST", "/bugcollect/v1/artifacts/upload"); + request.setServletPath("/bugcollect/v1/artifacts/upload"); + if (bearerToken != null) { + request.addHeader("Authorization", "Bearer " + bearerToken); + } + if (xApiKey != null) { + request.addHeader(ApiKeyAuthFilter.HEADER_NAME, xApiKey); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(); + + filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> chainCalled.set(true)); + return new FilterResult(response, chainCalled.get()); + } + + private record FilterResult(MockHttpServletResponse response, boolean chainCalled) { + } +} diff --git a/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/controller/GlobalExceptionHandlerTest.java b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/controller/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..a79329a --- /dev/null +++ b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/controller/GlobalExceptionHandlerTest.java @@ -0,0 +1,18 @@ +package com.xuqm.bugcollect.controller; + +import com.xuqm.bugcollect.service.BugCollectDisabledException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + + @Test + void exposesStableDisabledMachineCode() { + var response = new GlobalExceptionHandler().handle(new BugCollectDisabledException()); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().message()).isEqualTo("BUGCOLLECT_DISABLED"); + } +} diff --git a/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/service/SourcemapServiceTest.java b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/service/SourcemapServiceTest.java new file mode 100644 index 0000000..4aeabc7 --- /dev/null +++ b/xuqm-bugcollect-service/src/test/java/com/xuqm/bugcollect/service/SourcemapServiceTest.java @@ -0,0 +1,74 @@ +package com.xuqm.bugcollect.service; + +import com.xuqm.bugcollect.entity.LogSourcemapEntity; +import com.xuqm.bugcollect.repository.LogSourcemapRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SourcemapServiceTest { + + private SourcemapService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + LogSourcemapRepository repository = mock(LogSourcemapRepository.class); + when(repository + .findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion( + any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(Optional.empty()); + when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(repository.findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc( + any(), any(), any(), any())).thenReturn(java.util.List.of()); + when(repository.findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc( + any(), any(), any())).thenReturn(java.util.List.of()); + service = new SourcemapService(repository); + ReflectionTestUtils.setField(service, "storageDir", tempDir.toString()); + } + + @Test + void rejectsAbsoluteAndTraversalSourcePaths() { + for (String source : new String[]{"/abs/index.ts", "C:\\abs\\index.ts", "../../secret.ts"}) { + MockMultipartFile file = sourceMap(source); + assertThatThrownBy(() -> service.upload( + "RN_SOURCEMAP", "ak_test", "android", "1.0.0", "build-1", + "app", "1.0.0", "a".repeat(64), null, file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("相对路径"); + } + } + + @Test + void acceptsRealR8MappingWithMatchingHash() throws Exception { + byte[] mapping = "com.example.Original -> a:\n".getBytes(StandardCharsets.UTF_8); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(mapping)); + MockMultipartFile file = new MockMultipartFile( + "file", "mapping.txt", "text/plain", mapping); + + service.upload("R8_MAPPING", "ak_test", "android", "1.0.0", "build-1", + null, null, null, hash, file); + } + + private MockMultipartFile sourceMap(String source) { + String json = "{\"version\":3,\"sources\":[\"" + source.replace("\\", "\\\\") + + "\"],\"names\":[],\"mappings\":\"AAAA\"}"; + return new MockMultipartFile( + "file", "app.map", "application/json", json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/xuqm-bugcollect-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/xuqm-bugcollect-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/xuqm-bugcollect-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass