tenant: auto-generate license file on app creation, decouple from license service
- AppEntity: add licenseFileContent field to store pre-generated encrypted license - AppService: generate license file content on create/update with normalized baseUrl - AppController: read license file content from entity instead of generating on-the-fly - Web: remove license download v-if serviceEnabled check, always show download button Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
这个提交包含在:
父节点
8c9bfb6acd
当前提交
ccb976c605
@ -11,11 +11,8 @@ import com.xuqm.tenant.service.AppService;
|
||||
import com.xuqm.tenant.service.AppUserClient;
|
||||
import com.xuqm.tenant.service.EmailService;
|
||||
import com.xuqm.tenant.service.FeatureServiceManager;
|
||||
import com.xuqm.common.security.LicenseFileCrypto;
|
||||
import com.xuqm.tenant.config.PrivateDeploymentProperties;
|
||||
import com.xuqm.tenant.service.OperationLogService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@ -44,24 +41,18 @@ public class AppController {
|
||||
private final TenantRepository tenantRepository;
|
||||
private final FeatureServiceManager featureServiceManager;
|
||||
private final AppUserClient appUserClient;
|
||||
private final PrivateDeploymentProperties deployProps;
|
||||
|
||||
@Value("${license.public-base-url:https://auth.dev.xuqinmin.com/}")
|
||||
private String licensePublicBaseUrl;
|
||||
|
||||
public AppController(AppService appService, EmailService emailService,
|
||||
OperationLogService operationLogService,
|
||||
TenantRepository tenantRepository,
|
||||
FeatureServiceManager featureServiceManager,
|
||||
AppUserClient appUserClient,
|
||||
PrivateDeploymentProperties deployProps) {
|
||||
AppUserClient appUserClient) {
|
||||
this.appService = appService;
|
||||
this.emailService = emailService;
|
||||
this.operationLogService = operationLogService;
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.featureServiceManager = featureServiceManager;
|
||||
this.appUserClient = appUserClient;
|
||||
this.deployProps = deployProps;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@ -172,22 +163,10 @@ public class AppController {
|
||||
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||
payload.put("appKey", app.getAppKey());
|
||||
payload.put("appName", app.getName());
|
||||
payload.put("packageName", app.getPackageName());
|
||||
if (app.getIosBundleId() != null && !app.getIosBundleId().isBlank()) {
|
||||
payload.put("iosBundleId", app.getIosBundleId());
|
||||
String encrypted = app.getLicenseFileContent();
|
||||
if (encrypted == null || encrypted.isBlank()) {
|
||||
throw new BusinessException("License file not generated yet");
|
||||
}
|
||||
if (app.getHarmonyBundleName() != null && !app.getHarmonyBundleName().isBlank()) {
|
||||
payload.put("harmonyBundleName", app.getHarmonyBundleName());
|
||||
}
|
||||
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||
if (deployProps.isPrivate()) {
|
||||
payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||
}
|
||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||
String encrypted = LicenseFileCrypto.encrypt(new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(payload).toString());
|
||||
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
@ -195,11 +174,6 @@ public class AppController {
|
||||
.body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String value) {
|
||||
String baseUrl = value == null || value.isBlank() ? "https://auth.dev.xuqinmin.com/" : value.trim();
|
||||
return baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String value) {
|
||||
String name = value == null || value.isBlank() ? "license" : value.trim();
|
||||
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
|
||||
|
||||
@ -43,6 +43,9 @@ public class AppEntity {
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "license_file_content", length = 4096)
|
||||
private String licenseFileContent;
|
||||
|
||||
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
||||
private boolean isDefault;
|
||||
|
||||
@ -87,4 +90,7 @@ public class AppEntity {
|
||||
|
||||
public boolean isDeletable() { return deletable; }
|
||||
public void setDeletable(boolean deletable) { this.deletable = deletable; }
|
||||
|
||||
public String getLicenseFileContent() { return licenseFileContent; }
|
||||
public void setLicenseFileContent(String licenseFileContent) { this.licenseFileContent = licenseFileContent; }
|
||||
}
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
package com.xuqm.tenant.service;
|
||||
|
||||
import com.xuqm.common.exception.BusinessException;
|
||||
import com.xuqm.common.security.LicenseFileCrypto;
|
||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||
import com.xuqm.tenant.entity.AppEntity;
|
||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||
import com.xuqm.tenant.repository.AppRepository;
|
||||
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 org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
@ -22,14 +27,24 @@ public class AppService {
|
||||
private final AppRepository appRepository;
|
||||
private final OperationLogService operationLogService;
|
||||
private final FeatureServiceRepository featureServiceRepository;
|
||||
private final PrivateDeploymentProperties deployProps;
|
||||
private final TenantRepository tenantRepository;
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Value("${license.public-base-url:https://auth.dev.xuqinmin.com/}")
|
||||
private String licensePublicBaseUrl;
|
||||
|
||||
public AppService(AppRepository appRepository,
|
||||
OperationLogService operationLogService,
|
||||
FeatureServiceRepository featureServiceRepository) {
|
||||
FeatureServiceRepository featureServiceRepository,
|
||||
PrivateDeploymentProperties deployProps,
|
||||
TenantRepository tenantRepository) {
|
||||
this.appRepository = appRepository;
|
||||
this.operationLogService = operationLogService;
|
||||
this.featureServiceRepository = featureServiceRepository;
|
||||
this.deployProps = deployProps;
|
||||
this.tenantRepository = tenantRepository;
|
||||
}
|
||||
|
||||
public List<AppEntity> listByTenant(String tenantId) {
|
||||
@ -63,6 +78,7 @@ public class AppService {
|
||||
app.setAppKey(generateAppKey());
|
||||
app.setAppSecret(generateSecret());
|
||||
app.setCreatedAt(LocalDateTime.now());
|
||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||
AppEntity saved = appRepository.save(app);
|
||||
autoEnableFileService(saved.getAppKey());
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
||||
@ -85,6 +101,7 @@ public class AppService {
|
||||
app.setName(req.name());
|
||||
app.setDescription(req.description());
|
||||
app.setIconUrl(req.iconUrl());
|
||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||
AppEntity saved = appRepository.save(app);
|
||||
Map<String, Object> after = new LinkedHashMap<>();
|
||||
after.put("name", saved.getName());
|
||||
@ -143,4 +160,32 @@ public class AppService {
|
||||
random.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String generateLicenseFileContent(AppEntity app) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("appKey", app.getAppKey());
|
||||
payload.put("appName", app.getName());
|
||||
payload.put("packageName", app.getPackageName());
|
||||
if (app.getIosBundleId() != null && !app.getIosBundleId().isBlank()) {
|
||||
payload.put("iosBundleId", app.getIosBundleId());
|
||||
}
|
||||
if (app.getHarmonyBundleName() != null && !app.getHarmonyBundleName().isBlank()) {
|
||||
payload.put("harmonyBundleName", app.getHarmonyBundleName());
|
||||
}
|
||||
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||
if (deployProps.isPrivate()) {
|
||||
payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||
}
|
||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||
try {
|
||||
return LicenseFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate license file", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String value) {
|
||||
String baseUrl = value == null || value.isBlank() ? "https://auth.dev.xuqinmin.com/" : value.trim();
|
||||
return baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,14 +178,30 @@ public class SystemUpdateService {
|
||||
if (consoleDomain == null) consoleDomain = "";
|
||||
|
||||
String anchor = " SDK_TENANT_SERVICE_URL: \"http://tenant-service:9001\"\n";
|
||||
if (!content.contains(anchor)) {
|
||||
emit.accept(" [跳过] docker-compose update-service 补丁锚点未找到,请手动检查");
|
||||
// Fallback anchor for older docker-compose that may not have SDK_TENANT_SERVICE_URL
|
||||
String fallbackAnchor = " update-service:\n";
|
||||
String envBlock = " FILE_BASE_URL: \"" + consoleDomain + "\"\n"
|
||||
+ " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\n";
|
||||
String patched;
|
||||
if (content.contains(anchor)) {
|
||||
patched = content.replace(anchor, anchor + envBlock);
|
||||
} else if (content.contains(fallbackAnchor)) {
|
||||
// Inject env block into update-service's environment section by finding its image line
|
||||
String imageAnchor = " image: ${REGISTRY}/update-service:${IMAGE_TAG}\n";
|
||||
if (!content.contains(imageAnchor)) {
|
||||
emit.accept(" [跳过] docker-compose update-service 补丁锚点未找到,请手动检查");
|
||||
return;
|
||||
}
|
||||
String envAnchor = imageAnchor + " environment:\n";
|
||||
if (!content.contains(envAnchor)) {
|
||||
emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查");
|
||||
return;
|
||||
}
|
||||
patched = content.replace(envAnchor, envAnchor + envBlock);
|
||||
} else {
|
||||
emit.accept(" [跳过] docker-compose update-service 段未找到,请手动检查");
|
||||
return;
|
||||
}
|
||||
String injection = anchor
|
||||
+ " FILE_BASE_URL: \"" + consoleDomain + "\"\n"
|
||||
+ " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\n";
|
||||
String patched = content.replace(anchor, injection);
|
||||
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL");
|
||||
} catch (IOException e) {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户