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.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<ApiResponse<Map<String, Object>>> 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(); }

查看文件

@ -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<ApiResponse<Map<String, Object>>> 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<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<>();
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

查看文件

@ -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<ApiResponse<Map<String, Object>>> 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<String, Object> 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(); }

查看文件

@ -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<byte[]> 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<String, Object> 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));
}

查看文件

@ -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.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<ApiResponse<Map<String, Object>>> 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<AppVersionEntity> latest = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
Optional<AppVersionEntity> 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) {