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>
这个提交包含在:
XuqmGroup 2026-05-22 17:56:12 +08:00
父节点 8c9bfb6acd
当前提交 ccb976c605
共有 4 个文件被更改,包括 78 次插入37 次删除

查看文件

@ -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)) {
// 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 injection = anchor
+ " FILE_BASE_URL: \"" + consoleDomain + "\"\n"
+ " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\n";
String patched = content.replace(anchor, injection);
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;
}
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL");
} catch (IOException e) {