From e5f0e7faea78c94ceef1864c4e3180c0e1c2c64c Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 18 May 2026 20:49:46 +0800 Subject: [PATCH] feat: private deployment server-side capabilities (P2) - PrivateDeploymentProperties: DEPLOYMENT_MODE/ENABLE_*/TENANT_BOOTSTRAP_ENABLED config binding - PrivateTenantBootstrapInitializer: auto-create main tenant and app from env vars when PRIVATE mode, idempotent - AuthService: block registration with XUQM_PRIVATE_2001 when TENANT_REGISTER_ENABLED=false - EmailService: block REGISTER email verification in private mode - SdkConfigController: intersect DB feature flags with ENABLE_* deployment flags for runtime degradation - PrivateDeploymentController: GET /api/private/deployment/status public endpoint - SecurityConfig: permit /api/private/deployment/status without auth - application.yml: add deployment.* and tenant.bootstrap.* config sections with env var bindings Co-Authored-By: Claude Sonnet 4.6 --- .../config/PrivateDeploymentProperties.java | 50 ++++++++++ .../PrivateTenantBootstrapInitializer.java | 94 +++++++++++++++++++ .../xuqm/tenant/config/SecurityConfig.java | 1 + .../PrivateDeploymentController.java | 70 ++++++++++++++ .../controller/SdkConfigController.java | 13 ++- .../com/xuqm/tenant/service/AuthService.java | 9 +- .../com/xuqm/tenant/service/EmailService.java | 9 +- .../src/main/resources/application.yml | 20 ++++ 8 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java new file mode 100644 index 0000000..729fda5 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateDeploymentProperties.java @@ -0,0 +1,50 @@ +package com.xuqm.tenant.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "deployment") +public class PrivateDeploymentProperties { + + private String mode = "PUBLIC"; + private boolean tenantRegisterEnabled = true; + private boolean tenantBootstrapEnabled = false; + private boolean enableIm = false; + private boolean enablePush = false; + private boolean enableUpdate = false; + private boolean enableLicense = false; + private boolean enableFile = true; + + public boolean isPrivate() { + return "PRIVATE".equalsIgnoreCase(mode); + } + + public String getMode() { return mode; } + public void setMode(String mode) { this.mode = mode; } + + public boolean isTenantRegisterEnabled() { return tenantRegisterEnabled; } + public void setTenantRegisterEnabled(boolean tenantRegisterEnabled) { + this.tenantRegisterEnabled = tenantRegisterEnabled; + } + + public boolean isTenantBootstrapEnabled() { return tenantBootstrapEnabled; } + public void setTenantBootstrapEnabled(boolean tenantBootstrapEnabled) { + this.tenantBootstrapEnabled = tenantBootstrapEnabled; + } + + public boolean isEnableIm() { return enableIm; } + public void setEnableIm(boolean enableIm) { this.enableIm = enableIm; } + + public boolean isEnablePush() { return enablePush; } + public void setEnablePush(boolean enablePush) { this.enablePush = enablePush; } + + 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; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java new file mode 100644 index 0000000..c1b2020 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java @@ -0,0 +1,94 @@ +package com.xuqm.tenant.config; + +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.TenantRepository; +import com.xuqm.tenant.service.SdkAppProvisioningService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Runs only when DEPLOYMENT_MODE=PRIVATE. + * Ensures the bootstrap main tenant and app exist without overwriting existing data. + */ +@Component +@Order(20) +public class PrivateTenantBootstrapInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(PrivateTenantBootstrapInitializer.class); + + private final PrivateDeploymentProperties deployProps; + private final TenantRepository tenantRepository; + private final AppRepository appRepository; + private final SdkAppProvisioningService provisioningService; + private final PasswordEncoder passwordEncoder; + + @Value("${tenant.bootstrap.email:admin@customer.com}") + private String bootstrapEmail; + + @Value("${tenant.bootstrap.password:ChangeMe@2026}") + private String bootstrapPassword; + + @Value("${tenant.bootstrap.username:admin}") + private String bootstrapUsername; + + @Value("${sdk.bootstrap-app-key:ak_private_default}") + private String bootstrapAppKey; + + public PrivateTenantBootstrapInitializer(PrivateDeploymentProperties deployProps, + TenantRepository tenantRepository, + AppRepository appRepository, + SdkAppProvisioningService provisioningService, + PasswordEncoder passwordEncoder) { + this.deployProps = deployProps; + this.tenantRepository = tenantRepository; + this.appRepository = appRepository; + this.provisioningService = provisioningService; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!deployProps.isPrivate() || !deployProps.isTenantBootstrapEnabled()) { + return; + } + + TenantEntity tenant = tenantRepository.findByEmail(bootstrapEmail) + .or(() -> tenantRepository.findByUsername(bootstrapUsername)) + .orElse(null); + + if (tenant == null) { + tenant = new TenantEntity(); + tenant.setId(UUID.randomUUID().toString()); + tenant.setUsername(bootstrapUsername); + tenant.setPassword(passwordEncoder.encode(bootstrapPassword)); + tenant.setEmail(bootstrapEmail); + tenant.setNickname("Admin"); + tenant.setType(TenantEntity.Type.MAIN); + tenant.setStatus(TenantEntity.Status.ACTIVE); + tenant.setCreatedAt(LocalDateTime.now()); + tenantRepository.save(tenant); + log.info("[PRIVATE] Bootstrap tenant created: {}", bootstrapEmail); + } else { + log.info("[PRIVATE] Bootstrap tenant already exists: {}", bootstrapEmail); + } + + AppEntity app = appRepository.findByAppKey(bootstrapAppKey).orElse(null); + if (app == null) { + provisioningService.ensureApp(bootstrapAppKey, true); + log.info("[PRIVATE] Bootstrap app created: {}", bootstrapAppKey); + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java index dd28284..9aed72d 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java @@ -37,6 +37,7 @@ public class SecurityConfig { "/api/sdk/**", "/api/internal/sdk/**", "/api/internal/im/**", + "/api/private/deployment/status", "/actuator/health", "/actuator/info" ).permitAll() diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java new file mode 100644 index 0000000..65d99fa --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/PrivateDeploymentController.java @@ -0,0 +1,70 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.config.PrivateDeploymentProperties; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Public endpoint — returns deployment mode and enabled service capabilities. + * Safe to expose without authentication: contains no credentials. + */ +@RestController +@RequestMapping("/api/private/deployment") +public class PrivateDeploymentController { + + private final PrivateDeploymentProperties deployProps; + + @Value("${sdk.im-api-url:}") + private String imApiUrl; + + @Value("${sdk.file-service-url:}") + private String fileServiceUrl; + + @Value("${deployment.im-domain:}") + private String imDomain; + + @Value("${deployment.push-domain:}") + private String pushDomain; + + @Value("${deployment.update-domain:}") + private String updateDomain; + + @Value("${deployment.license-domain:}") + private String licenseDomain; + + public PrivateDeploymentController(PrivateDeploymentProperties deployProps) { + this.deployProps = deployProps; + } + + @GetMapping("/status") + public ResponseEntity>> status() { + Map services = new LinkedHashMap<>(); + + services.put("file", serviceStatus(deployProps.isEnableFile(), fileServiceUrl)); + 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 body = new LinkedHashMap<>(); + body.put("mode", deployProps.getMode()); + body.put("tenantRegisterEnabled", deployProps.isTenantRegisterEnabled()); + body.put("services", services); + + return ResponseEntity.ok(ApiResponse.success(body)); + } + + private Map serviceStatus(boolean enabled, String baseUrl) { + Map m = new LinkedHashMap<>(); + m.put("enabled", enabled); + m.put("baseUrl", enabled ? baseUrl : null); + return m; + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java index d908df0..79eadde 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -3,6 +3,7 @@ package com.xuqm.tenant.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.repository.FeatureServiceRepository; @@ -24,6 +25,7 @@ public class SdkConfigController { private final FeatureServiceRepository featureServiceRepository; private final SdkAppProvisioningService sdkAppProvisioningService; private final ObjectMapper objectMapper; + private final PrivateDeploymentProperties deployProps; @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") private String imWsUrl; @@ -36,10 +38,12 @@ public class SdkConfigController { public SdkConfigController(FeatureServiceRepository featureServiceRepository, SdkAppProvisioningService sdkAppProvisioningService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PrivateDeploymentProperties deployProps) { this.featureServiceRepository = featureServiceRepository; this.sdkAppProvisioningService = sdkAppProvisioningService; this.objectMapper = objectMapper; + this.deployProps = deployProps; } /** @@ -58,9 +62,10 @@ public class SdkConfigController { AppEntity app = sdkAppProvisioningService.resolveApp(appKey); List features = featureServiceRepository.findByAppKey(app.getAppKey()); - boolean imEnabled = features.stream() + // In private deployments, intersect DB feature flags with deployment-level service availability + boolean imEnabled = deployProps.isEnableIm() && features.stream() .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled()); - boolean pushEnabled = features.stream() + boolean pushEnabled = deployProps.isEnablePush() && features.stream() .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && f.isEnabled()); JsonNode updateConfig = featureServiceRepository .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) @@ -70,7 +75,7 @@ public class SdkConfigController { .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH) .map(feature -> parseConfig(feature.getConfig())) .orElseGet(objectMapper::createObjectNode); - boolean updateEnabled = featureServiceRepository + boolean updateEnabled = deployProps.isEnableUpdate() && featureServiceRepository .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) .map(FeatureServiceEntity::isEnabled) .orElse(false); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java index bd64fcc..c0d6fcf 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java @@ -2,6 +2,7 @@ package com.xuqm.tenant.service; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.security.JwtUtil; +import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.dto.LoginRequest; import com.xuqm.tenant.dto.RegisterRequest; import com.xuqm.tenant.entity.TenantEntity; @@ -23,19 +24,25 @@ public class AuthService { private final JwtUtil jwtUtil; private final EmailService emailService; private final StringRedisTemplate redis; + private final PrivateDeploymentProperties deployProps; private static final String CAPTCHA_PREFIX = "captcha:"; public AuthService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, - JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis) { + JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis, + PrivateDeploymentProperties deployProps) { this.tenantRepository = tenantRepository; this.passwordEncoder = passwordEncoder; this.jwtUtil = jwtUtil; this.emailService = emailService; this.redis = redis; + this.deployProps = deployProps; } public void register(RegisterRequest req) { + if (!deployProps.isTenantRegisterEnabled()) { + throw new BusinessException(403, "[XUQM_PRIVATE_2001] 私有化部署不支持注册新租户,请联系管理员"); + } emailService.verify(req.email(), req.emailCode(), "REGISTER"); if (tenantRepository.existsByUsername(req.username())) { diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java index ae2d580..bad3732 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java @@ -1,6 +1,7 @@ package com.xuqm.tenant.service; import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.entity.EmailVerificationEntity; import com.xuqm.tenant.repository.EmailVerificationRepository; import org.springframework.beans.factory.annotation.Value; @@ -18,6 +19,7 @@ public class EmailService { private final JavaMailSender mailSender; private final EmailVerificationRepository verificationRepository; + private final PrivateDeploymentProperties deployProps; @Value("${spring.mail.username}") private String fromAddress; @@ -27,13 +29,18 @@ public class EmailService { private static final SecureRandom random = new SecureRandom(); - public EmailService(JavaMailSender mailSender, EmailVerificationRepository verificationRepository) { + public EmailService(JavaMailSender mailSender, EmailVerificationRepository verificationRepository, + PrivateDeploymentProperties deployProps) { this.mailSender = mailSender; this.verificationRepository = verificationRepository; + this.deployProps = deployProps; } @Transactional public void sendVerificationCode(String email, String purpose) { + if ("REGISTER".equals(purpose) && !deployProps.isTenantRegisterEnabled()) { + throw new BusinessException(403, "[XUQM_PRIVATE_2001] 私有化部署不支持注册新租户"); + } String code = String.format("%06d", random.nextInt(1_000_000)); EmailVerificationEntity entity = new EmailVerificationEntity(); diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index 150d95b..9413743 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -97,3 +97,23 @@ sdk: 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} + +deployment: + mode: ${DEPLOYMENT_MODE:PUBLIC} + tenant-register-enabled: ${TENANT_REGISTER_ENABLED:true} + tenant-bootstrap-enabled: ${TENANT_BOOTSTRAP_ENABLED:false} + 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: + email: ${TENANT_BOOTSTRAP_EMAIL:admin@customer.com} + username: ${TENANT_BOOTSTRAP_USERNAME:admin} + password: ${TENANT_BOOTSTRAP_PASSWORD:ChangeMe@2026}