feat: license 文件作为通用凭证支持所有服务 SDK 初始化

- 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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 16:47:30 +08:00
父节点 0a267c5f70
当前提交 8c9bfb6acd
共有 7 个文件被更改,包括 208 次插入101 次删除

查看文件

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

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.LicenseFileCrypto;
import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.ImAppSecretClient; import com.xuqm.im.service.ImAppSecretClient;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@ -27,28 +28,36 @@ public class AuthController {
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login( public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam @NotBlank String appKey, @RequestParam(required = false) String appKey,
@RequestParam @NotBlank String userId, @RequestParam @NotBlank String userId,
@RequestParam @NotBlank String userSig, @RequestParam @NotBlank String userSig,
@RequestParam @NotBlank String packageName) { @RequestParam @NotBlank String packageName,
if (userSig.isBlank()) { @RequestParam(required = false) String licenseFile) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig")); String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile);
} ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig);
validatePackageName(appKey, packageName);
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin()))); 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); ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
String android = info.androidPackageName(); boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName());
String ios = info.iosBundleId();
String harmony = info.harmonyBundleName();
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
if (anyConfigured) { 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, "包名与应用配置不匹配"); if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
} }
return appKey;
} }
private static boolean hasText(String s) { return s != null && !s.isBlank(); } private static boolean hasText(String s) { return s != null && !s.isBlank(); }

查看文件

