package com.xuqm.tenant.controller; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.config.PrivateDeploymentProperties; import com.xuqm.tenant.dto.MigrateExportData; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.MigrateKeyEntity; import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.FeatureServiceRepository; import com.xuqm.tenant.repository.MigrateKeyRepository; import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.service.EmailService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.UUID; /** * Public platform side: generates migration keys and exports tenant data. * Only active when DEPLOYMENT_MODE != PRIVATE. */ @RestController @RequestMapping("/api/migrate") public class MigrateController { private static final String SYSTEM_APP_KEY = "ak_409e217e4aa14254ad73ad3c"; private static final String PURPOSE = "PRIVATE_MIGRATE"; private static final int KEY_EXPIRE_HOURS = 24; private static final SecureRandom RANDOM = new SecureRandom(); private final PrivateDeploymentProperties deployProps; private final EmailService emailService; private final MigrateKeyRepository migrateKeyRepository; private final TenantRepository tenantRepository; private final AppRepository appRepository; private final FeatureServiceRepository featureServiceRepository; public MigrateController(PrivateDeploymentProperties deployProps, EmailService emailService, MigrateKeyRepository migrateKeyRepository, TenantRepository tenantRepository, AppRepository appRepository, FeatureServiceRepository featureServiceRepository) { this.deployProps = deployProps; this.emailService = emailService; this.migrateKeyRepository = migrateKeyRepository; this.tenantRepository = tenantRepository; this.appRepository = appRepository; this.featureServiceRepository = featureServiceRepository; } /** Step 1: tenant requests email verification code. Requires login. */ @PostMapping("/request-code") public ResponseEntity> requestCode(@AuthenticationPrincipal String tenantId) { requirePublicMode(); TenantEntity tenant = tenantRepository.findById(tenantId) .orElseThrow(() -> new BusinessException("租户不存在")); emailService.sendVerificationCode(tenant.getEmail(), PURPOSE); return ResponseEntity.ok(ApiResponse.ok()); } /** Step 2: verify email code → generate a one-time migration key. Requires login. */ @PostMapping("/generate-key") @Transactional public ResponseEntity>> generateKey( @RequestBody Map body, @AuthenticationPrincipal String tenantId) { requirePublicMode(); TenantEntity tenant = tenantRepository.findById(tenantId) .orElseThrow(() -> new BusinessException("租户不存在")); emailService.verify(tenant.getEmail(), body.get("code"), PURPOSE); // Revoke previous unused keys for this tenant migrateKeyRepository.revokeUnusedByTenant(tenantId); // Generate plaintext key: pmk_ + 32 Base64URL chars byte[] raw = new byte[24]; RANDOM.nextBytes(raw); String plainKey = "pmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw); MigrateKeyEntity entity = new MigrateKeyEntity(); entity.setId(UUID.randomUUID().toString()); entity.setTenantId(tenantId); entity.setKeyHash(sha256(plainKey)); entity.setCreatedAt(LocalDateTime.now()); entity.setExpiresAt(LocalDateTime.now().plusHours(KEY_EXPIRE_HOURS)); migrateKeyRepository.save(entity); return ResponseEntity.ok(ApiResponse.success(Map.of("migrationKey", plainKey))); } /** Step 3: deploy script calls this with the migration key to export tenant data. No JWT required. */ @PostMapping("/export") @Transactional public ResponseEntity> export( @RequestBody Map body) { requirePublicMode(); String key = body.get("migrationKey"); if (key == null || key.isBlank()) { throw new BusinessException("迁移密钥不能为空"); } String hash = sha256(key); MigrateKeyEntity keyEntity = migrateKeyRepository.findByKeyHashAndUsedAtIsNull(hash) .orElseThrow(() -> new BusinessException("迁移密钥无效或已使用")); if (keyEntity.getExpiresAt().isBefore(LocalDateTime.now())) { throw new BusinessException("迁移密钥已过期,请重新生成"); } // Mark key as used keyEntity.setUsedAt(LocalDateTime.now()); migrateKeyRepository.save(keyEntity); TenantEntity tenant = tenantRepository.findById(keyEntity.getTenantId()) .orElseThrow(() -> new BusinessException("租户不存在")); List apps = appRepository.findByTenantId(tenant.getId()) .stream() .filter(a -> !SYSTEM_APP_KEY.equals(a.getAppKey())) .toList(); List appKeys = apps.stream().map(AppEntity::getAppKey).toList(); List featureServices = appKeys.isEmpty() ? List.of() : featureServiceRepository.findAll().stream() .filter(f -> appKeys.contains(f.getAppKey())) .toList(); MigrateExportData data = buildExport(tenant, apps, featureServices); return ResponseEntity.ok(ApiResponse.success(data)); } // ── Helpers ──────────────────────────────────────────────────────────────── private void requirePublicMode() { if (deployProps.isPrivate()) { throw new BusinessException(403, "此接口仅在公有化平台可用"); } } private MigrateExportData buildExport(TenantEntity tenant, List apps, List featureServices) { MigrateExportData data = new MigrateExportData(); MigrateExportData.TenantData td = new MigrateExportData.TenantData(); td.setId(tenant.getId()); td.setUsername(tenant.getUsername()); td.setEmail(tenant.getEmail()); td.setNickname(tenant.getNickname()); td.setPhone(tenant.getPhone()); td.setPasswordHash(tenant.getPassword()); td.setCreatedAt(tenant.getCreatedAt().toString()); data.setTenant(td); data.setApps(apps.stream().map(a -> { MigrateExportData.AppData ad = new MigrateExportData.AppData(); ad.setId(a.getId()); ad.setAppKey(a.getAppKey()); ad.setAppSecret(a.getAppSecret()); ad.setName(a.getName()); ad.setPackageName(a.getPackageName()); ad.setIosBundleId(a.getIosBundleId()); ad.setHarmonyBundleName(a.getHarmonyBundleName()); ad.setDescription(a.getDescription()); ad.setIconUrl(a.getIconUrl()); ad.setCreatedAt(a.getCreatedAt().toString()); return ad; }).toList()); data.setFeatureServices(featureServices.stream().map(f -> { MigrateExportData.FeatureServiceData fd = new MigrateExportData.FeatureServiceData(); fd.setId(f.getId()); fd.setAppKey(f.getAppKey()); fd.setPlatform(f.getPlatform().name()); fd.setServiceType(f.getServiceType().name()); fd.setEnabled(f.isEnabled()); fd.setSecretKey(f.getSecretKey()); fd.setConfig(f.getConfig()); fd.setCreatedAt(f.getCreatedAt().toString()); return fd; }).toList()); return data; } private static String sha256(String input) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hash); } catch (Exception e) { throw new RuntimeException("SHA-256 failed", e); } } }