feat(platform): implement signed sdk config and artifacts

这个提交包含在:
XuqmGroup 2026-07-26 23:45:08 +08:00
父节点 ea68030903
当前提交 c51cdd46f4
共有 82 个文件被更改,包括 1679 次插入2238 次删除

查看文件

@ -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 收集、错误追踪、漏斗分析 |
## 技术栈

查看文件

@ -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 \

55
Jenkinsfile vendored
查看文件

@ -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 ❌ 构建失败,请检查日志' }

查看文件

@ -1 +0,0 @@
1.0.0

查看文件

@ -42,5 +42,10 @@
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

查看文件

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

查看文件

@ -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.&lt;salt&gt;.&lt;iv&gt;.&lt;ciphertext&gt;).
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
* This is separate from LicenseFileCrypto (device activation).
* XUQM-CONFIG-V2 配置文件编解码器
*
* <p>AES-GCM 仅用于避免配置正文直接暴露Ed25519 签名才是配置来源可信的依据
* 签名私钥只允许保存在租户平台服务端客户端与构建工具只持有公钥</p>
*/
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();
}
}
}

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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.<keyId>.<salt>.<iv>.<ciphertext>.<signature>
```
- 内容加密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 <XUQM_API_TOKEN>
```
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-serviceV3 Config V2 元数据、V4 平台专属 App 包名、V5 移除离线 license 服务数据和枚举值。
- xuqm-bugcollect-serviceV9 引入精确 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 签名凭据以及生产主机运行环境变量必须由有权限的维护者配置后,才能进行生产发布。

查看文件

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

查看文件

@ -1,85 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xuqm</groupId>
<artifactId>xuqmgroup-server-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>license-service</artifactId>
<name>license-service</name>
<description>Standalone PAD license server: device registration, token verification, company management</description>
<dependencies>
<dependency>
<groupId>com.xuqm</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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<ApiResponse<Map<String, Object>>> getAppLicense(@PathVariable String appKey) {
AppLicenseEntity license = appLicenseService.getByAppKey(appKey);
List<DeviceEntity> devices = deviceService.listByApp(appKey);
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("license", license);
data.put("devices", devices);
return ResponseEntity.ok(ApiResponse.success(data));
}
@PatchMapping("/apps/{appKey}")
public ResponseEntity<ApiResponse<AppLicenseEntity>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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
) {}
}

查看文件

@ -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<ApiResponse<Map<String, Object>>> 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<String, Object> 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<ApiResponse<List<DeviceEntity>>> 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<ApiResponse<AppLicenseEntity>> 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<ApiResponse<List<DeviceEntity>>> 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<DeviceEntity> 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
) {}
}

查看文件

@ -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<ApiResponse<Map<String, Object>>> list(
@RequestParam String appKey,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<LicenseOperationLogEntity> result = logService.list(appKey, page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
}

查看文件

@ -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<ApiResponse<Map<String, Object>>> 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<String, Object> 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<ApiResponse<Map<String, Object>>> 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<String, Object> 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<ApiResponse<Map<String, Object>>> appInfo(@RequestParam String appKey) {
AppLicenseEntity license;
try {
license = appLicenseService.getByAppKey(appKey);
} catch (BusinessException e) {
throw new BusinessException(404, "App not found");
}
Map<String, Object> 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
) {}
}

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -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<AppLicenseEntity, String> {
}

查看文件

@ -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<DeviceEntity, String> {
Optional<DeviceEntity> findByAppKeyAndDeviceId(String appKey, String deviceId);
List<DeviceEntity> findByDeviceId(String deviceId);
List<DeviceEntity> findByAppKeyOrderByRegisteredAtDesc(String appKey);
long countByAppKeyAndIsActiveTrue(String appKey);
}

查看文件

@ -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<LicenseOperationLogEntity, String> {
Page<LicenseOperationLogEntity> findByAppKeyOrderByCreatedAtDesc(String appKey, Pageable pageable);
}

查看文件

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

查看文件

@ -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<DeviceEntity> findByDeviceId(String deviceId) {
return deviceRepository.findByDeviceId(deviceId);
}
public List<DeviceEntity> 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<DeviceEntity> 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) {}
}

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -20,7 +20,6 @@
<module>update-service</module>
<module>demo-service</module>
<module>file-service</module>
<module>license-service</module>
<module>xuqm-bugcollect-service</module>
</modules>

查看文件

@ -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");

查看文件

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

查看文件

@ -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<ApiResponse<Void>> regenerateConfigFile(@PathVariable String appKey,
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<String, Object> 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]+", "_");
}
}

查看文件

@ -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(

查看文件

@ -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<ApiResponse<Map<String, String>>> 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) {
}
}

查看文件

@ -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<String, Object> body = new LinkedHashMap<>();
body.put("mode", deployProps.getMode());

查看文件

@ -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<ApiResponse<AppService.BuildConfigValidation>> 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) {
}
}

查看文件

@ -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<String, Boolean> features,
boolean updateEnabled,
boolean updateRequiresLogin,
String updateDefaultPublishMode,
boolean updateDefaultPublishImmediately,
String updateDefaultScheduledPublishAt,

查看文件

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

查看文件

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

查看文件

@ -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<AppEntity> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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());
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 + "/";

查看文件

@ -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;
/**
* 集中管理租户平台配置签名密钥
*
* <p>私钥必须通过服务端密钥管理或环境变量注入禁止写入源码配置文件或数据库
* 公钥用于平台自身解析与构建期校验客户端 SDK 使用同一公钥验签</p>
*/
@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();
}
}

查看文件

