feat(platform): implement signed sdk config and artifacts
这个提交包含在:
父节点
ea68030903
当前提交
c51cdd46f4
@ -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
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.<salt>.<iv>.<ciphertext>).
|
||||
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
|
||||
* This is separate from LicenseFileCrypto (device activation).
|
||||
* XUQM-CONFIG-V2 配置文件编解码器。
|
||||
*
|
||||
* <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"
|
||||
}
|
||||
122
docs/SDK_PLATFORM_V2_HANDOFF.md
普通文件
122
docs/SDK_PLATFORM_V2_HANDOFF.md
普通文件
@ -0,0 +1,122 @@
|
||||
# SDK 平台 V2 实施与接手说明
|
||||
|
||||
> 更新时间:2026-07-26。本文记录当前源码事实与尚未执行的部署动作;不得把本文当作生产环境已发布证明。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- Config 文件唯一格式为 `XUQM-CONFIG-V2`,V1 不再接受。
|
||||
- Config 文件由租户服务生成;客户端只验证签名、有效期和包名,不在线检查吊销状态。
|
||||
- BugCollect 运行时上报失败不得影响宿主业务;服务关闭时固定返回 HTTP 403,消息码为 `BUGCOLLECT_DISABLED`。
|
||||
- 构建产物上传唯一入口为 `POST /bugcollect/v1/artifacts/upload`。
|
||||
- 已废弃的 App 离线激活 license 服务已从聚合构建、租户服务、网关和 Web 入口删除;它与签名 SDK 无关。
|
||||
|
||||
## Config 文件契约
|
||||
|
||||
格式:
|
||||
|
||||
```text
|
||||
XUQM-CONFIG-V2.<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-service:V3 Config V2 元数据、V4 平台专属 App 包名、V5 移除离线 license 服务数据和枚举值。
|
||||
- xuqm-bugcollect-service:V9 引入精确 bundle 身份和独立产物字段。
|
||||
|
||||
执行迁移前必须备份对应数据库。不得修改已在目标环境执行过的历史迁移文件。
|
||||
|
||||
## Web 与 Jenkins
|
||||
|
||||
- 应用详情页展示 Config 签发元数据,并支持“长期有效”或选择 UTC 到期时间后重新生成。
|
||||
- 符号化产物页同时展示 RN sourcemap 和 R8 mapping。
|
||||
- Web 依赖管理统一使用仓库声明的 Yarn 1;不再保留不同步的 `package-lock.json`。
|
||||
- Server 与 tenant-web Jenkins 默认 `DEPLOY_ENV=development`,development 只验证,不推送、不部署、不提交版本号。
|
||||
- production 必须经过人工确认,并从 Jenkins Credentials 读取 Git 与 Config 签名凭据。
|
||||
|
||||
## 已执行验证
|
||||
|
||||
```bash
|
||||
mvn -Dmaven.repo.local=/private/tmp/xuqm-m2 \
|
||||
-pl tenant-service,xuqm-bugcollect-service -am test
|
||||
```
|
||||
|
||||
2026-07-26 结果:BUILD SUCCESS。common 共 3 项测试通过,tenant-service 共 3 项测试通过,
|
||||
xuqm-bugcollect-service 共 7 项测试通过;覆盖 Config 向量、通用 X-API-Key 行为、
|
||||
受保护的内部 POST 请求体、更新配置失败安全缓存、BugCollect 固定错误、路径校验、
|
||||
R8 校验,以及产物 API Token 的无 Bearer、仅 X-API-Key、无效 Bearer、有效归属场景。
|
||||
|
||||
Server 全聚合 `mvn -DskipTests compile` 结果:BUILD SUCCESS,聚合构建中已不包含 license-service。
|
||||
|
||||
Web 验证命令:
|
||||
|
||||
```bash
|
||||
yarn install --frozen-lockfile
|
||||
yarn build:tenant
|
||||
yarn build:ops
|
||||
yarn workspace @xuqm/docs-site build
|
||||
```
|
||||
|
||||
2026-07-26 三项 Web 构建均通过。最终结果以当前任务交接记录为准。
|
||||
|
||||
## 尚未执行
|
||||
|
||||
- 未提交、未推送。
|
||||
- 未触发 Jenkins,未部署任何环境。
|
||||
- 未运行真实 MySQL 迁移。
|
||||
- Jenkins 中的 Git、Config 签名凭据以及生产主机运行环境变量必须由有权限的维护者配置后,才能进行生产发布。
|
||||
@ -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;
|
||||
1
pom.xml
1
pom.xml
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
正在加载...
在新工单中引用
屏蔽一个用户