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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-18 20:49:46 +08:00
父节点 4d54d2a4a4
当前提交 e5f0e7faea
共有 8 个文件被更改,包括 260 次插入6 次删除

查看文件

@ -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; }
}

查看文件

@ -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);
}
}
}

查看文件

@ -37,6 +37,7 @@ public class SecurityConfig {
"/api/sdk/**", "/api/sdk/**",
"/api/internal/sdk/**", "/api/internal/sdk/**",
"/api/internal/im/**", "/api/internal/im/**",
"/api/private/deployment/status",
"/actuator/health", "/actuator/health",
"/actuator/info" "/actuator/info"
).permitAll() ).permitAll()

查看文件

@ -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<ApiResponse<Map<String, Object>>> status() {
Map<String, Object> 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<String, Object> 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<String, Object> serviceStatus(boolean enabled, String baseUrl) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("enabled", enabled);
m.put("baseUrl", enabled ? baseUrl : null);
return m;
}
}

查看文件

@ -3,6 +3,7 @@ package com.xuqm.tenant.controller;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.config.PrivateDeploymentProperties;
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.FeatureServiceRepository; import com.xuqm.tenant.repository.FeatureServiceRepository;
@ -24,6 +25,7 @@ public class SdkConfigController {
private final FeatureServiceRepository featureServiceRepository; private final FeatureServiceRepository featureServiceRepository;
private final SdkAppProvisioningService sdkAppProvisioningService; private final SdkAppProvisioningService sdkAppProvisioningService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final PrivateDeploymentProperties deployProps;
@Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}")
private String imWsUrl; private String imWsUrl;
@ -36,10 +38,12 @@ public class SdkConfigController {
public SdkConfigController(FeatureServiceRepository featureServiceRepository, public SdkConfigController(FeatureServiceRepository featureServiceRepository,
SdkAppProvisioningService sdkAppProvisioningService, SdkAppProvisioningService sdkAppProvisioningService,
ObjectMapper objectMapper) { ObjectMapper objectMapper,
PrivateDeploymentProperties deployProps) {
this.featureServiceRepository = featureServiceRepository; this.featureServiceRepository = featureServiceRepository;
this.sdkAppProvisioningService = sdkAppProvisioningService; this.sdkAppProvisioningService = sdkAppProvisioningService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.deployProps = deployProps;
} }
/** /**
@ -58,9 +62,10 @@ public class SdkConfigController {
AppEntity app = sdkAppProvisioningService.resolveApp(appKey); AppEntity app = sdkAppProvisioningService.resolveApp(appKey);
List<FeatureServiceEntity> features = featureServiceRepository.findByAppKey(app.getAppKey()); List<FeatureServiceEntity> 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()); .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()); .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && f.isEnabled());
JsonNode updateConfig = featureServiceRepository JsonNode updateConfig = featureServiceRepository
.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
@ -70,7 +75,7 @@ public class SdkConfigController {
.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH) .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH)
.map(feature -> parseConfig(feature.getConfig())) .map(feature -> parseConfig(feature.getConfig()))
.orElseGet(objectMapper::createObjectNode); .orElseGet(objectMapper::createObjectNode);
boolean updateEnabled = featureServiceRepository boolean updateEnabled = deployProps.isEnableUpdate() && featureServiceRepository
.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) .findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
.map(FeatureServiceEntity::isEnabled) .map(FeatureServiceEntity::isEnabled)
.orElse(false); .orElse(false);

查看文件

@ -2,6 +2,7 @@ package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import com.xuqm.tenant.config.PrivateDeploymentProperties;
import com.xuqm.tenant.dto.LoginRequest; import com.xuqm.tenant.dto.LoginRequest;
import com.xuqm.tenant.dto.RegisterRequest; import com.xuqm.tenant.dto.RegisterRequest;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
@ -23,19 +24,25 @@ public class AuthService {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final EmailService emailService; private final EmailService emailService;
private final StringRedisTemplate redis; private final StringRedisTemplate redis;
private final PrivateDeploymentProperties deployProps;
private static final String CAPTCHA_PREFIX = "captcha:"; private static final String CAPTCHA_PREFIX = "captcha:";
public AuthService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, public AuthService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder,
JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis) { JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis,
PrivateDeploymentProperties deployProps) {
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.emailService = emailService; this.emailService = emailService;
this.redis = redis; this.redis = redis;
this.deployProps = deployProps;
} }
public void register(RegisterRequest req) { public void register(RegisterRequest req) {
if (!deployProps.isTenantRegisterEnabled()) {
throw new BusinessException(403, "[XUQM_PRIVATE_2001] 私有化部署不支持注册新租户,请联系管理员");
}
emailService.verify(req.email(), req.emailCode(), "REGISTER"); emailService.verify(req.email(), req.emailCode(), "REGISTER");
if (tenantRepository.existsByUsername(req.username())) { if (tenantRepository.existsByUsername(req.username())) {

查看文件

@ -1,6 +1,7 @@
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.tenant.config.PrivateDeploymentProperties;
import com.xuqm.tenant.entity.EmailVerificationEntity; import com.xuqm.tenant.entity.EmailVerificationEntity;
import com.xuqm.tenant.repository.EmailVerificationRepository; import com.xuqm.tenant.repository.EmailVerificationRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -18,6 +19,7 @@ public class EmailService {
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
private final EmailVerificationRepository verificationRepository; private final EmailVerificationRepository verificationRepository;
private final PrivateDeploymentProperties deployProps;
@Value("${spring.mail.username}") @Value("${spring.mail.username}")
private String fromAddress; private String fromAddress;
@ -27,13 +29,18 @@ public class EmailService {
private static final SecureRandom random = new SecureRandom(); 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.mailSender = mailSender;
this.verificationRepository = verificationRepository; this.verificationRepository = verificationRepository;
this.deployProps = deployProps;
} }
@Transactional @Transactional
public void sendVerificationCode(String email, String purpose) { 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)); String code = String.format("%06d", random.nextInt(1_000_000));
EmailVerificationEntity entity = new EmailVerificationEntity(); EmailVerificationEntity entity = new EmailVerificationEntity();

查看文件

@ -97,3 +97,23 @@ sdk:
im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform} 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-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}
im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c} 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}