@ -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,17 +168,6 @@ public class FeatureServiceManager {
if (isAppWideService(req.getServiceType())) {
List<FeatureServiceEntity> services = repository.findByAppKeyAndServiceType(normalizedAppId, req.getServiceType());
if (services.isEmpty()) {
if (req.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE) {
// LICENSE 不分平台只创建一条记录
FeatureServiceEntity created = new FeatureServiceEntity();
created.setId(UUID.randomUUID().toString());
created.setAppKey(normalizedAppId);
created.setPlatform(FeatureServiceEntity.Platform.ANDROID);
created.setServiceType(FeatureServiceEntity.ServiceType.LICENSE);
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());
@ -193,16 +178,10 @@ public class FeatureServiceManager {
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<FeatureServiceEntity> 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()

查看文件

@ -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<String> 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<Map<String, Object>> listDevices(String appKey) {
try {
ResponseEntity<String> response = callInternal("/api/license/internal/apps/" + appKey + "/devices", HttpMethod.GET, null);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
JsonNode node = objectMapper.readTree(response.getBody());
return objectMapper.convertValue(node.path("data"), new com.fasterxml.jackson.core.type.TypeReference<>() {});
}
} catch (Exception e) {
// ignore
}
return List.of();
}
public void syncAppLicense(String appKey, String name, Integer maxDevices) {
syncAppLicense(appKey, name, maxDevices, null, null, null, null);
}
public void syncAppLicense(String appKey, String name, Integer maxDevices, String expiresAt) {
syncAppLicense(appKey, name, maxDevices, expiresAt, null, null, null);
}
public void syncAppLicense(String appKey, String name, Integer maxDevices, String expiresAt,
String androidPackageName, String iosBundleId, String harmonyBundleName) {
try {
Map<String, Object> body = new java.util.HashMap<>();
body.put("id", appKey);
body.put("name", name);
body.put("maxDevices", maxDevices != null ? maxDevices : 1);
if (expiresAt != null && !expiresAt.isBlank()) {
body.put("expiresAt", expiresAt);
}
if (androidPackageName != null) body.put("androidPackageName", androidPackageName);
if (iosBundleId != null) body.put("iosBundleId", iosBundleId);
if (harmonyBundleName != null) body.put("harmonyBundleName", harmonyBundleName);
callInternal("/api/license/internal/apps", HttpMethod.POST, body);
} catch (Exception e) {
// ignore
}
}
private ResponseEntity<String> callInternal(String path, HttpMethod method, Object body) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Object> entity = new HttpEntity<>(body, headers);
return restTemplate.exchange(licenseBaseUrl + path, method, entity, String.class);
}
}

查看文件

@ -46,7 +46,7 @@ public class SystemUpdateService {
// nginx 最后重启确保它能获取到其他服务修复后的配置
private static final List<String> OTHER_SERVICES = List.of(
"file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "bugcollect-service", "nginx"
"file-service", "tenant-web", "im-service", "push-service", "update-service", "bugcollect-service", "nginx"
);
// 镜像名与 compose service 名不一致的例外历史命名遗留
@ -66,7 +66,7 @@ public class SystemUpdateService {
private static final Set<String> ALLOWED_LOG_SERVICES = Set.of(
"tenant-service", "file-service", "im-service", "push-service",
"update-service", "license-service", "bugcollect-service", "nginx", "tenant-web"
"update-service", "bugcollect-service", "nginx", "tenant-web"
);
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
@ -633,8 +633,6 @@ public class SystemUpdateService {
return;
}
migrate_v20260101_drop_device_id_unique_index(emit);
migrate_v20260527_push_license_operation_logs(emit);
migrate_v20260527_fix_orphan_tenant_data(emit);
migrate_v20260610_gray_mode_simplify_bookmark(emit);
// 新版本迁移在此追加例如
@ -684,59 +682,6 @@ public class SystemUpdateService {
// 各版本迁移
/**
* license-service DeviceEntity 上的 column-level unique=true 在多租户场景下产生了跨 appKey
* 全局唯一约束与正确的复合唯一索引 uk_app_key_device_id(app_key, device_id) 冲突
* Hibernate ddl-auto:update 不删除多余约束必须手动 ALTER TABLE
* 根治方案已同步移除 DeviceEntity.deviceId 上的 unique=true 注解新安装不再产生该约束
*/
private void migrate_v20260101_drop_device_id_unique_index(Consumer<String> emit) {
final String id = "v20260101_drop_device_id_unique_index";
if (migrationApplied(id)) {
emit.accept(" [已应用] " + id);
return;
}
try (Connection conn = dataSource.getConnection()) {
boolean exists;
try (PreparedStatement ps = conn.prepareStatement("""
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'devices'
AND INDEX_NAME = 'device_id'
AND NON_UNIQUE = 0
""");
ResultSet rs = ps.executeQuery()) {
exists = rs.next() && rs.getInt(1) > 0;
}
if (exists) {
try (Statement stmt = conn.createStatement()) {
stmt.execute("ALTER TABLE devices DROP INDEX device_id");
}
emit.accept(" [已迁移] " + id + ": 删除 devices.device_id 旧单列唯一约束");
} else {
emit.accept(" [已迁移] " + id + ": devices.device_id 单列约束不存在,无需处理");
}
recordMigration(id, "删除 devices 表 device_id 旧单列唯一约束");
} catch (Exception e) {
emit.accept(" [错误] " + id + ": " + e.getMessage());
log.error("migration {} failed", id, e);
}
}
/**
* push-service license-service 新增操作日志表push_operation_log / license_operation_log
* 实际表结构由各服务的 SchemaMigrationService 在各自数据库中创建此处仅记录版本标记
*/
private void migrate_v20260527_push_license_operation_logs(Consumer<String> emit) {
final String id = "v20260527_push_license_operation_logs";
if (migrationApplied(id)) {
emit.accept(" [已应用] " + id);
return;
}
emit.accept(" [已迁移] " + id + ": push/license 操作日志表已由各服务自行创建");
recordMigration(id, "push-service 和 license-service 新增操作日志表");
}
/**
* 私有化部署下修复租户数据
* 1. 多个租户 保留最早的删除其余

查看文件

@ -0,0 +1,88 @@
package com.xuqm.tenant.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/**
* 读取版本服务的权威发布配置读取失败时默认要求登录避免错误放宽灰度访问边界
*/
@Service
public class UpdatePublishConfigClient {
private static final Logger log = LoggerFactory.getLogger(UpdatePublishConfigClient.class);
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final String updateServiceBaseUrl;
private final Duration cacheTtl;
private final Map<String, CachedRequirement> cache = new ConcurrentHashMap<>();
public UpdatePublishConfigClient(
ObjectMapper objectMapper,
RestTemplateBuilder restTemplateBuilder,
@Value("${sdk.update-service-base-url:http://update-service:8084}") String updateServiceBaseUrl,
@Value("${sdk.update-service-connect-timeout:500ms}") Duration connectTimeout,
@Value("${sdk.update-service-read-timeout:800ms}") Duration readTimeout,
@Value("${sdk.update-service-cache-ttl:15s}") Duration cacheTtl) {
this.objectMapper = objectMapper;
this.restTemplate = restTemplateBuilder
.connectTimeout(connectTimeout)
.readTimeout(readTimeout)
.build();
this.updateServiceBaseUrl = updateServiceBaseUrl;
this.cacheTtl = cacheTtl;
}
UpdatePublishConfigClient(
ObjectMapper objectMapper,
RestTemplate restTemplate,
String updateServiceBaseUrl,
Duration cacheTtl) {
this.objectMapper = objectMapper;
this.restTemplate = restTemplate;
this.updateServiceBaseUrl = updateServiceBaseUrl;
this.cacheTtl = cacheTtl;
}
public boolean requiresLogin(String appKey) {
Instant now = Instant.now();
CachedRequirement cached = cache.get(appKey);
if (cached != null && now.isBefore(cached.expiresAt())) {
return cached.requiresLogin();
}
String uri = UriComponentsBuilder
.fromHttpUrl(updateServiceBaseUrl + "/api/v1/updates/publish/config")
.queryParam("appKey", appKey)
.build()
.toUriString();
try {
String body = restTemplate.getForObject(uri, String.class);
JsonNode data = objectMapper.readTree(body).path("data");
JsonNode config = data.path("configJson");
JsonNode publishConfig = config.isTextual()
? objectMapper.readTree(config.asText())
: config;
boolean requiresLogin = !publishConfig.path("allowAnonymousUpdateCheck").asBoolean(false);
cache.put(appKey, new CachedRequirement(requiresLogin, now.plus(cacheTtl)));
return requiresLogin;
} catch (Exception e) {
log.warn("读取更新发布配置失败,按需要登录处理: appKey={} cause={}",
appKey, e.getClass().getSimpleName());
cache.put(appKey, new CachedRequirement(true, now.plus(cacheTtl)));
return true;
}
}
private record CachedRequirement(boolean requiresLogin, Instant expiresAt) {
}
}

查看文件

@ -70,12 +70,6 @@ jwt:
secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac}
expiration: 3153600000000
license:
service:
base-url: ${LICENSE_SERVICE_BASE_URL:http://license-service:8085}
public-base-url: ${LICENSE_PUBLIC_BASE_URL:https://auth.dev.xuqinmin.com/}
internal-token: ${LICENSE_INTERNAL_TOKEN:xuqm-license-internal-token}
captcha:
expire-seconds: 300
@ -108,6 +102,14 @@ sdk:
file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}
bugcollect-api-url: ${SDK_BUGCOLLECT_API_URL:https://dev.xuqinmin.com/api}
update-service-base-url: ${SDK_UPDATE_SERVICE_BASE_URL:http://update-service:8084}
update-service-connect-timeout: ${SDK_UPDATE_SERVICE_CONNECT_TIMEOUT:500ms}
update-service-read-timeout: ${SDK_UPDATE_SERVICE_READ_TIMEOUT:800ms}
update-service-cache-ttl: ${SDK_UPDATE_SERVICE_CACHE_TTL:15s}
config-signing:
key-id: ${SDK_CONFIG_SIGNING_KEY_ID:v2-primary}
private-key-base64: ${SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64:}
public-key-base64: ${SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64:}
im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform}
im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}
im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c}
@ -119,12 +121,10 @@ deployment:
enable-im: ${ENABLE_IM:false}
enable-push: ${ENABLE_PUSH:false}
enable-update: ${ENABLE_UPDATE:false}
enable-license: ${ENABLE_LICENSE:false}
enable-file: ${ENABLE_FILE:true}
im-domain: ${IM_DOMAIN:}
push-domain: ${PUSH_DOMAIN:}
update-domain: ${UPDATE_DOMAIN:}
license-domain: ${LICENSE_DOMAIN:}
tenant:
bootstrap:

查看文件

@ -0,0 +1,8 @@
-- XUQM-CONFIG-V2 的当前签发身份。旧配置只在新的 Release 构建校验时失效,
-- 已安装客户端运行时不查询这些字段。
ALTER TABLE t_app
ADD COLUMN config_file_id VARCHAR(64) NULL AFTER license_file_content,
ADD COLUMN config_file_revision BIGINT NOT NULL DEFAULT 0 AFTER config_file_id,
ADD COLUMN config_file_issued_at DATETIME(6) NULL AFTER config_file_revision,
ADD COLUMN config_file_expires_at DATETIME(6) NULL AFTER config_file_issued_at;

查看文件

@ -0,0 +1,2 @@
-- 产品允许仅配置 iOS 或鸿蒙包名,因此 Android package_name 不能强制非空。
ALTER TABLE t_app MODIFY COLUMN package_name VARCHAR(128) NULL;

查看文件

@ -0,0 +1,8 @@
DELETE FROM t_service_activation_request WHERE service_type = 'LICENSE';
DELETE FROM t_feature_service WHERE service_type = 'LICENSE';
ALTER TABLE t_service_activation_request
MODIFY service_type ENUM('FILE','IM','PUSH','UPDATE','BUG_COLLECT') NOT NULL;
ALTER TABLE t_feature_service
MODIFY service_type ENUM('FILE','IM','PUSH','UPDATE','BUG_COLLECT') NOT NULL;

查看文件

@ -83,16 +83,6 @@ server {
proxy_read_timeout 60s;
}
# License — 必须在通用 /api/ 之前
location /api/license/ {
set $svc license-service;
proxy_pass http://$svc:8085;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
# 文件上传下载 — 必须在通用 /api/ 之前
location /api/file/ {
set $svc file-service;

查看文件

@ -0,0 +1,35 @@
package com.xuqm.tenant.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.xuqm.tenant.service.ApiKeyService;
import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class InternalSdkControllerTest {
@Test
void validatesApiKeyFromProtectedPostBody() {
ApiKeyService apiKeyService = mock(ApiKeyService.class);
when(apiKeyService.validateApiKey("secret-api-token")).thenReturn("ak_test");
InternalSdkController controller = new InternalSdkController(
mock(SdkAppProvisioningService.class),
mock(FeatureServiceManager.class),
apiKeyService);
ReflectionTestUtils.setField(controller, "internalToken", "internal-only");
var response = controller.validateApiKey(
new InternalSdkController.ApiKeyValidationRequest("secret-api-token"),
"internal-only");
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().data()).containsEntry("appKey", "ak_test");
verify(apiKeyService).validateApiKey("secret-api-token");
}
}

查看文件

@ -0,0 +1,44 @@
package com.xuqm.tenant.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.web.client.RestTemplate;
class UpdatePublishConfigClientTest {
private static final String URL =
"http://update-service:8084/api/v1/updates/publish/config?appKey=ak_test";
@Test
void cachesAuthoritativeAnonymousSetting() {
RestTemplate restTemplate = Mockito.mock(RestTemplate.class);
when(restTemplate.getForObject(URL, String.class))
.thenReturn("{\"data\":{\"configJson\":{\"allowAnonymousUpdateCheck\":true}}}");
UpdatePublishConfigClient client = new UpdatePublishConfigClient(
new ObjectMapper(), restTemplate, "http://update-service:8084", Duration.ofMinutes(1));
assertThat(client.requiresLogin("ak_test")).isFalse();
assertThat(client.requiresLogin("ak_test")).isFalse();
verify(restTemplate, times(1)).getForObject(URL, String.class);
}
@Test
void failureIsCachedAsSafeLoginRequirement() {
RestTemplate restTemplate = Mockito.mock(RestTemplate.class);
when(restTemplate.getForObject(URL, String.class))
.thenThrow(new IllegalStateException("update service unavailable"));
UpdatePublishConfigClient client = new UpdatePublishConfigClient(
new ObjectMapper(), restTemplate, "http://update-service:8084", Duration.ofMinutes(1));
assertThat(client.requiresLogin("ak_test")).isTrue();
assertThat(client.requiresLogin("ak_test")).isTrue();
verify(restTemplate, times(1)).getForObject(URL, String.class);
}
}

查看文件

@ -0,0 +1 @@
mock-maker-subclass

查看文件

@ -1,52 +0,0 @@
server:
port: 8084
spring:
application:
name: update-service
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://39.107.53.187:3306/xuqm_update?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true}
username: ${SPRING_DATASOURCE_USERNAME:xuqm}
password: ${SPRING_DATASOURCE_PASSWORD:Xuqm@2026}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
idle-timeout: 300000
max-lifetime: 900000
jpa:
hibernate:
ddl-auto: validate
show-sql: false
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
locations: classpath:db/migration
table: flyway_history_update
jwt:
secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac}
expiration: 3153600000000
update:
upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update}
base-url: ${UPDATE_BASE_URL:https://update.dev.xuqinmin.com}
sdk:
tenant-service-url: ${SDK_TENANT_SERVICE_URL:http://xuqm-tenant-service:9001}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always

查看文件

@ -38,14 +38,16 @@ public class TenantApiKeyValidator implements ApiKeyValidator {
if (cached != null) return cached;
try {
String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key?apiKey=" + apiKey;
String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key";
var headers = new org.springframework.http.HttpHeaders();
headers.set("X-Internal-Token", internalToken);
var entity = new org.springframework.http.HttpEntity<>(headers);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
var entity = new org.springframework.http.HttpEntity<>(
java.util.Map.of("apiKey", apiKey), headers);
var response = restTemplate.exchange(
url,
org.springframework.http.HttpMethod.GET,
org.springframework.http.HttpMethod.POST,
entity,
String.class
);
@ -60,7 +62,7 @@ public class TenantApiKeyValidator implements ApiKeyValidator {
return appKey;
}
} catch (Exception e) {
log.warn("API Key validation failed: {}", e.getMessage());
log.warn("API Key validation failed: {}", e.getClass().getSimpleName());
}
return null;
}

查看文件

@ -27,7 +27,6 @@ import com.xuqm.update.service.UpdateTenantClient;
import com.xuqm.update.handler.UpdateWebSocketHandler;
import com.xuqm.update.service.GrayMemberService;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.LicenseFileCrypto;
@RestController
@RequestMapping("/api/v1/updates")
@ -76,7 +75,6 @@ public class AppVersionController {
@RequestParam(required = false) String appKey,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode,
@RequestParam(required = false) String licenseFile,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String deviceId,
@RequestParam(required = false) String model,
@ -84,7 +82,7 @@ public class AppVersionController {
@RequestParam(required = false) String vendor,
@RequestParam(required = false) String currentVersionName) {
String resolvedAppKey = resolveAndValidate(appKey, platform, licenseFile);
String resolvedAppKey = requireAppKey(appKey);
// 异步记录设备信息不影响响应性能
recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode, currentVersionName);
@ -547,13 +545,9 @@ public class AppVersionController {
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
}
private String resolveAndValidate(String appKey, AppVersionEntity.Platform platform, String licenseFile) {
if (hasText(licenseFile)) {
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile);
return payload.appKey();
}
private String requireAppKey(String appKey) {
if (!hasText(appKey)) {
throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个");
throw new BusinessException(400, "appKey 不能为空");
}
return appKey;
}

查看文件

@ -1,16 +1,27 @@
package com.xuqm.bugcollect.config;
import com.xuqm.common.security.ApiKeyAuthFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final TenantApiKeyValidator apiKeyValidator;
public SecurityConfig(TenantApiKeyValidator apiKeyValidator) {
this.apiKeyValidator = apiKeyValidator;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@ -18,9 +29,19 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/**").permitAll()
.requestMatchers(HttpMethod.POST, "/bugcollect/v1/artifacts/upload").authenticated()
.requestMatchers("/bugcollect/**").permitAll()
.anyRequest().permitAll()
);
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(
(request, response, exception) ->
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))
.addFilterBefore(
new ApiKeyAuthFilter(
apiKeyValidator,
new AntPathRequestMatcher("/bugcollect/v1/artifacts/upload", "POST"),
true),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

查看文件

@ -0,0 +1,73 @@
package com.xuqm.bugcollect.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.security.ApiKeyValidator;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* 通过租户服务验证平台 API Token并返回其唯一归属 appKey
*/
@Component
public class TenantApiKeyValidator implements ApiKeyValidator {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final String tenantServiceUrl;
private final String internalToken;
private final Map<String, CachedAppKey> cache = new ConcurrentHashMap<>();
public TenantApiKeyValidator(
RestTemplateBuilder restTemplateBuilder,
ObjectMapper objectMapper,
@Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") String tenantServiceUrl,
@Value("${sdk.internal-token:}") String internalToken) {
this.restTemplate = restTemplateBuilder
.connectTimeout(Duration.ofMillis(500))
.readTimeout(Duration.ofMillis(800))
.build();
this.objectMapper = objectMapper;
this.tenantServiceUrl = tenantServiceUrl;
this.internalToken = internalToken;
}
@Override
public String resolveAppKey(String apiKey) {
CachedAppKey cached = cache.get(apiKey);
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
return cached.appKey();
}
try {
String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key";
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
String body = restTemplate.exchange(
url,
HttpMethod.POST,
new HttpEntity<>(Map.of("apiKey", apiKey), headers),
String.class).getBody();
JsonNode root = objectMapper.readTree(body);
String appKey = root.path("data").path("appKey").asText(null);
if (appKey != null && !appKey.isBlank()) {
cache.put(apiKey, new CachedAppKey(appKey, Instant.now().plusSeconds(30)));
}
return appKey;
} catch (Exception ignored) {
return null;
}
}
private record CachedAppKey(String appKey, Instant expiresAt) {
}
}

查看文件

@ -2,6 +2,7 @@ package com.xuqm.bugcollect.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.bugcollect.service.BugCollectDisabledException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -23,6 +24,12 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BugCollectDisabledException.class)
public ResponseEntity<ApiResponse<Void>> handle(BugCollectDisabledException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, BugCollectDisabledException.ERROR_CODE));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex, HttpServletRequest request) {
if (ex.getCode() >= 500) {

查看文件

@ -6,8 +6,11 @@ import com.xuqm.bugcollect.dto.*;
import com.xuqm.bugcollect.service.LogService;
import com.xuqm.bugcollect.service.SourcemapService;
import com.xuqm.bugcollect.service.WebhookService;
import com.xuqm.bugcollect.service.BugCollectAvailabilityService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -21,23 +24,41 @@ public class LogController {
private final LogService logService;
private final SourcemapService sourcemapService;
private final WebhookService webhookService;
private final BugCollectAvailabilityService availabilityService;
public LogController(LogService logService, SourcemapService sourcemapService, WebhookService webhookService) {
public LogController(LogService logService, SourcemapService sourcemapService,
WebhookService webhookService,
BugCollectAvailabilityService availabilityService) {
this.logService = logService;
this.sourcemapService = sourcemapService;
this.webhookService = webhookService;
this.availabilityService = availabilityService;
}
// Ingestion
@PostMapping("/issues/batch")
public ApiResponse<Void> ingestIssues(@Valid @RequestBody IssueBatchRequest request) {
request.events().stream()
.map(item -> item.appKey() + "\n" + item.platform())
.distinct()
.forEach(key -> {
String[] parts = key.split("\n", 2);
availabilityService.requireEnabled(parts[0], parts.length > 1 ? parts[1] : null);
});
logService.processIssueBatch(request);
return ApiResponse.ok();
}
@PostMapping("/events/batch")
public ApiResponse<Void> ingestEvents(@Valid @RequestBody EventBatchRequest request) {
request.events().stream()
.map(item -> item.appKey() + "\n" + item.platform())
.distinct()
.forEach(key -> {
String[] parts = key.split("\n", 2);
availabilityService.requireEnabled(parts[0], parts.length > 1 ? parts[1] : null);
});
logService.processEventBatch(request);
return ApiResponse.ok();
}
@ -192,16 +213,26 @@ public class LogController {
// Sourcemaps
@PostMapping("/sourcemaps/upload")
public ApiResponse<SourcemapUploadResponse> uploadSourcemap(
@PostMapping("/artifacts/upload")
public ApiResponse<SourcemapUploadResponse> uploadArtifact(
@RequestParam String artifactType,
@RequestParam String appKey,
@RequestParam String platform,
@RequestParam String appVersion,
@RequestParam(required = false) String buildId,
@RequestParam(required = false) String bundleVersion,
@RequestParam(required = false, defaultValue = "index") String bundleName,
@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, buildId, bundleVersion, bundleName, file));
@RequestParam String buildId,
@RequestParam(required = false) String moduleId,
@RequestParam(required = false) String moduleVersion,
@RequestParam(required = false) String bundleHash,
@RequestParam(required = false) String artifactHash,
@RequestParam("file") MultipartFile file,
Authentication authentication) throws IOException {
if (authentication == null || !("apikey:" + appKey).equals(authentication.getName())) {
throw new AccessDeniedException("API Token 与 appKey 不匹配");
}
availabilityService.requireEnabled(appKey, platform);
return ApiResponse.success(sourcemapService.upload(
artifactType, appKey, platform, appVersion, buildId,
moduleId, moduleVersion, bundleHash, artifactHash, file));
}
@GetMapping("/sourcemaps")

查看文件

@ -29,6 +29,9 @@ public record IssueBatchRequest(
@JsonProperty("device") DeviceInfo device,
String release,
String buildId,
String moduleId,
String moduleVersion,
String bundleHash,
String environment,
@JsonProperty("tags") Object tags
) {}

查看文件

@ -8,8 +8,13 @@ public record SourcemapInfo(
@JsonProperty("appKey") String appKey,
@JsonProperty("platform") String platform,
@JsonProperty("appVersion") String appVersion,
@JsonProperty("bundleName") String bundleName,
@JsonProperty("buildId") String buildId,
@JsonProperty("moduleId") String moduleId,
@JsonProperty("moduleVersion") String moduleVersion,
@JsonProperty("bundleHash") String bundleHash,
@JsonProperty("artifactType") String artifactType,
@JsonProperty("artifactHash") String artifactHash,
@JsonProperty("contentHash") String contentHash,
@JsonProperty("storageKey") String storageKey,
@JsonProperty("uploadedAt") LocalDateTime uploadedAt
) {}

查看文件

@ -7,7 +7,12 @@ public record SourcemapUploadResponse(
@JsonProperty("appKey") String appKey,
String platform,
@JsonProperty("appVersion") String appVersion,
@JsonProperty("bundleName") String bundleName,
@JsonProperty("buildId") String buildId,
@JsonProperty("moduleId") String moduleId,
@JsonProperty("moduleVersion") String moduleVersion,
@JsonProperty("bundleHash") String bundleHash,
@JsonProperty("artifactType") String artifactType,
@JsonProperty("artifactHash") String artifactHash,
@JsonProperty("contentHash") String contentHash,
@JsonProperty("storageKey") String storageKey
) {}

查看文件

@ -59,6 +59,15 @@ public class LogIssueEventEntity {
@Column(length = 32)
private String buildId;
@Column(length = 128)
private String moduleId;
@Column(length = 32)
private String moduleVersion;
@Column(length = 64)
private String bundleHash;
@Column(length = 50)
private String environment;
@ -125,6 +134,15 @@ public class LogIssueEventEntity {
public String getBuildId() { return buildId; }
public void setBuildId(String buildId) { this.buildId = buildId; }
public String getModuleId() { return moduleId; }
public void setModuleId(String moduleId) { this.moduleId = moduleId; }
public String getModuleVersion() { return moduleVersion; }
public void setModuleVersion(String moduleVersion) { this.moduleVersion = moduleVersion; }
public String getBundleHash() { return bundleHash; }
public void setBundleHash(String bundleHash) { this.bundleHash = bundleHash; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }

查看文件

@ -21,9 +21,24 @@ public class LogSourcemapEntity {
private String appVersion;
@Column(nullable = false, length = 128)
private String bundleName = "index";
private String moduleId;
@Column(length = 32)
@Column(nullable = false, length = 32)
private String moduleVersion;
@Column(length = 64)
private String bundleHash;
@Column(nullable = false, length = 24)
private String artifactType;
@Column(nullable = false, length = 64)
private String artifactHash;
@Column(nullable = false, length = 64)
private String contentHash;
@Column(nullable = false, length = 64)
private String buildId;
@Column(nullable = false, length = 512)
@ -44,8 +59,23 @@ public class LogSourcemapEntity {
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public String getBundleName() { return bundleName; }
public void setBundleName(String bundleName) { this.bundleName = bundleName; }
public String getModuleId() { return moduleId; }
public void setModuleId(String moduleId) { this.moduleId = moduleId; }
public String getModuleVersion() { return moduleVersion; }
public void setModuleVersion(String moduleVersion) { this.moduleVersion = moduleVersion; }
public String getBundleHash() { return bundleHash; }
public void setBundleHash(String bundleHash) { this.bundleHash = bundleHash; }
public String getArtifactType() { return artifactType; }
public void setArtifactType(String artifactType) { this.artifactType = artifactType; }
public String getArtifactHash() { return artifactHash; }
public void setArtifactHash(String artifactHash) { this.artifactHash = artifactHash; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public String getBuildId() { return buildId; }
public void setBuildId(String buildId) { this.buildId = buildId; }

查看文件

@ -8,25 +8,18 @@ import java.util.Optional;
public interface LogSourcemapRepository extends JpaRepository<LogSourcemapEntity, Long> {
/** Exact match by buildId (for events that carry a buildId). */
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
String appKey, String platform, String appVersion, String buildId, String bundleName);
Optional<LogSourcemapEntity>
findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion(
String appKey, String platform, String appVersion, String buildId,
String artifactType, String artifactHash, String moduleId, String moduleVersion);
/** Backward-compat: exact match on (appKey, platform, appVersion, bundleName) where buildId is NULL. */
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName(
String appKey, String platform, String appVersion, String bundleName);
List<LogSourcemapEntity>
findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc(
String appKey, String platform, String appVersion, String moduleId);
/** Fallback: latest upload for a version regardless of buildId. */
Optional<LogSourcemapEntity> findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
String appKey, String platform, String appVersion, String bundleName);
/** All records for one version, newest first — used for per-version retention pruning. */
List<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
String appKey, String platform, String appVersion, String bundleName);
/** All records for one (appKey, platform, bundleName), newest first — used for version-count pruning. */
List<LogSourcemapEntity> findByAppKeyAndPlatformAndBundleNameOrderByUploadedAtDesc(
String appKey, String platform, String bundleName);
List<LogSourcemapEntity>
findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc(
String appKey, String platform, String moduleId);
List<LogSourcemapEntity> findByAppKeyOrderByUploadedAtDesc(String appKey);

查看文件

@ -0,0 +1,84 @@
package com.xuqm.bugcollect.service;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 从租户平台读取应用级 BugCollect 动态开关
*
* <p>关闭状态在短缓存周期内生效租户平台暂时不可用时采用 fail-open避免配置服务故障
* 让已经启用的采集链路产生额外错误SDK 收到 {@code BUGCOLLECT_DISABLED} 后会自行停用
* 因此服务端不需要长期保存设备侧状态</p>
*/
@Service
public class BugCollectAvailabilityService {
private static final Logger log = LoggerFactory.getLogger(BugCollectAvailabilityService.class);
private final RestClient restClient;
private final Duration cacheTtl;
private final Map<String, CachedAvailability> cache = new ConcurrentHashMap<>();
public BugCollectAvailabilityService(
RestClient.Builder restClientBuilder,
@Value("${bugcollect-service.tenant-sdk-config-url:http://127.0.0.1:8081/api/sdk/config}")
String sdkConfigUrl,
@Value("${bugcollect-service.availability-cache-seconds:30}") long cacheSeconds) {
this.restClient = restClientBuilder.baseUrl(sdkConfigUrl).build();
this.cacheTtl = Duration.ofSeconds(Math.max(1, cacheSeconds));
}
public void requireEnabled(String appKey, String platform) {
if (!isEnabled(appKey, platform)) {
throw new BugCollectDisabledException();
}
}
boolean isEnabled(String appKey, String platform) {
String normalizedPlatform = normalizePlatform(platform);
String cacheKey = appKey + ":" + normalizedPlatform;
Instant now = Instant.now();
CachedAvailability cached = cache.get(cacheKey);
if (cached != null && now.isBefore(cached.expiresAt())) {
return cached.enabled();
}
try {
JsonNode response = restClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("appKey", appKey)
.queryParam("platform", normalizedPlatform)
.build())
.retrieve()
.body(JsonNode.class);
boolean enabled = response != null
&& response.path("data").path("features").path("bugCollect").asBoolean(false);
cache.put(cacheKey, new CachedAvailability(enabled, now.plus(cacheTtl)));
return enabled;
} catch (Exception e) {
log.warn("租户平台 BugCollect 开关查询失败,沿用有效缓存或暂时放行: appKey={} cause={}",
appKey, e.getClass().getSimpleName());
return cached == null || cached.enabled();
}
}
private static String normalizePlatform(String platform) {
if (platform == null || platform.isBlank()) return "ANDROID";
return switch (platform.trim().toUpperCase()) {
case "IOS" -> "IOS";
case "HARMONY", "HARMONYOS" -> "HARMONY";
default -> "ANDROID";
};
}
private record CachedAvailability(boolean enabled, Instant expiresAt) {
}
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.bugcollect.service;
/**
* SDK 可稳定识别的服务关闭信号机器码不得本地化或改写
*/
public final class BugCollectDisabledException extends RuntimeException {
public static final String ERROR_CODE = "BUGCOLLECT_DISABLED";
public BugCollectDisabledException() {
super(ERROR_CODE);
}
}

查看文件

@ -140,6 +140,9 @@ public class LogService {
eventEntity.setPlatform(item.platform());
eventEntity.setRelease(item.release());
eventEntity.setBuildId(item.buildId());
eventEntity.setModuleId(item.moduleId());
eventEntity.setModuleVersion(item.moduleVersion());
eventEntity.setBundleHash(item.bundleHash());
eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production");
eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null);
eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null);
@ -150,7 +153,8 @@ public class LogService {
eventEntity = issueEventRepository.save(eventEntity);
if (isNew) triggerWebhookAsync(issue);
triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), item.buildId());
triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(),
item.buildId(), item.moduleId(), item.moduleVersion(), item.bundleHash());
}
@Async
@ -163,14 +167,16 @@ public class LogService {
}
@Async
void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, String buildId) {
log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}",
eventId, appKey, platform, release, buildId);
void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release,
String buildId, String moduleId, String moduleVersion, String bundleHash) {
log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={} moduleId={} moduleVersion={} bundleHash={}",
eventId, appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash);
// 查找 sourcemap 文件精确 buildId 匹配兜底取最新版本
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index");
String storageKey = sourcemapService.findSourceMap(
appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash);
if (storageKey == null) {
log.debug("No sourcemap found for appKey={} platform={} release={} buildId={}", appKey, platform, release, buildId);
log.debug("No exact Source Map found for appKey={} platform={} release={} buildId={} moduleId={} moduleVersion={} bundleHash={}",
appKey, platform, release, buildId, moduleId, moduleVersion, bundleHash);
return;
}

查看文件

@ -1,5 +1,7 @@
package com.xuqm.bugcollect.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.bugcollect.dto.SourcemapInfo;
import com.xuqm.bugcollect.dto.SourcemapUploadResponse;
import com.xuqm.bugcollect.entity.LogSourcemapEntity;
@ -15,6 +17,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
@ -23,12 +26,15 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Source Map 只按完整 Bundle 身份精确存取禁止取同版本最新文件等不可靠兜底
*/
@Service
public class SourcemapService {
private static final Logger log = LoggerFactory.getLogger(SourcemapService.class);
private static final long MAX_FILE_SIZE = 200L * 1024 * 1024; // 200 MB
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".map", ".jsbundle.map", ".js.map");
private static final long MAX_FILE_SIZE = 50L * 1024 * 1024;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final LogSourcemapRepository sourcemapRepository;
@ -40,162 +46,205 @@ public class SourcemapService {
}
@Transactional
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
String bundleVersion, String bundleName, MultipartFile file) throws IOException {
return upload(appKey, platform, appVersion, null, bundleVersion, bundleName, file);
}
/**
* Upload a sourcemap file.
* When buildId is provided, each unique (appKey, platform, appVersion, buildId, bundleName)
* produces a separate record multiple builds of the same version coexist.
* When buildId is null (backward compat), the legacy single-record-per-version behaviour is kept.
*/
@Transactional
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
String buildId, String bundleVersion, String bundleName,
public SourcemapUploadResponse upload(
String artifactTypeValue,
String appKey,
String platform,
String appVersion,
String buildId,
String moduleId,
String moduleVersion,
String bundleHash,
String artifactHash,
MultipartFile file) throws IOException {
if (bundleName == null || bundleName.isBlank()) {
bundleName = "index";
ArtifactType artifactType = ArtifactType.parse(artifactTypeValue);
requireBaseIdentity(appKey, platform, appVersion, buildId);
String normalizedModuleId;
String normalizedModuleVersion;
String normalizedArtifactHash;
if (artifactType == ArtifactType.RN_SOURCEMAP) {
requireIdentity(moduleId, moduleVersion, bundleHash);
normalizedModuleId = moduleId;
normalizedModuleVersion = moduleVersion;
normalizedArtifactHash = normalizeHash(bundleHash, "bundleHash");
} else {
normalizedModuleId = "";
normalizedModuleVersion = "";
normalizedArtifactHash = normalizeHash(artifactHash, "artifactHash");
if (!"android".equalsIgnoreCase(platform)) {
throw new IllegalArgumentException("R8_MAPPING 只支持 Android 平台");
}
}
if (file.isEmpty() || file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("调试产物为空或超过 50 MB");
}
String originalName = file.getOriginalFilename() == null ? "" : file.getOriginalFilename();
if (artifactType == ArtifactType.RN_SOURCEMAP && !originalName.endsWith(".map")) {
throw new IllegalArgumentException("RN_SOURCEMAP 只接受 .map 文件");
}
if (artifactType == ArtifactType.R8_MAPPING && !"mapping.txt".equalsIgnoreCase(originalName)) {
throw new IllegalArgumentException("R8_MAPPING 只接受 mapping.txt");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("File size exceeds the 200 MB limit");
byte[] content = file.getBytes();
String contentHash = sha256(content);
if (artifactType == ArtifactType.RN_SOURCEMAP) {
validateSanitizedSourceMap(content);
} else if (!contentHash.equals(normalizedArtifactHash)) {
throw new IllegalArgumentException("artifactHash 与 mapping.txt 内容不一致");
}
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "";
if (!originalName.endsWith(".map")) {
throw new IllegalArgumentException("Only .map sourcemap files are accepted");
}
String filename = resolveFilename(platform, bundleName);
String versionKey = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : appVersion;
// When buildId is present, store under a subdirectory to avoid file collisions across builds
String buildDir = buildId != null && !buildId.isBlank() ? buildId : "legacy";
Path dir = Paths.get(storageDir, appKey, platform, versionKey, buildDir);
Path dir = Paths.get(storageDir,
safeSegment(appKey), safeSegment(platform), safeSegment(appVersion),
safeSegment(buildId), artifactType.name(),
safeSegment(normalizedModuleId.isEmpty() ? "_" : normalizedModuleId),
safeSegment(normalizedModuleVersion.isEmpty() ? "_" : normalizedModuleVersion));
Files.createDirectories(dir);
Path filePath = dir.resolve(filename);
file.transferTo(filePath.toFile());
Path filePath = dir.resolve(normalizedArtifactHash
+ (artifactType == ArtifactType.R8_MAPPING ? ".txt" : ".map"));
Files.write(filePath, content);
LogSourcemapEntity entity;
if (buildId != null && !buildId.isBlank()) {
// New path: each buildId gets its own record; no overwrite
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
appKey, platform, appVersion, buildId, bundleName);
if (existing.isPresent()) {
entity = existing.get();
LogSourcemapEntity entity = sourcemapRepository
.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion(
appKey, platform, appVersion, buildId, artifactType.name(),
normalizedArtifactHash, normalizedModuleId, normalizedModuleVersion)
.orElseGet(LogSourcemapEntity::new);
entity.setAppKey(appKey);
entity.setPlatform(platform);
entity.setAppVersion(appVersion);
entity.setBuildId(buildId);
entity.setModuleId(normalizedModuleId);
entity.setModuleVersion(normalizedModuleVersion);
entity.setBundleHash(artifactType == ArtifactType.RN_SOURCEMAP ? normalizedArtifactHash : null);
entity.setArtifactType(artifactType.name());
entity.setArtifactHash(normalizedArtifactHash);
entity.setContentHash(contentHash);
entity.setStorageKey(filePath.toString());
entity.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai")));
} else {
entity = newEntity(appKey, platform, appVersion, buildId, bundleName, filePath.toString());
}
} else {
// Legacy path: overwrite the single record for (appKey, platform, appVersion, bundleName)
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName(
appKey, platform, appVersion, bundleName);
if (existing.isPresent()) {
entity = existing.get();
entity.setStorageKey(filePath.toString());
entity.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai")));
} else {
entity = newEntity(appKey, platform, appVersion, null, bundleName, filePath.toString());
}
}
entity = sourcemapRepository.save(entity);
log.info("Sourcemap uploaded: appKey={} platform={} version={} buildId={} bundle={}",
appKey, platform, versionKey, buildId, bundleName);
pruneSourcemaps(appKey, platform, appVersion, bundleName);
return new SourcemapUploadResponse(
entity.getId(), entity.getAppKey(), entity.getPlatform(),
entity.getAppVersion(), entity.getBundleName(), entity.getBuildId(), entity.getStorageKey()
);
pruneSourcemaps(appKey, platform, appVersion, normalizedModuleId);
log.info("Debug artifact uploaded: type={} appKey={} platform={} appVersion={} buildId={} moduleId={} moduleVersion={} artifactHash={}",
artifactType, appKey, platform, appVersion, buildId,
normalizedModuleId, normalizedModuleVersion, normalizedArtifactHash);
return toUploadResponse(entity);
}
private LogSourcemapEntity newEntity(String appKey, String platform, String appVersion,
String buildId, String bundleName, String storageKey) {
var e = new LogSourcemapEntity();
e.setAppKey(appKey);
e.setPlatform(platform);
e.setAppVersion(appVersion);
e.setBuildId(buildId);
e.setBundleName(bundleName);
e.setStorageKey(storageKey);
e.setUploadedAt(LocalDateTime.now(ZoneId.of("Asia/Shanghai")));
return e;
public String findSourceMap(
String appKey,
String platform,
String appVersion,
String buildId,
String moduleId,
String moduleVersion,
String bundleHash) {
if (hasBlank(appKey, platform, appVersion, buildId, moduleId, moduleVersion, bundleHash)) {
return null;
}
/** Produce platform-appropriate filename, e.g. index.android.bundle.map */
private String resolveFilename(String platform, String bundleName) {
return switch (platform != null ? platform.toLowerCase() : "") {
case "android" -> bundleName + ".android.bundle.map";
case "ios" -> bundleName + ".ios.bundle.map";
case "harmony" -> bundleName + ".harmony.bundle.map";
default -> bundleName + ".map";
};
}
/**
* Locate a sourcemap file.
* Priority: exact (appKey, platform, appVersion, buildId, bundleName)
* version-only latest (appKey, platform, appVersion, bundleName)
*/
public String findSourceMap(String appKey, String platform, String appVersion,
String buildId, String bundleName) {
if (buildId != null && !buildId.isBlank()) {
var exact = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
appKey, platform, appVersion, buildId, bundleName);
if (exact.isPresent()) return exact.get().getStorageKey();
}
// Fallback: newest upload for this version
return sourcemapRepository
.findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
appKey, platform, appVersion, bundleName)
.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion(
appKey, platform, appVersion, buildId, ArtifactType.RN_SOURCEMAP.name(),
bundleHash.toLowerCase(), moduleId, moduleVersion)
.map(LogSourcemapEntity::getStorageKey)
.orElse(null);
}
/** Backward-compat overload without buildId. */
public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) {
return findSourceMap(appKey, platform, appVersion, null, bundleName);
private void validateSanitizedSourceMap(byte[] content) {
try {
JsonNode root = MAPPER.readTree(content);
if (root == null || !root.isObject() || !root.path("mappings").isTextual()) {
throw new IllegalArgumentException("Source Map 格式无效");
}
JsonNode sourcesContent = root.path("sourcesContent");
if (sourcesContent.isArray() && !sourcesContent.isEmpty()) {
throw new IllegalArgumentException("Source Map 必须移除 sourcesContent 后再上传");
}
JsonNode sources = root.path("sources");
if (sources.isArray()) {
for (JsonNode source : sources) {
String path = source.asText("");
if (path.startsWith("/")
|| path.matches("^[A-Za-z]:[\\\\/].*")
|| hasParentTraversal(path)) {
throw new IllegalArgumentException("Source Map sources 只能包含项目内相对路径");
}
}
}
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Source Map JSON 无法解析", e);
}
}
private static void requireBaseIdentity(String... values) {
if (hasBlank(values)) {
throw new IllegalArgumentException("调试产物基础身份参数不能为空");
}
}
private static void requireIdentity(String... values) {
if (hasBlank(values)) throw new IllegalArgumentException("RN Bundle 身份参数不能为空");
}
private static String normalizeHash(String value, String field) {
if (value == null || !value.matches("(?i)^[0-9a-f]{64}$")) {
throw new IllegalArgumentException(field + " 必须是 SHA-256 十六进制摘要");
}
return value.toLowerCase();
}
private static String sha256(byte[] content) {
try {
return java.util.HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(content));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private static boolean hasParentTraversal(String path) {
for (String segment : path.replace('\\', '/').split("/")) {
if ("..".equals(segment)) return true;
}
return false;
}
private static boolean hasBlank(String... values) {
for (String value : values) {
if (value == null || value.isBlank()) return true;
}
return false;
}
private static String safeSegment(String value) {
if (value.contains("/") || value.contains("\\") || value.contains("..")) {
throw new IllegalArgumentException("Source Map 身份参数包含非法路径字符");
}
return value;
}
/**
* Enforce retention policy after every upload:
* - per version (appVersion): keep at most 3 records (newest by uploadedAt)
* - per (appKey, platform, bundleName): keep at most 5 distinct appVersions (newest by latest upload)
* 每个应用版本和模块最多保留 3 个构建每个模块最多保留 5 个应用版本
*/
private void pruneSourcemaps(String appKey, String platform, String appVersion, String bundleName) {
// 1. Per-version limit: keep newest 3 buildIds
private void pruneSourcemaps(String appKey, String platform, String appVersion, String moduleId) {
List<LogSourcemapEntity> versionRecords = sourcemapRepository
.findByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
appKey, platform, appVersion, bundleName);
.findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc(
appKey, platform, appVersion, moduleId);
if (versionRecords.size() > 3) {
deleteEntities(versionRecords.subList(3, versionRecords.size()));
}
// 2. Version-count limit: keep at most 5 distinct appVersions
List<LogSourcemapEntity> allRecords = sourcemapRepository
.findByAppKeyAndPlatformAndBundleNameOrderByUploadedAtDesc(appKey, platform, bundleName);
// Preserve insertion order = newest-first version order
.findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc(appKey, platform, moduleId);
LinkedHashSet<String> orderedVersions = new LinkedHashSet<>();
for (LogSourcemapEntity r : allRecords) {
orderedVersions.add(r.getAppVersion());
}
allRecords.forEach(item -> orderedVersions.add(item.getAppVersion()));
if (orderedVersions.size() > 5) {
List<String> versionList = new ArrayList<>(orderedVersions);
Set<String> versionsToDelete = new LinkedHashSet<>(versionList.subList(5, versionList.size()));
List<LogSourcemapEntity> excess = allRecords.stream()
.filter(r -> versionsToDelete.contains(r.getAppVersion()))
.toList();
deleteEntities(excess);
log.info("Pruned {} old-version sourcemap(s) for appKey={} platform={} bundle={}",
excess.size(), appKey, platform, bundleName);
List<String> versions = new ArrayList<>(orderedVersions);
Set<String> expiredVersions = new LinkedHashSet<>(versions.subList(5, versions.size()));
deleteEntities(allRecords.stream()
.filter(item -> expiredVersions.contains(item.getAppVersion()))
.toList());
}
}
@ -204,31 +253,25 @@ public class SourcemapService {
try {
Files.deleteIfExists(Paths.get(entity.getStorageKey()));
} catch (IOException e) {
log.warn("Could not delete sourcemap file {}: {}", entity.getStorageKey(), e.getMessage());
log.warn("Source Map 文件删除失败: storageKey={} cause={}",
entity.getStorageKey(), e.getClass().getSimpleName());
}
sourcemapRepository.delete(entity);
}
}
/** One-time / admin: apply the retention policy to all existing records. Returns deleted count. */
@Transactional
public int pruneAll() {
List<LogSourcemapEntity> all = sourcemapRepository.findAll();
long before = all.size();
record VersionKey(String appKey, String platform, String bundleName, String appVersion) {}
Set<VersionKey> versionKeys = all.stream()
.map(e -> new VersionKey(e.getAppKey(), e.getPlatform(), e.getBundleName(), e.getAppVersion()))
record VersionKey(String appKey, String platform, String moduleId, String appVersion) {}
Set<VersionKey> keys = all.stream()
.map(item -> new VersionKey(
item.getAppKey(), item.getPlatform(), item.getModuleId(), item.getAppVersion()))
.collect(Collectors.toCollection(LinkedHashSet::new));
for (VersionKey key : versionKeys) {
pruneSourcemaps(key.appKey(), key.platform(), key.appVersion(), key.bundleName());
}
long after = sourcemapRepository.count();
int deleted = (int) (before - after);
log.info("pruneAll: scanned {} records, deleted {}", before, deleted);
return deleted;
keys.forEach(key -> pruneSourcemaps(
key.appKey(), key.platform(), key.appVersion(), key.moduleId()));
return (int) (before - sourcemapRepository.count());
}
public String readSourceMapContent(String storageKey) throws IOException {
@ -237,25 +280,42 @@ public class SourcemapService {
@Transactional
public void deleteById(Long id) {
sourcemapRepository.findById(id).ifPresent(entity -> {
try {
Files.deleteIfExists(Paths.get(entity.getStorageKey()));
} catch (IOException e) {
log.warn("Could not delete sourcemap file {}: {}", entity.getStorageKey(), e.getMessage());
}
sourcemapRepository.delete(entity);
log.info("Sourcemap deleted: id={} storageKey={}", id, entity.getStorageKey());
});
sourcemapRepository.findById(id).ifPresent(entity -> deleteEntities(List.of(entity)));
}
public List<SourcemapInfo> listByAppKey(String appKey) {
return sourcemapRepository.findByAppKeyOrderByUploadedAtDesc(appKey)
.stream()
.map(e -> new SourcemapInfo(
e.getId(), e.getAppKey(), e.getPlatform(),
e.getAppVersion(), e.getBundleName(), e.getBuildId(),
e.getStorageKey(), e.getUploadedAt()
))
.map(this::toInfo)
.toList();
}
private SourcemapInfo toInfo(LogSourcemapEntity entity) {
return new SourcemapInfo(
entity.getId(), entity.getAppKey(), entity.getPlatform(), entity.getAppVersion(),
entity.getBuildId(), entity.getModuleId(), entity.getModuleVersion(),
entity.getBundleHash(), entity.getArtifactType(), entity.getArtifactHash(),
entity.getContentHash(), entity.getStorageKey(), entity.getUploadedAt());
}
private SourcemapUploadResponse toUploadResponse(LogSourcemapEntity entity) {
return new SourcemapUploadResponse(
entity.getId(), entity.getAppKey(), entity.getPlatform(), entity.getAppVersion(),
entity.getBuildId(), entity.getModuleId(), entity.getModuleVersion(),
entity.getBundleHash(), entity.getArtifactType(), entity.getArtifactHash(),
entity.getContentHash(), entity.getStorageKey());
}
public enum ArtifactType {
RN_SOURCEMAP,
R8_MAPPING;
static ArtifactType parse(String value) {
try {
return valueOf(value == null ? "" : value.trim().toUpperCase());
} catch (Exception e) {
throw new IllegalArgumentException("artifactType 仅支持 RN_SOURCEMAP 或 R8_MAPPING");
}
}
}
}

查看文件

@ -51,7 +51,13 @@ spring:
max-file-size: 50MB
max-request-size: 50MB
sdk:
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:9001}
internal-token: ${SDK_INTERNAL_TOKEN:}
bugcollect-service:
tenant-sdk-config-url: ${TENANT_SDK_CONFIG_URL:http://127.0.0.1:8081/api/sdk/config}
availability-cache-seconds: ${BUGCOLLECT_AVAILABILITY_CACHE_SECONDS:30}
sourcemap:
storage-dir: ${LOG_SOURCEMAP_DIR:/data/bugcollect-service/sourcemaps}
webhook:
@ -67,6 +73,3 @@ management:
web:
exposure:
include: health,info
spring.security:
enabled: false

查看文件

@ -0,0 +1,23 @@
-- Source Map 必须按完整 Bundle 身份精确匹配,不允许按版本或“最新上传”兜底。
-- 旧记录缺少 moduleVersion/bundleHash,无法可靠符号化,必须重新上传新格式产物。
DELETE FROM log_sourcemaps;
ALTER TABLE log_sourcemaps DROP INDEX uk_map;
ALTER TABLE log_sourcemaps
CHANGE COLUMN bundle_name module_id VARCHAR(128) NOT NULL,
ADD COLUMN module_version VARCHAR(32) NOT NULL DEFAULT '' AFTER module_id,
ADD COLUMN bundle_hash VARCHAR(64) NULL AFTER module_version,
ADD COLUMN artifact_type VARCHAR(24) NOT NULL AFTER bundle_hash,
ADD COLUMN artifact_hash VARCHAR(64) NOT NULL AFTER artifact_type,
ADD COLUMN content_hash VARCHAR(64) NOT NULL AFTER artifact_hash,
MODIFY COLUMN build_id VARCHAR(64) NOT NULL;
ALTER TABLE log_sourcemaps
ADD UNIQUE KEY uk_map_exact (
app_key, platform, app_version, build_id, artifact_type, artifact_hash, module_id, module_version
);
ALTER TABLE log_issue_events
ADD COLUMN module_id VARCHAR(128) NULL AFTER build_id,
ADD COLUMN module_version VARCHAR(32) NULL AFTER module_id,
ADD COLUMN bundle_hash VARCHAR(64) NULL AFTER module_version;

查看文件

@ -0,0 +1,82 @@
package com.xuqm.bugcollect.config;
import static org.assertj.core.api.Assertions.assertThat;
import com.xuqm.common.security.ApiKeyAuthFilter;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
class ArtifactApiTokenFilterTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void missingBearerTokenReturns401() throws Exception {
FilterResult result = execute(null, null, token -> "ak_test");
assertThat(result.response().getStatus()).isEqualTo(401);
assertThat(result.chainCalled()).isFalse();
}
@Test
void xApiKeyWithoutBearerReturns401() throws Exception {
FilterResult result = execute(null, "legacy-api-key", token -> "ak_test");
assertThat(result.response().getStatus()).isEqualTo(401);
assertThat(result.chainCalled()).isFalse();
}
@Test
void invalidBearerTokenReturns401() throws Exception {
FilterResult result = execute("invalid", null, token -> null);
assertThat(result.response().getStatus()).isEqualTo(401);
assertThat(result.chainCalled()).isFalse();
}
@Test
void validBearerTokenAuthenticatesItsOwningApp() throws Exception {
FilterResult result = execute("valid-token", null, token -> "ak_test");
assertThat(result.response().getStatus()).isEqualTo(200);
assertThat(result.chainCalled()).isTrue();
assertThat(SecurityContextHolder.getContext().getAuthentication().getName())
.isEqualTo("apikey:ak_test");
}
private FilterResult execute(
String bearerToken,
String xApiKey,
com.xuqm.common.security.ApiKeyValidator validator)
throws Exception {
ApiKeyAuthFilter filter = new ApiKeyAuthFilter(
validator,
new AntPathRequestMatcher("/bugcollect/v1/artifacts/upload", "POST"),
true);
MockHttpServletRequest request =
new MockHttpServletRequest("POST", "/bugcollect/v1/artifacts/upload");
request.setServletPath("/bugcollect/v1/artifacts/upload");
if (bearerToken != null) {
request.addHeader("Authorization", "Bearer " + bearerToken);
}
if (xApiKey != null) {
request.addHeader(ApiKeyAuthFilter.HEADER_NAME, xApiKey);
}
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean chainCalled = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> chainCalled.set(true));
return new FilterResult(response, chainCalled.get());
}
private record FilterResult(MockHttpServletResponse response, boolean chainCalled) {
}
}

查看文件

@ -0,0 +1,18 @@
package com.xuqm.bugcollect.controller;
import com.xuqm.bugcollect.service.BugCollectDisabledException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GlobalExceptionHandlerTest {
@Test
void exposesStableDisabledMachineCode() {
var response = new GlobalExceptionHandler().handle(new BugCollectDisabledException());
assertThat(response.getStatusCode().value()).isEqualTo(403);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().message()).isEqualTo("BUGCOLLECT_DISABLED");
}
}

