From 8c9bfb6acd498a1692c5b203a5876e6c41b46196 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 22 May 2026 16:47:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20license=20=E6=96=87=E4=BB=B6=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E9=80=9A=E7=94=A8=E5=87=AD=E8=AF=81=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=9C=8D=E5=8A=A1=20SDK=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LicenseFileCrypto 移至 common 模块并新增 decrypt() 方法 - LicenseFileCrypto.LicensePayload 携带 appKey / packageName / iosBundleId / harmonyBundleName,matchesPackageName() 支持三端包名任一匹配 - tenant-service downloadLicenseFile:去掉"License 服务已开通"限制,app 创建即可下载;payload 新增 iosBundleId / harmonyBundleName - im / push / update / license 四个服务 SDK 初始化端点均支持双模式: · licenseFile 模式:解密文件取 appKey,比对 packageName(无需调 tenant-service) · appKey 模式:调 tenant-service 取 platformInfo 比对 packageName(原有逻辑) - appKey 参数由必填改为可选(与 licenseFile 二选一) Co-Authored-By: Claude Sonnet 4.6 --- .../common/security/LicenseFileCrypto.java | 112 ++++++++++++++++++ .../xuqm/im/controller/AuthController.java | 35 ++++-- .../controller/LicensePublicController.java | 28 ++++- .../push/controller/PushAuthController.java | 32 +++-- .../xuqm/tenant/controller/AppController.java | 15 ++- .../tenant/service/LicenseFileCrypto.java | 55 --------- .../controller/AppVersionController.java | 32 +++-- 7 files changed, 208 insertions(+), 101 deletions(-) create mode 100644 common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java delete mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java diff --git a/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java b/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java new file mode 100644 index 0000000..e144083 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/LicenseFileCrypto.java @@ -0,0 +1,112 @@ +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/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java index cfd956d..1a16857 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,6 +2,7 @@ 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 jakarta.validation.constraints.NotBlank; @@ -27,28 +28,36 @@ public class AuthController { @PostMapping("/login") public ResponseEntity>> login( - @RequestParam @NotBlank String appKey, + @RequestParam(required = false) String appKey, @RequestParam @NotBlank String userId, @RequestParam @NotBlank String userSig, - @RequestParam @NotBlank String packageName) { - if (userSig.isBlank()) { - return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig")); - } - validatePackageName(appKey, packageName); - ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig); + @RequestParam @NotBlank String packageName, + @RequestParam(required = false) String licenseFile) { + String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile); + ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig); return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin()))); } - private void validatePackageName(String appKey, String packageName) { + 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(); + } + if (!hasText(appKey)) { + throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + } ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey); - String android = info.androidPackageName(); - String ios = info.iosBundleId(); - String harmony = info.harmonyBundleName(); - boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony); + boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName()); if (anyConfigured) { - boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony); + boolean matches = packageName.equals(info.androidPackageName()) + || packageName.equals(info.iosBundleId()) + || packageName.equals(info.harmonyBundleName()); if (!matches) throw new BusinessException(403, "包名与应用配置不匹配"); } + return appKey; } private static boolean hasText(String s) { return s != null && !s.isBlank(); } 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 index 6fb8bd1..d2c832f 100644 --- a/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java +++ b/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java @@ -3,7 +3,9 @@ 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.service.DeviceService; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -27,8 +29,9 @@ public class LicensePublicController { @PostMapping("/register") public ResponseEntity>> register(@Valid @RequestBody RegisterRequest req) { + String resolvedAppKey = resolveAppKey(req.appKey(), req.packageName(), req.licenseFile()); DeviceService.RegisterResult result = deviceService.register( - req.appKey(), + resolvedAppKey, req.packageName(), req.deviceId(), req.deviceName(), @@ -47,7 +50,8 @@ public class LicensePublicController { @PostMapping("/verify") public ResponseEntity>> verify(@Valid @RequestBody VerifyRequest req) { - DeviceService.VerifyResult result = deviceService.verify(req.appKey(), req.packageName(), req.deviceId(), req.token(), req.userInfo()); + String resolvedAppKey = resolveAppKey(req.appKey(), req.packageName(), req.licenseFile()); + DeviceService.VerifyResult result = deviceService.verify(resolvedAppKey, req.packageName(), req.deviceId(), req.token(), req.userInfo()); Map data = new java.util.LinkedHashMap<>(); data.put("valid", result.valid()); if (result.error() != null) { @@ -56,9 +60,24 @@ public class LicensePublicController { return ResponseEntity.ok(ApiResponse.success(data)); } + private static String resolveAppKey(String appKey, String packageName, String licenseFile) { + if (licenseFile != null && !licenseFile.isBlank()) { + LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); + if (!payload.matchesPackageName(packageName)) { + throw new BusinessException(403, "包名与应用配置不匹配"); + } + return payload.appKey(); + } + if (appKey == null || appKey.isBlank()) { + throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + } + return appKey; + } + public record RegisterRequest( - @NotBlank String appKey, + String appKey, @NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName, + @JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile, @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, @JsonProperty("deviceName") @JsonAlias("device_name") String deviceName, @JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel, @@ -68,8 +87,9 @@ public class LicensePublicController { ) {} public record VerifyRequest( - @NotBlank String appKey, + String appKey, @NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName, + @JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile, @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, @NotBlank String token, @JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo 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 f8350c2..08f3d42 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,6 +2,7 @@ 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; @@ -31,10 +32,11 @@ public class PushAuthController { @PostMapping("/login") public ResponseEntity>> login( - @RequestParam String appKey, + @RequestParam(required = false) String appKey, @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, @@ -44,9 +46,9 @@ public class PushAuthController { @RequestParam(required = false) String osVersion, @RequestParam(required = false) String appVersion) { - validatePackageName(appKey, packageName); - PushAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig); - pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); + String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile); + PushAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig); + pushDispatcher.registerToken(resolvedAppKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); Map response = Map.of( "pushToken", result.pushToken(), @@ -57,19 +59,29 @@ public class PushAuthController { return ResponseEntity.ok(ApiResponse.success(response)); } - private void validatePackageName(String appKey, String packageName) { + 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(); + } + if (!hasText(appKey)) { + throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + } if (packageName == null || packageName.isBlank()) { throw new BusinessException(403, "packageName is required"); } PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey); - String android = info.androidPackageName(); - String ios = info.iosBundleId(); - String harmony = info.harmonyBundleName(); - boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony); + boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName()); if (anyConfigured) { - boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony); + boolean matches = packageName.equals(info.androidPackageName()) + || packageName.equals(info.iosBundleId()) + || packageName.equals(info.harmonyBundleName()); if (!matches) throw new BusinessException(403, "包名与应用配置不匹配"); } + return appKey; } private static boolean hasText(String s) { return s != null && !s.isBlank(); } 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 2ee207d..ecce96f 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 @@ -11,7 +11,7 @@ import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.AppUserClient; import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.FeatureServiceManager; -import com.xuqm.tenant.service.LicenseFileCrypto; +import com.xuqm.common.security.LicenseFileCrypto; import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.service.OperationLogService; import jakarta.validation.Valid; @@ -172,18 +172,17 @@ public class AppController { public ResponseEntity downloadLicenseFile(@PathVariable String appKey, @AuthenticationPrincipal String tenantId) { AppEntity app = appService.getByAppKey(appKey, tenantId); - boolean licenseEnabled = featureServiceManager.listByApp(appKey).stream() - .anyMatch(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE && service.isEnabled()); - if (!licenseEnabled) { - throw new BusinessException(400, "License 服务未开通"); - } Map payload = new java.util.LinkedHashMap<>(); payload.put("appKey", app.getAppKey()); payload.put("appName", app.getName()); payload.put("packageName", app.getPackageName()); + if (app.getIosBundleId() != null && !app.getIosBundleId().isBlank()) { + payload.put("iosBundleId", app.getIosBundleId()); + } + if (app.getHarmonyBundleName() != null && !app.getHarmonyBundleName().isBlank()) { + payload.put("harmonyBundleName", app.getHarmonyBundleName()); + } payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl)); - // serverUrl is set only for private deployments; the SDK uses it to configure all service - // endpoints automatically via XuqmSDK.autoInitialize(). Public deployments use default endpoints. if (deployProps.isPrivate()) { payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl)); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java deleted file mode 100644 index 94b3e6e..0000000 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.xuqm.tenant.service; - -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 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); - } - } - - 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; - } -} 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 6e6983f..e28d91a 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 @@ -23,6 +23,7 @@ import com.xuqm.update.service.AppStoreService; import com.xuqm.update.service.ImPushUserClient; import com.xuqm.update.service.UpdateTenantClient; import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.LicenseFileCrypto; @RestController @RequestMapping("/api/v1/updates") @@ -56,21 +57,22 @@ public class AppVersionController { @GetMapping("/app/check") public ResponseEntity>> checkUpdate( - @RequestParam String appKey, + @RequestParam(required = false) String appKey, @RequestParam AppVersionEntity.Platform platform, @RequestParam int currentVersionCode, @RequestParam @jakarta.validation.constraints.NotBlank String packageName, + @RequestParam(required = false) String licenseFile, @RequestParam(required = false) String userId) { - validatePackageName(appKey, platform, packageName); - boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey); + String resolvedAppKey = resolveAndValidate(appKey, platform, packageName, licenseFile); + boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey); Optional latest = versionRepository .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( - appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); + resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); Optional forcedHigher = versionRepository .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( - appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); + resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); if (latest.isEmpty()) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); @@ -78,8 +80,6 @@ public class AppVersionController { AppVersionEntity v = latest.get(); - // Gray release: userId is required when anonymous checks are disabled and version is gray-targeted. - // Non-gray published versions are visible to all callers regardless of userId. if (v.isGrayEnabled()) { if (!allowAnonymousCheck && (userId == null || userId.isBlank())) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); @@ -91,16 +91,15 @@ public class AppVersionController { } } } else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) { - // App explicitly requires login to check for updates even without gray targeting. return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); } String appStoreJumpUrl = hasText(v.getAppStoreUrl()) ? v.getAppStoreUrl() - : appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); + : appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); String harmonyJumpUrl = hasText(v.getMarketUrl()) ? v.getMarketUrl() - : appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); + : appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", true, "versionName", v.getVersionName(), @@ -495,7 +494,17 @@ public class AppVersionController { return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT"; } - private void validatePackageName(String appKey, AppVersionEntity.Platform platform, String packageName) { + private String resolveAndValidate(String appKey, AppVersionEntity.Platform platform, String packageName, String licenseFile) { + if (hasText(licenseFile)) { + LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile); + if (!payload.matchesPackageName(packageName)) { + throw new BusinessException(403, "包名与应用配置不匹配"); + } + return payload.appKey(); + } + if (!hasText(appKey)) { + throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个"); + } UpdateTenantClient.PlatformInfo info = tenantClient.getPlatformInfo(appKey); String registered = switch (platform) { case IOS -> info.iosBundleId(); @@ -505,6 +514,7 @@ public class AppVersionController { if (hasText(registered) && !registered.equals(packageName)) { throw new BusinessException(403, "包名与应用配置不匹配"); } + return appKey; } private boolean hasText(String value) {