feat:备份恢复完善用户密钥的流程
This commit is contained in:
@@ -46,7 +46,7 @@ public class ResourceBackupController {
|
||||
|
||||
@PostMapping
|
||||
@ReplayProtected
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.BACKUP, summary = "创建资源备份任务")
|
||||
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
|
||||
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) {
|
||||
@@ -54,7 +54,7 @@ public class ResourceBackupController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}")
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源备份任务详情", description = "返回资源备份任务详情。")
|
||||
public ApiResponse<ResourceBackupTaskDetailResponse> taskDetail(
|
||||
@Parameter(description = "备份任务号")
|
||||
@@ -64,7 +64,7 @@ public class ResourceBackupController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}/download")
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.EXPORT, summary = "下载资源备份包")
|
||||
@Operation(summary = "下载资源备份包", description = "以浏览器附件方式下载资源备份包。")
|
||||
public ResponseEntity<Resource> downloadPackage(
|
||||
@@ -81,7 +81,7 @@ public class ResourceBackupController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}/steps")
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源备份任务步骤", description = "返回资源备份任务步骤列表。")
|
||||
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
|
||||
@Parameter(description = "备份任务号")
|
||||
@@ -91,7 +91,7 @@ public class ResourceBackupController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}/steps/{stepNo}/log")
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源备份步骤日志", description = "返回资源备份任务步骤日志。")
|
||||
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
|
||||
@Parameter(description = "备份任务号")
|
||||
|
||||
@@ -43,7 +43,7 @@ public class ResourceRestoreController {
|
||||
private final ResourceTaskQueryService resourceTaskQueryService;
|
||||
|
||||
@PostMapping("/precheck")
|
||||
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.ACCESS, summary = "预检资源恢复包")
|
||||
@Operation(summary = "预检资源恢复请求", description = "根据 fileId 校验备份包头、签名和恢复范围摘要。")
|
||||
public ApiResponse<ResourceRestorePrecheckResponse> precheck(@Valid @RequestBody ResourceRestorePrecheckRequest request) {
|
||||
@@ -52,7 +52,7 @@ public class ResourceRestoreController {
|
||||
|
||||
@PostMapping
|
||||
@ReplayProtected
|
||||
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "创建资源恢复任务")
|
||||
@Operation(summary = "创建资源恢复任务", description = "根据预检号创建资源恢复任务。")
|
||||
public ApiResponse<CreateResourceRestoreResponse> createRestoreTask(@Valid @RequestBody CreateResourceRestoreRequest request) {
|
||||
@@ -60,7 +60,7 @@ public class ResourceRestoreController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}")
|
||||
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。")
|
||||
public ApiResponse<ResourceRestoreTaskDetailResponse> taskDetail(
|
||||
@Parameter(description = "恢复任务号")
|
||||
@@ -70,7 +70,7 @@ public class ResourceRestoreController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}/steps")
|
||||
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。")
|
||||
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
|
||||
@Parameter(description = "恢复任务号")
|
||||
@@ -80,7 +80,7 @@ public class ResourceRestoreController {
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}/steps/{stepNo}/log")
|
||||
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
|
||||
@Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。")
|
||||
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
|
||||
@Parameter(description = "恢复任务号")
|
||||
|
||||
@@ -27,15 +27,23 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
|
||||
throw new IllegalArgumentException("productType和mqType不能为空");
|
||||
}
|
||||
|
||||
// 资源发现只决定“应该备份什么”,不在这里触碰文件系统、数据库或密码卡。
|
||||
// 真实采集放在 ResourcePackageServiceImpl 中完成,便于统一处理必选资源缺失和包内 manifest。
|
||||
List<DiscoveredResourceItem> items = new ArrayList<>();
|
||||
// TMS 自身配置目录是恢复后服务能否按原部署参数启动的基础资源,按目录递归打包。
|
||||
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
|
||||
// 收发器配置和 license 按产品形态切换路径:标准版走 cmep4i,直连版走 direct 目标路径。
|
||||
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
|
||||
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
|
||||
// 数据库资源在包内固定成 db/*.sql,恢复脚本通过 restore-databases.tsv 再决定导入目标库名。
|
||||
items.add(database("TMS_DB", "TMS", "db/TMS.sql", true));
|
||||
if (!"DIRECT".equals(productType)) {
|
||||
// 第一版标准版迁移需要把收发器业务库一起带走,DIRECT 暂时不要求这份库。
|
||||
items.add(database("CMEP_DB", "CMEP", "db/CMEP.sql", true));
|
||||
}
|
||||
// 实体证书表记录的 keyIdx 对应密码卡内用户签名密钥;备份时会按这些索引逐个调用 backupUserKey。
|
||||
items.add(userKey(true));
|
||||
// MQ 不直接备份运行时队列数据,第一版只生成可 replay 的上下文,交给现场脚本恢复配置。
|
||||
items.add(mqReplay(true));
|
||||
return items;
|
||||
}
|
||||
@@ -85,6 +93,14 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private static DiscoveredResourceItem userKey(boolean required) {
|
||||
DiscoveredResourceItem item = new DiscoveredResourceItem();
|
||||
item.setResourceType("USER_KEY");
|
||||
item.setResourceCode("USER_KEY");
|
||||
item.setRequired(required);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static String upper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
private final ResourceBackupProperties resourceBackupProperties;
|
||||
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
|
||||
private final DatabaseBackupCollector databaseBackupCollector;
|
||||
private final ResourceUserKeyBackupCollector resourceUserKeyBackupCollector;
|
||||
private final MqReplayContextBuilder mqReplayContextBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -46,9 +47,33 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
ResourceBackupProperties resourceBackupProperties,
|
||||
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
|
||||
DatabaseBackupCollector databaseBackupCollector,
|
||||
ResourceUserKeyBackupCollector resourceUserKeyBackupCollector,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this(resourceBackupProperties, resourceEnvelopeCryptoService, databaseBackupCollector, new MqReplayContextBuilder(objectMapper), objectMapper);
|
||||
this(
|
||||
resourceBackupProperties,
|
||||
resourceEnvelopeCryptoService,
|
||||
databaseBackupCollector,
|
||||
resourceUserKeyBackupCollector,
|
||||
new MqReplayContextBuilder(objectMapper),
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
|
||||
public ResourcePackageServiceImpl(
|
||||
ResourceBackupProperties resourceBackupProperties,
|
||||
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
|
||||
DatabaseBackupCollector databaseBackupCollector,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this(
|
||||
resourceBackupProperties,
|
||||
resourceEnvelopeCryptoService,
|
||||
databaseBackupCollector,
|
||||
ResourceUserKeyBackupCollector.empty(objectMapper),
|
||||
new MqReplayContextBuilder(objectMapper),
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
|
||||
public ResourcePackageServiceImpl(
|
||||
@@ -57,10 +82,29 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
DatabaseBackupCollector databaseBackupCollector,
|
||||
MqReplayContextBuilder mqReplayContextBuilder,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this(
|
||||
resourceBackupProperties,
|
||||
resourceEnvelopeCryptoService,
|
||||
databaseBackupCollector,
|
||||
ResourceUserKeyBackupCollector.empty(objectMapper),
|
||||
mqReplayContextBuilder,
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
|
||||
public ResourcePackageServiceImpl(
|
||||
ResourceBackupProperties resourceBackupProperties,
|
||||
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
|
||||
DatabaseBackupCollector databaseBackupCollector,
|
||||
ResourceUserKeyBackupCollector resourceUserKeyBackupCollector,
|
||||
MqReplayContextBuilder mqReplayContextBuilder,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.resourceBackupProperties = resourceBackupProperties;
|
||||
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
|
||||
this.databaseBackupCollector = databaseBackupCollector;
|
||||
this.resourceUserKeyBackupCollector = resourceUserKeyBackupCollector;
|
||||
this.mqReplayContextBuilder = mqReplayContextBuilder;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -87,8 +131,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
Files.createDirectories(taskDir);
|
||||
|
||||
// 资源恢复真正消费的是“受保护的内部归档”,而不是一段摘要 JSON。
|
||||
// 这里先把可采集的文件资源打成 payload.zip,再统一交给数字信封加密,
|
||||
// 后续即使补数据库导出/MQ definitions,也仍然沿着这个边界扩展。
|
||||
// 这里先把文件、数据库 dump、用户密钥备份和 MQ 上下文打成 payload.zip,
|
||||
// 再统一交给数字信封加密。外层 .tmsbak 只暴露包头、信封、密文和签名。
|
||||
PayloadArchiveBuildResult payloadArchive = buildPayloadArchive(
|
||||
taskId,
|
||||
backupId,
|
||||
@@ -96,6 +140,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
discoveredItems,
|
||||
request
|
||||
);
|
||||
// seal 会生成随机 SM4 会话密钥加密 payload.zip,并用配置的 PCIe 用户加密密钥封装该会话密钥。
|
||||
// 之后恢复端必须先验签,再解 envelope 拿回会话密钥,最后才能得到 payload 明文。
|
||||
ResourceSealedPayload sealedPayload = resourceEnvelopeCryptoService.seal(payloadArchive.archiveBytes());
|
||||
|
||||
// manifest 只保留恢复前需要快速识别的元数据,不直接展开敏感资源内容。
|
||||
@@ -233,6 +279,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
|
||||
for (DiscoveredResourceItem item : discoveredItems) {
|
||||
// 每个资源都在 payload-manifest.json 中留一条资源级记录。
|
||||
// 恢复端不会重新推断资源范围,而是严格按这个 manifest 生成恢复计划。
|
||||
Map<String, Object> resourceEntry = new LinkedHashMap<>();
|
||||
resourceEntry.put("resourceType", item.getResourceType());
|
||||
resourceEntry.put("resourceCode", item.getResourceCode());
|
||||
@@ -246,6 +294,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
resourceEntries.add(resourceEntry);
|
||||
}
|
||||
payloadManifest.put("resources", resourceEntries);
|
||||
// payload-manifest.json 是恢复端的唯一内部目录索引:
|
||||
// 文件、数据库、MQ、用户密钥都通过这里的 entryPath 找到 payload 中的真实条目。
|
||||
addZipEntry(
|
||||
zipOutputStream,
|
||||
"payload-manifest.json",
|
||||
@@ -261,12 +311,17 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
DiscoveredResourceItem item,
|
||||
Map<String, Object> resourceEntry
|
||||
) throws IOException {
|
||||
// 资源类型分派只认 V1 已实现的四类:文件、数据库、用户密钥和 MQ replay。
|
||||
// 必选资源一旦采集失败会直接中断打包,避免生成看似成功但无法完整恢复的包。
|
||||
if ("DATABASE".equalsIgnoreCase(trim(item.getResourceType())) || "DB".equalsIgnoreCase(trim(item.getResourceType()))) {
|
||||
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
|
||||
}
|
||||
if ("MQ_REPLAY".equalsIgnoreCase(trim(item.getResourceType()))) {
|
||||
return collectMqReplayItem(zipOutputStream, initTask, item, resourceEntry);
|
||||
}
|
||||
if ("USER_KEY".equalsIgnoreCase(trim(item.getResourceType()))) {
|
||||
return collectUserKeyItem(zipOutputStream, item, resourceEntry);
|
||||
}
|
||||
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
|
||||
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
|
||||
resourceEntry.put("collected", Boolean.FALSE);
|
||||
@@ -277,6 +332,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
resourceEntry.put("mode", trim(item.getMode()).isEmpty() ? "FILE" : trim(item.getMode()));
|
||||
resourceEntry.put("sourcePath", item.getResolvedPath());
|
||||
resourceEntry.put("restorePath", trim(item.getRestorePath()).isEmpty() ? item.getResolvedPath() : item.getRestorePath());
|
||||
// 文件类资源在备份端只允许采集真实存在的文件/目录;恢复端还会再次校验 restorePath 白名单。
|
||||
Path resolvedPath = toResolvedPath(item.getResolvedPath());
|
||||
if (resolvedPath == null || !Files.exists(resolvedPath)) {
|
||||
assertOptionalResource(item, "PATH_NOT_FOUND");
|
||||
@@ -325,6 +381,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
DiscoveredResourceItem item,
|
||||
Map<String, Object> resourceEntry
|
||||
) throws IOException {
|
||||
// 数据库资源在 manifest 中表现为 DATABASE,但真实内容是 payload 内的一份 SQL dump 文件。
|
||||
// shell 恢复阶段会读取 restore-databases.tsv,再调用 restore-db.sh 导入到指定库。
|
||||
DatabaseBackupCollector.CollectedDatabaseDump dump = resolveDatabaseDump(item);
|
||||
if (dump == null) {
|
||||
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
|
||||
@@ -371,6 +429,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
DiscoveredResourceItem item,
|
||||
Map<String, Object> resourceEntry
|
||||
) throws IOException {
|
||||
// MQ 第一版只生成恢复上下文,不直接操作 RabbitMQ/TLQ/CFMQ。
|
||||
// 这样 Java 包结构稳定,具体 replay 命令可由部署环境提供脚本适配。
|
||||
byte[] contextBytes = mqReplayContextBuilder.build(initTask);
|
||||
String entryPath = trim(item.getEntryPath()).isEmpty() ? "mq/mq-restore-context.json" : trim(item.getEntryPath());
|
||||
addZipEntry(zipOutputStream, entryPath, contextBytes);
|
||||
@@ -381,6 +441,23 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private int collectUserKeyItem(
|
||||
ZipOutputStream zipOutputStream,
|
||||
DiscoveredResourceItem item,
|
||||
Map<String, Object> resourceEntry
|
||||
) throws IOException {
|
||||
try {
|
||||
// 用户密钥备份必须在 payload 加密之前完成;备份 blob 本身仍会随 payload 一起被 SM4 加密保护。
|
||||
return resourceUserKeyBackupCollector.collect(zipOutputStream, resourceEntry);
|
||||
} catch (RuntimeException ex) {
|
||||
assertOptionalResource(item, "USER_KEY_BACKUP_FAILED");
|
||||
resourceEntry.put("kind", "USER_KEY");
|
||||
resourceEntry.put("collected", Boolean.FALSE);
|
||||
resourceEntry.put("skipReason", "USER_KEY_BACKUP_FAILED");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
|
||||
String resourceCode = trim(item.getResourceCode());
|
||||
if ("TMS_DB".equalsIgnoreCase(resourceCode) || "TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
|
||||
|
||||
@@ -38,9 +38,12 @@ public class ResourcePayloadExtractor {
|
||||
public ResourcePayloadExtractionResult extract(Path packagePath, Path workDir) {
|
||||
Path normalizedWorkDir = workDir.normalize();
|
||||
Path payloadDir = normalizedWorkDir.resolve("payload").normalize();
|
||||
// inspectPackage 会完成外层包验签、数字信封解密,并返回 payload.zip 明文字节。
|
||||
// 预检阶段不会解密 payload,真正恢复创建时才走这条完整解包路径。
|
||||
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
|
||||
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
|
||||
.orElseThrow(() -> new IllegalStateException("payload归档内容缺失"));
|
||||
// payload.zip 中包含配置文件、SQL dump、MQ 上下文、用户密钥备份文件和 payload-manifest.json。
|
||||
extractPayloadArchive(payloadArchiveBytes, payloadDir);
|
||||
|
||||
Path manifestPath = payloadDir.resolve("payload-manifest.json").normalize();
|
||||
@@ -48,7 +51,10 @@ public class ResourcePayloadExtractor {
|
||||
throw new IllegalStateException("payload清单缺失:" + manifestPath);
|
||||
}
|
||||
Map<String, Object> manifest = readManifest(manifestPath);
|
||||
// 文件恢复目标在启动 shell 前就校验白名单。数据库、MQ、用户密钥不走文件覆盖路径,不在这里校验 restorePath。
|
||||
validateRestoreTargets(payloadDir, manifest);
|
||||
// Java 把 manifest 转成 shell 最容易处理的 TSV:
|
||||
// shell 不需要理解完整 JSON,只按行读取“payload entry -> 目标位置/目标库/上下文文件”。
|
||||
writeShellPlans(normalizedWorkDir, manifest);
|
||||
Path restoreContextPath = writeRestoreContext(packagePath, normalizedWorkDir, payloadDir, manifestPath, manifest);
|
||||
|
||||
@@ -133,12 +139,15 @@ public class ResourcePayloadExtractor {
|
||||
if ("FILE".equalsIgnoreCase(kind)) {
|
||||
appendFilePlanLines(fileLines, resource);
|
||||
} else if ("DATABASE".equalsIgnoreCase(kind) || "DB".equalsIgnoreCase(kind)) {
|
||||
// 数据库 TSV 格式:payload 内 SQL 文件路径 + 目标数据库名。
|
||||
// restore-db.sh 负责具体 mysql 命令和错误处理。
|
||||
String entryPath = firstText(resource, "entryPath");
|
||||
String databaseName = normalizeDatabaseName(firstText(resource, "databaseName", "database", "resourceCode", "code"));
|
||||
if (!entryPath.isEmpty()) {
|
||||
databaseLines.add(entryPath + "\t" + databaseName);
|
||||
}
|
||||
} else if ("MQ_REPLAY".equalsIgnoreCase(kind)) {
|
||||
// MQ TSV 只传上下文文件路径,具体 replay 策略交给部署环境脚本。
|
||||
String entryPath = firstText(resource, "entryPath");
|
||||
if (!entryPath.isEmpty()) {
|
||||
mqLines.add(entryPath);
|
||||
@@ -224,6 +233,7 @@ public class ResourcePayloadExtractor {
|
||||
String relativeName = firstText(fileEntry, "relativeName");
|
||||
String mode = firstText(resource, "mode");
|
||||
if (!relativeName.isEmpty() && "DIR".equalsIgnoreCase(mode)) {
|
||||
// 目录资源在备份包中被拆成多个相对路径文件,恢复时再拼回原目录结构。
|
||||
return Path.of(resourceRestorePath).resolve(relativeName).normalize().toString();
|
||||
}
|
||||
return resourceRestorePath;
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.cisd.tms.modules.backup.packagex;
|
||||
|
||||
import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType;
|
||||
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
|
||||
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
|
||||
import com.cisd.tms.modules.cert.entity.KeyEntity;
|
||||
import com.cisd.tms.modules.cert.repository.KeyEntityRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ResourceUserKeyBackupCollector {
|
||||
|
||||
private static final int USER_KEY_BACKUP_BUFFER_SIZE = 4096;
|
||||
private static final int USER_KEY_TYPE = PcieUserKeyType.SIGN.getCode();
|
||||
|
||||
private final KeyEntityRepository keyEntityRepository;
|
||||
private final PcieCryptoService pcieCryptoService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ResourceUserKeyBackupCollector(
|
||||
KeyEntityRepository keyEntityRepository,
|
||||
PcieCryptoService pcieCryptoService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.keyEntityRepository = keyEntityRepository;
|
||||
this.pcieCryptoService = pcieCryptoService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
static ResourceUserKeyBackupCollector empty(ObjectMapper objectMapper) {
|
||||
return new ResourceUserKeyBackupCollector(null, null, objectMapper);
|
||||
}
|
||||
|
||||
int collect(ZipOutputStream zipOutputStream, Map<String, Object> resourceEntry) throws IOException {
|
||||
// USER_KEY 资源的来源不是文件路径,而是实体证书表 t_key_entity 中已经分配过的 keyIdx。
|
||||
// 每个 keyIdx 对应密码卡内的一把用户签名密钥,备份包要保证这些索引在新机器恢复后仍可用。
|
||||
resourceEntry.put("kind", "USER_KEY");
|
||||
resourceEntry.put("keyType", USER_KEY_TYPE);
|
||||
List<Integer> keyIndexes = keyIndexes();
|
||||
resourceEntry.put("keyCount", keyIndexes.size());
|
||||
if (keyIndexes.isEmpty()) {
|
||||
resourceEntry.put("collected", Boolean.FALSE);
|
||||
resourceEntry.put("skipReason", "NO_USER_KEYS");
|
||||
resourceEntry.put("keys", List.of());
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> keys = new ArrayList<>();
|
||||
for (Integer keyIdx : keyIndexes) {
|
||||
// 厂商接口返回的是可导入 recoverUserKey 的加密密钥 blob,不是明文私钥。
|
||||
// 这里仍把它放进 payload 内部,再由外层数字信封整体加密和签名。
|
||||
BackupDataResult backupData = pcieCryptoService.backupUserKey(keyIdx, USER_KEY_TYPE, USER_KEY_BACKUP_BUFFER_SIZE);
|
||||
byte[] encryptedKey = normalizeBackupData(backupData);
|
||||
if (encryptedKey.length == 0) {
|
||||
throw new IllegalStateException("用户密钥备份数据为空:" + keyIdx);
|
||||
}
|
||||
String entryPath = "keys/user-key-" + keyIdx + ".json";
|
||||
// 独立 key 文件保存恢复所需的最小参数:keyIdx、keyType 和加密 key blob。
|
||||
// manifest 中只放 entryPath 和摘要,恢复端按 entryPath 再读取具体内容。
|
||||
Map<String, Object> keyFile = new LinkedHashMap<>();
|
||||
keyFile.put("schemaVersion", "1.0");
|
||||
keyFile.put("keyIdx", keyIdx);
|
||||
keyFile.put("keyType", USER_KEY_TYPE);
|
||||
keyFile.put("encryptedKeyBase64", Base64.getEncoder().encodeToString(encryptedKey));
|
||||
keyFile.put("length", encryptedKey.length);
|
||||
keyFile.put("contentSm3", sm3Hex(encryptedKey));
|
||||
addZipEntry(zipOutputStream, entryPath, objectMapper.writeValueAsBytes(keyFile));
|
||||
|
||||
Map<String, Object> keyEntry = new LinkedHashMap<>();
|
||||
keyEntry.put("keyIdx", keyIdx);
|
||||
keyEntry.put("keyType", USER_KEY_TYPE);
|
||||
keyEntry.put("entryPath", entryPath);
|
||||
keyEntry.put("length", encryptedKey.length);
|
||||
keyEntry.put("contentSm3", sm3Hex(encryptedKey));
|
||||
keys.add(keyEntry);
|
||||
}
|
||||
resourceEntry.put("collected", Boolean.TRUE);
|
||||
resourceEntry.put("keys", keys);
|
||||
return keys.size();
|
||||
}
|
||||
|
||||
private List<Integer> keyIndexes() {
|
||||
if (keyEntityRepository == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<KeyEntity> entities = keyEntityRepository.findAll();
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
// 同一个 keyIdx 可能因为历史数据或测试夹具重复出现,恢复只应导入一次。
|
||||
// 排序保证包内容稳定,便于测试、审计和人工排查。
|
||||
return entities.stream()
|
||||
.map(KeyEntity::getKeyIdx)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static byte[] normalizeBackupData(BackupDataResult backupData) {
|
||||
if (backupData == null || backupData.getData() == null || backupData.getData().length == 0) {
|
||||
return new byte[0];
|
||||
}
|
||||
byte[] data = backupData.getData();
|
||||
int length = backupData.getLength();
|
||||
int safeLength = length > 0 && length <= data.length ? length : data.length;
|
||||
return Arrays.copyOf(data, safeLength);
|
||||
}
|
||||
|
||||
private static void addZipEntry(ZipOutputStream zipOutputStream, String entryName, byte[] content) throws IOException {
|
||||
zipOutputStream.putNextEntry(new ZipEntry(entryName));
|
||||
zipOutputStream.write(content);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
private static String sm3Hex(byte[] data) {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
digest.update(data, 0, data.length);
|
||||
byte[] out = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(out, 0);
|
||||
StringBuilder builder = new StringBuilder(out.length * 2);
|
||||
for (byte item : out) {
|
||||
builder.append(String.format("%02x", item));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.cisd.tms.modules.backup.packagex;
|
||||
|
||||
import com.cisd.tms.integration.crypto.pcie.model.RecoverUserKeyRequest;
|
||||
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ResourceUserKeyRestoreService {
|
||||
|
||||
private final PcieCryptoService pcieCryptoService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ResourceUserKeyRestoreService(PcieCryptoService pcieCryptoService, ObjectMapper objectMapper) {
|
||||
this.pcieCryptoService = pcieCryptoService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void recover(Path payloadDir) {
|
||||
Path normalizedPayloadDir = payloadDir.normalize();
|
||||
Path manifestPath = normalizedPayloadDir.resolve("payload-manifest.json").normalize();
|
||||
if (!Files.isRegularFile(manifestPath)) {
|
||||
return;
|
||||
}
|
||||
// 用户密钥恢复只相信 payload-manifest.json 中声明的 USER_KEY 条目。
|
||||
// 这样即使 payload 目录里被混入额外 key 文件,也不会被恢复流程消费。
|
||||
Map<String, Object> manifest = readMap(manifestPath);
|
||||
Object resourcesValue = manifest.get("resources");
|
||||
if (!(resourcesValue instanceof List<?> resources)) {
|
||||
return;
|
||||
}
|
||||
for (Object resourceObject : resources) {
|
||||
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> resource = castMap(rawResource);
|
||||
if (!"USER_KEY".equalsIgnoreCase(kindOf(resource))) {
|
||||
continue;
|
||||
}
|
||||
recoverKeys(normalizedPayloadDir, resource);
|
||||
}
|
||||
}
|
||||
|
||||
private void recoverKeys(Path payloadDir, Map<String, Object> resource) {
|
||||
Object keysValue = resource.get("keys");
|
||||
if (!(keysValue instanceof List<?> keys)) {
|
||||
return;
|
||||
}
|
||||
for (Object keyObject : keys) {
|
||||
if (!(keyObject instanceof Map<?, ?> rawKeyEntry)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> keyEntry = castMap(rawKeyEntry);
|
||||
String entryPath = firstText(keyEntry, "entryPath");
|
||||
if (entryPath.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// entryPath 必须停留在 payload 目录内,避免恶意 manifest 引导读取任意本地文件。
|
||||
Path keyFile = payloadDir.resolve(entryPath).normalize();
|
||||
if (!keyFile.startsWith(payloadDir)) {
|
||||
throw new IllegalStateException("用户密钥备份条目越过payload目录:" + entryPath);
|
||||
}
|
||||
Map<String, Object> keyPayload = readMap(keyFile);
|
||||
// recoverUserKey 需要的三个核心参数来自备份文件:
|
||||
// keyIdx 保持旧机器的索引,keyType 当前为 SIGN(0),encryptedKey 是 backupUserKey 返回的 blob。
|
||||
RecoverUserKeyRequest request = new RecoverUserKeyRequest();
|
||||
request.setKeyIndex(intValue(keyPayload.get("keyIdx"), intValue(keyEntry.get("keyIdx"), -1)));
|
||||
request.setKeyType(intValue(keyPayload.get("keyType"), intValue(keyEntry.get("keyType"), -1)));
|
||||
request.setEncryptedKey(decodeEncryptedKey(keyPayload));
|
||||
// storeFlag=1 表示掉电保存,符合跨机器恢复后长期使用的场景。
|
||||
request.setStoreFlag(1);
|
||||
pcieCryptoService.recoverUserKey(request);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> readMap(Path path) {
|
||||
try {
|
||||
return objectMapper.readValue(path.toFile(), new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("读取用户密钥备份文件失败:" + path, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeEncryptedKey(Map<String, Object> keyPayload) {
|
||||
String encryptedKeyBase64 = firstText(keyPayload, "encryptedKeyBase64");
|
||||
if (encryptedKeyBase64.isEmpty()) {
|
||||
throw new IllegalStateException("用户密钥备份内容为空");
|
||||
}
|
||||
return Base64.getDecoder().decode(encryptedKeyBase64);
|
||||
}
|
||||
|
||||
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String kindOf(Map<String, Object> resource) {
|
||||
String kind = firstText(resource, "kind");
|
||||
if (!kind.isEmpty()) {
|
||||
return kind;
|
||||
}
|
||||
return firstText(resource, "resourceType");
|
||||
}
|
||||
|
||||
private static String firstText(Map<String, Object> map, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = map.get(key);
|
||||
if (value != null) {
|
||||
String text = String.valueOf(value).trim();
|
||||
if (!text.isEmpty() && !"null".equalsIgnoreCase(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static int intValue(Object value, int defaultValue) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value != null) {
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
+6
@@ -36,8 +36,12 @@ public class LightweightRestoreApplierLauncher {
|
||||
|
||||
public void launch(Path workDir) {
|
||||
Path normalizedWorkDir = workDir.toAbsolutePath().normalize();
|
||||
// shell 脚本需要数据库连接、恢复脚本路径、健康检查地址等运行时参数。
|
||||
// 这些信息来自当前 TMS 配置,写入任务目录下的 restore-env.sh,避免在命令行暴露复杂参数。
|
||||
writeRestoreEnv(normalizedWorkDir);
|
||||
Path logFile = normalizedWorkDir.resolve("restore-launch.log");
|
||||
// Java 阶段到这里已经完成包验签、解密、payload 展开和用户密钥恢复。
|
||||
// 后续进入停机恢复脚本,当前 TMS 服务可能被脚本停止。
|
||||
List<String> command = List.of(
|
||||
"/bin/bash",
|
||||
properties.getRestoreApplyScriptPath(),
|
||||
@@ -55,6 +59,8 @@ public class LightweightRestoreApplierLauncher {
|
||||
Files.createDirectories(workDir);
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
MysqlTarget target = parseMysqlTarget(datasourceUrl);
|
||||
// restore-env.sh 是 apply-resource-backup.sh 的唯一环境输入。
|
||||
// 脚本读取这些变量后执行停服务、导库、MQ replay、健康检查等现场动作。
|
||||
values.put("TMS_SCRIPT_PATH", trim(properties.getTmsScriptPath()));
|
||||
values.put("DB_RESTORE_SCRIPT_PATH", trim(properties.getRestoreDbScriptPath()));
|
||||
values.put("MQ_REPLAY_SCRIPT_PATH", trim(properties.getMqReplayScriptPath()));
|
||||
|
||||
@@ -58,11 +58,15 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
|
||||
|
||||
@Override
|
||||
public CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request) {
|
||||
// 备份会读取配置文件、导出数据库、调用密码卡备份用户密钥并生成本地包文件。
|
||||
// 这些动作不能和恢复、初始化、升级并发,否则容易出现“刚导出又被覆盖”的不一致。
|
||||
resourceTaskAdmissionGuard.assertCanStartBackup();
|
||||
// 资源备份必须基于“最近一次成功初始化”快照收集上下文,避免前端重新传一套可能失真的参数。
|
||||
InitTaskEntity initTask = initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")
|
||||
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "成功ful init task is missing"));
|
||||
|
||||
// taskId 用于后台任务表和工作目录;backupId 写入备份包 manifest,
|
||||
// 恢复创建时用户需要确认同一个 backupId,避免把预检包和恢复包搞混。
|
||||
String taskId = resourceTaskIdGenerator.nextBackupTaskId();
|
||||
String backupId = resourceTaskIdGenerator.nextBackupId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -79,10 +83,12 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
entity.setStartTime(now);
|
||||
// 先落 RUNNING,再开始真实采集。这样第二个备份/恢复请求能被互斥守卫拦住。
|
||||
resourceBackupTaskRepository.save(entity);
|
||||
|
||||
try {
|
||||
// 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。
|
||||
// createPackage 内部会完成:资源采集 -> payload.zip -> 数字信封加密 -> 外层 .tmsbak 写盘。
|
||||
ResourcePackageResult packageResult = resourcePackageService.createPackage(
|
||||
taskId,
|
||||
backupId,
|
||||
@@ -110,6 +116,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
|
||||
}
|
||||
entity.setFinishTime(LocalDateTime.now());
|
||||
entity.setUpdateTime(entity.getFinishTime());
|
||||
// 同一条任务记录从 RUNNING 更新为 SUCCESS/FAILED,列表和详情均以这里的终态为准。
|
||||
resourceBackupTaskRepository.update(entity);
|
||||
|
||||
CreateResourceBackupResponse response = new CreateResourceBackupResponse();
|
||||
@@ -152,6 +159,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
|
||||
public ResourceBackupDownloadResult downloadBackupPackage(String taskId) {
|
||||
ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId))
|
||||
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份任务不存在:" + trim(taskId)));
|
||||
// 下载接口只按任务表保存的最终路径取包,不重新打包;如果包被人工删除,直接报业务错误。
|
||||
Path packagePath = Path.of(trim(entity.getPackagePath())).normalize().toAbsolutePath();
|
||||
if (trim(entity.getPackagePath()).isEmpty() || !Files.exists(packagePath) || !Files.isRegularFile(packagePath)) {
|
||||
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份包文件不存在");
|
||||
|
||||
+5
@@ -34,6 +34,8 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
|
||||
|
||||
@Override
|
||||
public ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request) {
|
||||
// 预检输入是上传模块返回的 fileId。这里先通过文件表解析实际存储路径,
|
||||
// 后续所有恢复创建都必须复用本次预检保存的 fileId,避免前端替换包文件。
|
||||
FileRecordEntity fileRecord = fileRecordRepository.findByFileId(trim(request.getFileId()))
|
||||
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "备份包文件不存在:" + trim(request.getFileId())));
|
||||
// 预检阶段只解析包头、验签和比对指纹,不提前解密完整 payload。
|
||||
@@ -43,6 +45,8 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
|
||||
if (!expectedStorageFingerprint().equals(storageFingerprint) || !expectedAuthenticityFingerprint().equals(authenticityFingerprint)) {
|
||||
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份包密钥指纹不匹配");
|
||||
}
|
||||
// precheckId 是“用户看过并确认的包头结果”的短期凭据。
|
||||
// 创建恢复任务时会校验 precheckId、backupId、兼容性和过期时间,避免跳过预检直接恢复。
|
||||
ResourceRestorePrecheckResponse response = new ResourceRestorePrecheckResponse();
|
||||
response.setPrecheckId(resourceTaskIdGenerator.nextPrecheckId());
|
||||
response.setBackupId(stringValue(inspectResult.getManifest().get("backupId")));
|
||||
@@ -57,6 +61,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
|
||||
response.setPayloadDecrypted(false);
|
||||
response.setWarnings(List.of());
|
||||
response.setBlockingIssues(List.of());
|
||||
// 第一版恢复计划固定为两段:Java 负责验签/解密/展开/用户密钥恢复,shell 负责停服后的落盘恢复。
|
||||
response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY"));
|
||||
response.setRestoreScopeSummary(buildScopeSummary(inspectResult));
|
||||
response.setExpiresAt(LocalDateTime.now().plusMinutes(30).toString());
|
||||
|
||||
+14
-1
@@ -7,6 +7,8 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
|
||||
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
|
||||
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
|
||||
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
|
||||
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractionResult;
|
||||
import com.cisd.tms.modules.backup.packagex.ResourceUserKeyRestoreService;
|
||||
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
|
||||
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
|
||||
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
|
||||
@@ -35,12 +37,15 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
|
||||
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
|
||||
private final ResourceBackupProperties resourceBackupProperties;
|
||||
private final ResourcePayloadExtractor resourcePayloadExtractor;
|
||||
private final ResourceUserKeyRestoreService resourceUserKeyRestoreService;
|
||||
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
|
||||
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
|
||||
|
||||
|
||||
@Override
|
||||
public CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request) {
|
||||
// 恢复会导入用户密钥、覆盖配置文件、导入数据库并停启服务,
|
||||
// 必须在入口处和其它高风险任务互斥。
|
||||
resourceTaskAdmissionGuard.assertCanStartRestore();
|
||||
if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) {
|
||||
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险");
|
||||
@@ -64,7 +69,11 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
|
||||
createDirectories(taskDir);
|
||||
resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
|
||||
// Java 阶段完成包的完整验签、解密和 payload 展开,并生成 shell 可消费的 TSV 计划文件。
|
||||
ResourcePayloadExtractionResult extractionResult = resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
|
||||
// 用户密钥恢复必须放在 shell 停服/导库之前:数据库和证书数据恢复后会引用这些 keyIdx。
|
||||
// 如果这里失败,不保存 APPLYING 任务、不启动 shell,避免出现“数据已覆盖但密钥不可用”的半恢复状态。
|
||||
resourceUserKeyRestoreService.recover(extractionResult.getPayloadDir());
|
||||
|
||||
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
|
||||
entity.setTaskId(taskId);
|
||||
@@ -85,7 +94,10 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
|
||||
entity.setCreateBy("SYSTEM");
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
// 只有 Java 解包和用户密钥恢复都成功后,任务才进入 APPLYING。
|
||||
// 后续状态由 shell 写 status.json,查询接口会读取该状态文件展示实时恢复进度。
|
||||
resourceRestoreTaskRepository.save(entity);
|
||||
// shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。
|
||||
lightweightRestoreApplierLauncher.launch(taskDir);
|
||||
|
||||
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
|
||||
@@ -111,6 +123,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
|
||||
if (precheck == null) {
|
||||
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在");
|
||||
}
|
||||
// 兼容性和过期时间在创建恢复时再次校验,防止用户拿旧的或不匹配的预检结果继续恢复。
|
||||
if (!Boolean.TRUE.equals(precheck.getCompatible())) {
|
||||
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck is not compatible");
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ public class ResourceTaskAdmissionGuard {
|
||||
}
|
||||
|
||||
public synchronized void assertCanStartBackup() {
|
||||
// 同一个 JVM 内先用 synchronized 收窄并发窗口,再查询任务表/状态文件做跨请求互斥。
|
||||
// 第一版不引入分布式锁,目标是防止单机部署下最危险的并发恢复/备份。
|
||||
assertNoActiveBackup();
|
||||
assertNoActiveRestore();
|
||||
assertNoRunningInitOrReset();
|
||||
@@ -67,6 +69,8 @@ public class ResourceTaskAdmissionGuard {
|
||||
if (!ACTIVE_RESTORE_STATUSES.contains(trim(task.getStatus()))) {
|
||||
continue;
|
||||
}
|
||||
// 恢复任务的最终结果由 shell 写 status.json;数据库中的 APPLYING 可能不会被 Java 回写。
|
||||
// 因此这里读取状态文件,如果 shell 已明确 SUCCESS/FAILED,就不再把该任务视为活动任务。
|
||||
RestoreStatusFileReader.RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(task));
|
||||
if ("SUCCESS".equalsIgnoreCase(trim(snapshot.status())) || "FAILED".equalsIgnoreCase(trim(snapshot.status()))) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user