查看文件

@ -0,0 +1,74 @@
package com.xuqm.bugcollect.service;
import com.xuqm.bugcollect.entity.LogSourcemapEntity;
import com.xuqm.bugcollect.repository.LogSourcemapRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SourcemapServiceTest {
private SourcemapService service;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
LogSourcemapRepository repository = mock(LogSourcemapRepository.class);
when(repository
.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndArtifactTypeAndArtifactHashAndModuleIdAndModuleVersion(
any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Optional.empty());
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(repository.findByAppKeyAndPlatformAndAppVersionAndModuleIdOrderByUploadedAtDesc(
any(), any(), any(), any())).thenReturn(java.util.List.of());
when(repository.findByAppKeyAndPlatformAndModuleIdOrderByUploadedAtDesc(
any(), any(), any())).thenReturn(java.util.List.of());
service = new SourcemapService(repository);
ReflectionTestUtils.setField(service, "storageDir", tempDir.toString());
}
@Test
void rejectsAbsoluteAndTraversalSourcePaths() {
for (String source : new String[]{"/abs/index.ts", "C:\\abs\\index.ts", "../../secret.ts"}) {
MockMultipartFile file = sourceMap(source);
assertThatThrownBy(() -> service.upload(
"RN_SOURCEMAP", "ak_test", "android", "1.0.0", "build-1",
"app", "1.0.0", "a".repeat(64), null, file))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("相对路径");
}
}
@Test
void acceptsRealR8MappingWithMatchingHash() throws Exception {
byte[] mapping = "com.example.Original -> a:\n".getBytes(StandardCharsets.UTF_8);
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(mapping));
MockMultipartFile file = new MockMultipartFile(
"file", "mapping.txt", "text/plain", mapping);
service.upload("R8_MAPPING", "ak_test", "android", "1.0.0", "build-1",
null, null, null, hash, file);
}
private MockMultipartFile sourceMap(String source) {
String json = "{\"version\":3,\"sources\":[\"" + source.replace("\\", "\\\\")
+ "\"],\"names\":[],\"mappings\":\"AAAA\"}";
return new MockMultipartFile(
"file", "app.map", "application/json", json.getBytes(StandardCharsets.UTF_8));
}
}