@ -3,7 +3,9 @@ package com.xuqm.license.controller;
import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.LicenseFileCrypto;
import com.xuqm.license.service.DeviceService; import com.xuqm.license.service.DeviceService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@ -27,8 +29,9 @@ public class LicensePublicController {
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@Valid @RequestBody RegisterRequest req) { public ResponseEntity<ApiResponse<Map<String, Object>>> register(@Valid @RequestBody RegisterRequest req) {
String resolvedAppKey = resolveAppKey(req.appKey(), req.packageName(), req.licenseFile());
DeviceService.RegisterResult result = deviceService.register( DeviceService.RegisterResult result = deviceService.register(
req.appKey(), resolvedAppKey,
req.packageName(), req.packageName(),
req.deviceId(), req.deviceId(),
req.deviceName(), req.deviceName(),
@ -47,7 +50,8 @@ public class LicensePublicController {
@PostMapping("/verify") @PostMapping("/verify")
public ResponseEntity<ApiResponse<Map<String, Object>>> verify(@Valid @RequestBody VerifyRequest req) { public ResponseEntity<ApiResponse<Map<String, Object>>> 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<String, Object> data = new java.util.LinkedHashMap<>(); Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("valid", result.valid()); data.put("valid", result.valid());
if (result.error() != null) { if (result.error() != null) {
@ -56,9 +60,24 @@ public class LicensePublicController {
return ResponseEntity.ok(ApiResponse.success(data)); 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( public record RegisterRequest(
@NotBlank String appKey, String appKey,
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName, @NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
@JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile,
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
@JsonProperty("deviceName") @JsonAlias("device_name") String deviceName, @JsonProperty("deviceName") @JsonAlias("device_name") String deviceName,
@JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel, @JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel,
@ -68,8 +87,9 @@ public class LicensePublicController {
) {} ) {}
public record VerifyRequest( public record VerifyRequest(
@NotBlank String appKey, String appKey,
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName, @NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
@JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile,
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
@NotBlank String token, @NotBlank String token,
@JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo @JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo

查看文件

@ -2,6 +2,7 @@ package com.xuqm.push.controller;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.LicenseFileCrypto;
import com.xuqm.push.entity.DeviceTokenEntity; import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.service.PushAccountService; import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushAppSecretClient; import com.xuqm.push.service.PushAppSecretClient;
@ -31,10 +32,11 @@ public class PushAuthController {
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login( public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam String appKey, @RequestParam(required = false) String appKey,
@RequestParam String userId, @RequestParam String userId,
@RequestParam String userSig, @RequestParam String userSig,
@RequestParam String packageName, @RequestParam String packageName,
@RequestParam(required = false) String licenseFile,
@RequestParam DeviceTokenEntity.Vendor vendor, @RequestParam DeviceTokenEntity.Vendor vendor,
@RequestParam String token, @RequestParam String token,
@RequestParam(required = false) String platform, @RequestParam(required = false) String platform,
@ -44,9 +46,9 @@ public class PushAuthController {
@RequestParam(required = false) String osVersion, @RequestParam(required = false) String osVersion,
@RequestParam(required = false) String appVersion) { @RequestParam(required = false) String appVersion) {
validatePackageName(appKey, packageName); String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile);
PushAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig); PushAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig);
pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); pushDispatcher.registerToken(resolvedAppKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
Map<String, Object> response = Map.of( Map<String, Object> response = Map.of(
"pushToken", result.pushToken(), "pushToken", result.pushToken(),
@ -57,19 +59,29 @@ public class PushAuthController {
return ResponseEntity.ok(ApiResponse.success(response)); 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()) { if (packageName == null || packageName.isBlank()) {
throw new BusinessException(403, "packageName is required"); throw new BusinessException(403, "packageName is required");
} }
PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey); PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
String android = info.androidPackageName(); boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName());
String ios = info.iosBundleId();
String harmony = info.harmonyBundleName();
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
if (anyConfigured) { 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, "包名与应用配置不匹配"); if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
} }
return appKey;
} }
private static boolean hasText(String s) { return s != null && !s.isBlank(); } private static boolean hasText(String s) { return s != null && !s.isBlank(); }

查看文件

@ -11,7 +11,7 @@ import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.AppUserClient; import com.xuqm.tenant.service.AppUserClient;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.FeatureServiceManager; 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.config.PrivateDeploymentProperties;
import com.xuqm.tenant.service.OperationLogService; import com.xuqm.tenant.service.OperationLogService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -172,18 +172,17 @@ public class AppController {
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey, public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
AppEntity app = appService.getByAppKey(appKey, 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<String, Object> payload = new java.util.LinkedHashMap<>(); Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("appKey", app.getAppKey()); payload.put("appKey", app.getAppKey());
payload.put("appName", app.getName()); payload.put("appName", app.getName());
payload.put("packageName", app.getPackageName()); 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)); 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()) { if (deployProps.isPrivate()) {
payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl)); payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl));
} }

查看文件

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

查看文件

@ -23,6 +23,7 @@ import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient; import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.UpdateTenantClient; import com.xuqm.update.service.UpdateTenantClient;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.LicenseFileCrypto;
@RestController @RestController
@RequestMapping("/api/v1/updates") @RequestMapping("/api/v1/updates")
@ -56,21 +57,22 @@ public class AppVersionController {
@GetMapping("/app/check") @GetMapping("/app/check")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate( public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appKey, @RequestParam(required = false) String appKey,
@RequestParam AppVersionEntity.Platform platform, @RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode, @RequestParam int currentVersionCode,
@RequestParam @jakarta.validation.constraints.NotBlank String packageName, @RequestParam @jakarta.validation.constraints.NotBlank String packageName,
@RequestParam(required = false) String licenseFile,
@RequestParam(required = false) String userId) { @RequestParam(required = false) String userId) {
validatePackageName(appKey, platform, packageName); String resolvedAppKey = resolveAndValidate(appKey, platform, packageName, licenseFile);
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey); boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
Optional<AppVersionEntity> latest = versionRepository Optional<AppVersionEntity> latest = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
Optional<AppVersionEntity> forcedHigher = versionRepository Optional<AppVersionEntity> forcedHigher = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
if (latest.isEmpty()) { if (latest.isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
@ -78,8 +80,6 @@ public class AppVersionController {
AppVersionEntity v = latest.get(); 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 (v.isGrayEnabled()) {
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) { if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
@ -91,16 +91,15 @@ public class AppVersionController {
} }
} }
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) { } 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))); return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
} }
String appStoreJumpUrl = hasText(v.getAppStoreUrl()) String appStoreJumpUrl = hasText(v.getAppStoreUrl())
? 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()) String harmonyJumpUrl = hasText(v.getMarketUrl())
? 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( return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", true, "needsUpdate", true,
"versionName", v.getVersionName(), "versionName", v.getVersionName(),
@ -495,7 +494,17 @@ public class AppVersionController {
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT"; 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); UpdateTenantClient.PlatformInfo info = tenantClient.getPlatformInfo(appKey);
String registered = switch (platform) { String registered = switch (platform) {
case IOS -> info.iosBundleId(); case IOS -> info.iosBundleId();
@ -505,6 +514,7 @@ public class AppVersionController {
if (hasText(registered) && !registered.equals(packageName)) { if (hasText(registered) && !registered.equals(packageName)) {
throw new BusinessException(403, "包名与应用配置不匹配"); throw new BusinessException(403, "包名与应用配置不匹配");
} }
return appKey;
} }
private boolean hasText(String value) { private boolean hasText(String value) {