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