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.AppUserClient;
|
||||||
import com.xuqm.tenant.service.EmailService;
|
import com.xuqm.tenant.service.EmailService;
|
||||||
import com.xuqm.tenant.service.FeatureServiceManager;
|
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 com.xuqm.tenant.service.OperationLogService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.ContentDisposition;
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@ -44,24 +41,18 @@ public class AppController {
|
|||||||
private final TenantRepository tenantRepository;
|
private final TenantRepository tenantRepository;
|
||||||
private final FeatureServiceManager featureServiceManager;
|
private final FeatureServiceManager featureServiceManager;
|
||||||
private final AppUserClient appUserClient;
|
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,
|
public AppController(AppService appService, EmailService emailService,
|
||||||
OperationLogService operationLogService,
|
OperationLogService operationLogService,
|
||||||
TenantRepository tenantRepository,
|
TenantRepository tenantRepository,
|
||||||
FeatureServiceManager featureServiceManager,
|
FeatureServiceManager featureServiceManager,
|
||||||
AppUserClient appUserClient,
|
AppUserClient appUserClient) {
|
||||||
PrivateDeploymentProperties deployProps) {
|
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
this.featureServiceManager = featureServiceManager;
|
this.featureServiceManager = featureServiceManager;
|
||||||
this.appUserClient = appUserClient;
|
this.appUserClient = appUserClient;
|
||||||
this.deployProps = deployProps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -172,22 +163,10 @@ public class AppController {
|
|||||||
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
||||||
@AuthenticationPrincipal String tenantId) {
|
@AuthenticationPrincipal String tenantId) {
|
||||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
String encrypted = app.getLicenseFileContent();
|
||||||
payload.put("appKey", app.getAppKey());
|
if (encrypted == null || encrypted.isBlank()) {
|
||||||
payload.put("appName", app.getName());
|
throw new BusinessException("License file not generated yet");
|
||||||
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());
|
|
||||||
String encrypted = LicenseFileCrypto.encrypt(new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(payload).toString());
|
|
||||||
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
@ -195,11 +174,6 @@ public class AppController {
|
|||||||
.body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
.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) {
|
private static String sanitizeFileName(String value) {
|
||||||
String name = value == null || value.isBlank() ? "license" : value.trim();
|
String name = value == null || value.isBlank() ? "license" : value.trim();
|
||||||
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
|
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
|
||||||
|
|||||||
@ -43,6 +43,9 @@ public class AppEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "license_file_content", length = 4096)
|
||||||
|
private String licenseFileContent;
|
||||||
|
|
||||||
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
||||||
private boolean isDefault;
|
private boolean isDefault;
|
||||||
|
|
||||||
@ -87,4 +90,7 @@ public class AppEntity {
|
|||||||
|
|
||||||
public boolean isDeletable() { return deletable; }
|
public boolean isDeletable() { return deletable; }
|
||||||
public void setDeletable(boolean deletable) { this.deletable = 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;
|
package com.xuqm.tenant.service;
|
||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
import com.xuqm.tenant.repository.AppRepository;
|
import com.xuqm.tenant.repository.AppRepository;
|
||||||
import com.xuqm.tenant.repository.FeatureServiceRepository;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
@ -22,14 +27,24 @@ public class AppService {
|
|||||||
private final AppRepository appRepository;
|
private final AppRepository appRepository;
|
||||||
private final OperationLogService operationLogService;
|
private final OperationLogService operationLogService;
|
||||||
private final FeatureServiceRepository featureServiceRepository;
|
private final FeatureServiceRepository featureServiceRepository;
|
||||||
|
private final PrivateDeploymentProperties deployProps;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
private static final SecureRandom random = new SecureRandom();
|
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,
|
public AppService(AppRepository appRepository,
|
||||||
OperationLogService operationLogService,
|
OperationLogService operationLogService,
|
||||||
FeatureServiceRepository featureServiceRepository) {
|
FeatureServiceRepository featureServiceRepository,
|
||||||
|
PrivateDeploymentProperties deployProps,
|
||||||
|
TenantRepository tenantRepository) {
|
||||||
this.appRepository = appRepository;
|
this.appRepository = appRepository;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
this.featureServiceRepository = featureServiceRepository;
|
this.featureServiceRepository = featureServiceRepository;
|
||||||
|
this.deployProps = deployProps;
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppEntity> listByTenant(String tenantId) {
|
public List<AppEntity> listByTenant(String tenantId) {
|
||||||
@ -63,6 +78,7 @@ public class AppService {
|
|||||||
app.setAppKey(generateAppKey());
|
app.setAppKey(generateAppKey());
|
||||||
app.setAppSecret(generateSecret());
|
app.setAppSecret(generateSecret());
|
||||||
app.setCreatedAt(LocalDateTime.now());
|
app.setCreatedAt(LocalDateTime.now());
|
||||||
|
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||||
AppEntity saved = appRepository.save(app);
|
AppEntity saved = appRepository.save(app);
|
||||||
autoEnableFileService(saved.getAppKey());
|
autoEnableFileService(saved.getAppKey());
|
||||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
||||||
@ -85,6 +101,7 @@ public class AppService {
|
|||||||
app.setName(req.name());
|
app.setName(req.name());
|
||||||
app.setDescription(req.description());
|
app.setDescription(req.description());
|
||||||
app.setIconUrl(req.iconUrl());
|
app.setIconUrl(req.iconUrl());
|
||||||
|
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||||
AppEntity saved = appRepository.save(app);
|
AppEntity saved = appRepository.save(app);
|
||||||
Map<String, Object> after = new LinkedHashMap<>();
|
Map<String, Object> after = new LinkedHashMap<>();
|
||||||
after.put("name", saved.getName());
|
after.put("name", saved.getName());
|
||||||
@ -143,4 +160,32 @@ public class AppService {
|
|||||||
random.nextBytes(bytes);
|
random.nextBytes(bytes);
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(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 = "";
|
if (consoleDomain == null) consoleDomain = "";
|
||||||
|
|
||||||
String anchor = " SDK_TENANT_SERVICE_URL: \"http://tenant-service:9001\"\n";
|
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
|
||||||
emit.accept(" [跳过] docker-compose update-service 补丁锚点未找到,请手动检查");
|
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;
|
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);
|
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL");
|
emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户