- Add MigrateController: request-code / generate-key / export endpoints with one-time pmk_ key (SHA-256 hashed, 24h expiry) - Add PrivateDeploymentController import endpoint for private mode only - Add MigrateKeyEntity / MigrateKeyRepository for key lifecycle - Add MigrateExportData DTO (tenant + apps + feature services) - Add AppEntity.isDefault / deletable fields - Add AppRepository.deleteAllExcept / FeatureServiceRepository.deleteAllExcept - Permit /api/migrate/export and /api/private/migrate/import in SecurityConfig Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
216 行
9.1 KiB
Java
216 行
9.1 KiB
Java
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<ApiResponse<Void>> 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<ApiResponse<Map<String, String>>> generateKey(
|
|
@RequestBody Map<String, String> 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<ApiResponse<MigrateExportData>> export(
|
|
@RequestBody Map<String, String> 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<AppEntity> apps = appRepository.findByTenantId(tenant.getId())
|
|
.stream()
|
|
.filter(a -> !SYSTEM_APP_KEY.equals(a.getAppKey()))
|
|
.toList();
|
|
|
|
List<String> appKeys = apps.stream().map(AppEntity::getAppKey).toList();
|
|
List<FeatureServiceEntity> 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<AppEntity> apps,
|
|
List<FeatureServiceEntity> 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);
|
|
}
|
|
}
|
|
}
|