From 03d8ccce788c5f2b3e857e76151f2129e007bf10 Mon Sep 17 00:00:00 2001 From: waner Date: Thu, 7 May 2026 10:46:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A4=87=E4=BB=BD=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=A8=E6=88=B7=E5=AF=86=E9=92=A5=E7=9A=84?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- .../resource-restore/apply-resource-backup.sh | 12 ++ .../controller/ResourceBackupController.java | 10 +- .../controller/ResourceRestoreController.java | 10 +- .../ResourceDiscoveryServiceImpl.java | 16 ++ .../packagex/ResourcePackageServiceImpl.java | 83 ++++++++++- .../packagex/ResourcePayloadExtractor.java | 10 ++ .../ResourceUserKeyBackupCollector.java | 139 +++++++++++++++++ .../ResourceUserKeyRestoreService.java | 141 ++++++++++++++++++ .../LightweightRestoreApplierLauncher.java | 6 + .../impl/ResourceBackupServiceImpl.java | 8 + .../impl/ResourcePrecheckServiceImpl.java | 5 + .../impl/ResourceRestoreServiceImpl.java | 15 +- .../impl/ResourceTaskAdmissionGuard.java | 4 + .../ResourceDiscoveryServiceTest.java | 3 + .../packagex/ResourcePackageServiceTest.java | 73 +++++++++ .../ResourceUserKeyRestoreServiceTest.java | 58 +++++++ .../ResourceRestoreHandoffServiceTest.java | 18 +++ 18 files changed, 599 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyBackupCollector.java create mode 100644 src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreService.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreServiceTest.java diff --git a/README.md b/README.md index e25b379..d7cafea 100644 --- a/README.md +++ b/README.md @@ -271,9 +271,10 @@ Open: - `CMEP_DB` - `CP_CONFIG` - `RECEIVER_LICENSE` + - `USER_KEY`(从实体证书 `KeyEntity.keyIdx` 采集用户密钥备份) - `MQ_REPLAY` - 预检阶段只做包头解析、指纹匹配和验签,不提前解密完整 payload -- 恢复任务创建时 Java 完成验签、PCIe 解密、payload 展开和恢复路径白名单校验 +- 恢复任务创建时 Java 完成验签、PCIe 解密、payload 展开、用户密钥恢复和恢复路径白名单校验 - Java 会生成 shell 友好的 `restore-files.tsv`、`restore-databases.tsv`、`restore-mq.tsv` - 创建恢复任务后启动 `apply-resource-backup.sh --work-dir `,由 shell 执行停服务、文件覆盖、SQL 导入、MQ replay、起服务和健康检查 - shell 侧状态写入 `status.json`,日志写入 `restore.log` diff --git a/scripts/resource-restore/apply-resource-backup.sh b/scripts/resource-restore/apply-resource-backup.sh index 3a11adc..9e27d72 100755 --- a/scripts/resource-restore/apply-resource-backup.sh +++ b/scripts/resource-restore/apply-resource-backup.sh @@ -24,6 +24,8 @@ PAYLOAD_DIR="${WORK_DIR}/payload" STATUS_FILE="${WORK_DIR}/status.json" LOG_FILE="${WORK_DIR}/restore.log" MANIFEST="${PAYLOAD_DIR}/payload-manifest.json" +# Java 解包阶段会把 payload-manifest.json 转换成下面三份 TSV。 +# shell 阶段只消费 TSV,避免在 bash 中解析复杂 JSON。 FILES_PLAN="${WORK_DIR}/restore-files.tsv" DATABASES_PLAN="${WORK_DIR}/restore-databases.tsv" MQ_PLAN="${WORK_DIR}/restore-mq.tsv" @@ -41,6 +43,8 @@ write_status() { local status="$1" local phase="$2" local message="$3" + # status.json 是 Java 查询接口和运维排障共同读取的轻量状态文件。 + # 即使 TMS 服务被 stop,脚本仍会持续刷新这个文件,服务重启后可继续展示结果。 printf '{"status":"%s","phase":"%s","message":"%s","updatedAt":"%s"}\n' \ "$(json_escape "${status}")" \ "$(json_escape "${phase}")" \ @@ -70,6 +74,8 @@ require_env() { restore_files() { [[ -f "${FILES_PLAN}" ]] || return 0 + # restore-files.tsv 每行格式:payload内相对路径目标绝对路径。 + # 目标路径白名单已在 Java 解包阶段校验,这里只负责按计划覆盖文件。 while IFS=$'\t' read -r entry_path restore_path || [[ -n "${entry_path:-}" ]]; do [[ -n "${entry_path:-}" ]] || continue [[ -n "${restore_path:-}" ]] || fail "restore file target is empty for ${entry_path}" "FILE" @@ -86,6 +92,8 @@ restore_databases() { [[ -s "${DATABASES_PLAN}" ]] || return 0 local script="${DB_RESTORE_SCRIPT_PATH:-}" [[ -n "${script}" && -x "${script}" ]] || fail "DB_RESTORE_SCRIPT_PATH is not executable" "DATABASE" + # 数据库连接参数来自 Java 生成的 restore-env.sh;实际导入命令下沉到 restore-db.sh, + # 方便现场根据 MySQL/TiDB 客户端和账号策略调整实现。 require_env "DB_HOST" "DATABASE" require_env "DB_PORT" "DATABASE" require_env "DB_USER" "DATABASE" @@ -113,6 +121,7 @@ replay_mq() { [[ -s "${MQ_PLAN}" ]] || return 0 local script="${MQ_REPLAY_SCRIPT_PATH:-}" [[ -n "${script}" && -x "${script}" ]] || fail "MQ_REPLAY_SCRIPT_PATH is not executable" "MQ_REPLAY" + # MQ replay 当前只接收 Java 生成的上下文文件,具体恢复 RabbitMQ/TLQ/CFMQ 的命令由现场脚本实现。 while IFS= read -r entry_path || [[ -n "${entry_path:-}" ]]; do [[ -n "${entry_path:-}" ]] || continue local context_file="${PAYLOAD_DIR}/${entry_path}" @@ -144,11 +153,14 @@ chmod 700 "${WORK_DIR}" || true if [[ -f "${ENV_FILE}" ]]; then # shellcheck disable=SC1090 + # restore-env.sh 由 LightweightRestoreApplierLauncher 生成,包含脚本路径、数据库连接和健康检查配置。 source "${ENV_FILE}" fi [[ -f "${MANIFEST}" ]] || fail "payload manifest not found" "PRECHECK" +# 从这里开始进入停机恢复窗口。TMS_SCRIPT_PATH stop/start 允许失败后继续, +# 因为某些部署环境可能由 systemd/外部平台接管服务状态,但文件/数据库/MQ 失败必须中断。 write_status "RUNNING" "SERVICE_STOP" "stopping services" log "SERVICE_STOP" if [[ -n "${TMS_SCRIPT_PATH:-}" && -x "${TMS_SCRIPT_PATH}" ]]; then diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java index bad2774..8e1cc94 100644 --- a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java @@ -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 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 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 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> 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 taskStepLog( @Parameter(description = "备份任务号") diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java index 9cf7dc5..1896e53 100644 --- a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java @@ -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 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 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 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> 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 taskStepLog( @Parameter(description = "恢复任务号") diff --git a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java index 1718591..c79d15c 100644 --- a/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceImpl.java @@ -27,15 +27,23 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { throw new IllegalArgumentException("productType和mqType不能为空"); } + // 资源发现只决定“应该备份什么”,不在这里触碰文件系统、数据库或密码卡。 + // 真实采集放在 ResourcePackageServiceImpl 中完成,便于统一处理必选资源缺失和包内 manifest。 List 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(); } diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java index 8330794..ed59b40 100644 --- a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceImpl.java @@ -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 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 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 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 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 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)) { diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractor.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractor.java index 07a0369..468569c 100644 --- a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractor.java +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractor.java @@ -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 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; diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyBackupCollector.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyBackupCollector.java new file mode 100644 index 0000000..16b46e8 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyBackupCollector.java @@ -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 resourceEntry) throws IOException { + // USER_KEY 资源的来源不是文件路径,而是实体证书表 t_key_entity 中已经分配过的 keyIdx。 + // 每个 keyIdx 对应密码卡内的一把用户签名密钥,备份包要保证这些索引在新机器恢复后仍可用。 + resourceEntry.put("kind", "USER_KEY"); + resourceEntry.put("keyType", USER_KEY_TYPE); + List 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> 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 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 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 keyIndexes() { + if (keyEntityRepository == null) { + return List.of(); + } + List 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(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreService.java b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreService.java new file mode 100644 index 0000000..ed0408b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreService.java @@ -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 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 resource = castMap(rawResource); + if (!"USER_KEY".equalsIgnoreCase(kindOf(resource))) { + continue; + } + recoverKeys(normalizedPayloadDir, resource); + } + } + + private void recoverKeys(Path payloadDir, Map resource) { + Object keysValue = resource.get("keys"); + if (!(keysValue instanceof List keys)) { + return; + } + for (Object keyObject : keys) { + if (!(keyObject instanceof Map rawKeyEntry)) { + continue; + } + Map 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 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 readMap(Path path) { + try { + return objectMapper.readValue(path.toFile(), new TypeReference>() { + }); + } catch (IOException ex) { + throw new IllegalStateException("读取用户密钥备份文件失败:" + path, ex); + } + } + + private static byte[] decodeEncryptedKey(Map keyPayload) { + String encryptedKeyBase64 = firstText(keyPayload, "encryptedKeyBase64"); + if (encryptedKeyBase64.isEmpty()) { + throw new IllegalStateException("用户密钥备份内容为空"); + } + return Base64.getDecoder().decode(encryptedKeyBase64); + } + + private static Map castMap(Map rawMap) { + Map result = new LinkedHashMap<>(); + rawMap.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + + private static String kindOf(Map resource) { + String kind = firstText(resource, "kind"); + if (!kind.isEmpty()) { + return kind; + } + return firstText(resource, "resourceType"); + } + + private static String firstText(Map 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; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncher.java b/src/main/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncher.java index b911d24..df953f9 100644 --- a/src/main/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncher.java +++ b/src/main/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncher.java @@ -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 command = List.of( "/bin/bash", properties.getRestoreApplyScriptPath(), @@ -55,6 +59,8 @@ public class LightweightRestoreApplierLauncher { Files.createDirectories(workDir); Map 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())); diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java index a611970..48f6fa9 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java @@ -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(), "资源备份包文件不存在"); diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java index 09fad98..e58380b 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourcePrecheckServiceImpl.java @@ -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()); diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java index 11f83a2..77d1707 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java @@ -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"); } diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java index 0dc2816..05d76ea 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java @@ -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; diff --git a/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java index 511e2f7..d9c9902 100644 --- a/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/discovery/ResourceDiscoveryServiceTest.java @@ -20,6 +20,7 @@ class ResourceDiscoveryServiceTest { Assertions.assertTrue(containsCode(items, "CMEP_DB")); Assertions.assertTrue(containsCode(items, "CP_CONFIG")); Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE")); + Assertions.assertTrue(containsCode(items, "USER_KEY")); Assertions.assertTrue(containsCode(items, "MQ_REPLAY")); Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS")); Assertions.assertFalse(containsCode(items, "TLQ_LICENSE")); @@ -27,6 +28,7 @@ class ResourceDiscoveryServiceTest { Assertions.assertTrue(items.stream().allMatch(item -> "FILE".equals(item.getResourceType()) || "DATABASE".equals(item.getResourceType()) + || "USER_KEY".equals(item.getResourceType()) || "MQ_REPLAY".equals(item.getResourceType()))); Assertions.assertFalse(containsCode(items, "NGINX_CONFIG")); Assertions.assertFalse(containsCode(items, "WEB_STATIC")); @@ -45,6 +47,7 @@ class ResourceDiscoveryServiceTest { Assertions.assertFalse(containsCode(items, "TLQ_LICENSE")); Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS")); Assertions.assertTrue(hasRequired(items, "TMS_DB")); + Assertions.assertTrue(hasRequired(items, "USER_KEY")); Assertions.assertTrue(hasRequired(items, "MQ_REPLAY")); Assertions.assertFalse(containsCode(items, "CMEP_DB")); } diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java index 362f63e..3fb35e6 100644 --- a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePackageServiceTest.java @@ -2,12 +2,15 @@ package com.cisd.tms.modules.backup.packagex; import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl; +import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType; import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult; import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest; import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest; import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; +import com.cisd.tms.modules.cert.entity.KeyEntity; +import com.cisd.tms.modules.cert.repository.KeyEntityRepository; import com.cisd.tms.modules.init.entity.InitTaskEntity; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; @@ -157,6 +160,64 @@ class ResourcePackageServiceTest { Assertions.assertTrue(exception.getMessage().contains("PATH_NOT_FOUND")); } + @Test + void shouldCollectUserKeysFromEntityCertificateKeyIndexes() throws Exception { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setOutputDir(tempDir.toString()); + PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); + Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class))) + .thenAnswer(invocation -> backup(invocation.getArgument(0).getData())); + Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any())) + .thenReturn(backup(new byte[]{0x11, 0x22, 0x33})); + Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) + .thenAnswer(invocation -> backup(invocation.getArgument(0).getCipherBlob())); + Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any()); + Mockito.when(pcieCryptoService.backupUserKey(2, PcieUserKeyType.SIGN.getCode(), 4096)) + .thenReturn(backup(new byte[]{0x02, 0x20})); + Mockito.when(pcieCryptoService.backupUserKey(3, PcieUserKeyType.SIGN.getCode(), 4096)) + .thenReturn(backup(new byte[]{0x03, 0x30})); + KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class); + Mockito.when(keyEntityRepository.findAll()).thenReturn(List.of(keyEntity(3), keyEntity(2), keyEntity(2))); + ResourcePackageService service = new ResourcePackageServiceImpl( + properties, + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + mockDatabaseBackupCollector(), + new ResourceUserKeyBackupCollector(keyEntityRepository, pcieCryptoService, new ObjectMapper()), + new MqReplayContextBuilder(new ObjectMapper()), + new ObjectMapper() + ); + + ResourcePackageResult result = service.createPackage( + "RBKP-TASK-20260413-000001", + "RBKP-20260413-153000-000001", + sampleInitTask(), + List.of(userKeyResource()), + sampleRequest() + ); + + ResourcePackageInspectResult inspectResult = service.inspectPackage(Path.of(result.getPackagePath())); + @SuppressWarnings("unchecked") + java.util.List> resources = (java.util.List>) inspectResult.getPayload().get("resources"); + Assertions.assertEquals(1, resources.size()); + Assertions.assertEquals("USER_KEY", resources.get(0).get("kind")); + Assertions.assertEquals(2, resources.get(0).get("keyCount")); + + try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) { + java.util.Map entries = new java.util.LinkedHashMap<>(); + java.util.zip.ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + entries.put(entry.getName(), new String(zipInputStream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } + Assertions.assertTrue(entries.containsKey("keys/user-key-2.json")); + Assertions.assertTrue(entries.get("keys/user-key-2.json").contains("\"keyIdx\":2")); + Assertions.assertTrue(entries.containsKey("keys/user-key-3.json")); + Assertions.assertTrue(entries.get("keys/user-key-3.json").contains("\"keyIdx\":3")); + } + + Mockito.verify(pcieCryptoService).backupUserKey(2, PcieUserKeyType.SIGN.getCode(), 4096); + Mockito.verify(pcieCryptoService).backupUserKey(3, PcieUserKeyType.SIGN.getCode(), 4096); + } + private static BackupDataResult backup(byte[] data) { BackupDataResult result = new BackupDataResult(); result.setData(data); @@ -212,6 +273,18 @@ class ResourcePackageServiceTest { return item; } + private static DiscoveredResourceItem userKeyResource() { + DiscoveredResourceItem item = new DiscoveredResourceItem(); + item.setResourceType("USER_KEY"); + item.setResourceCode("USER_KEY"); + item.setRequired(true); + return item; + } + + private static KeyEntity keyEntity(int keyIdx) { + return new KeyEntity().setKeyIdx(keyIdx); + } + private static DatabaseBackupCollector mockDatabaseBackupCollector() { DatabaseBackupCollector collector = Mockito.mock(DatabaseBackupCollector.class); DatabaseBackupCollector.CollectedDatabaseDump tmsDump = new DatabaseBackupCollector.CollectedDatabaseDump(); diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreServiceTest.java new file mode 100644 index 0000000..50ab69f --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourceUserKeyRestoreServiceTest.java @@ -0,0 +1,58 @@ +package com.cisd.tms.modules.backup.packagex; + +import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType; +import com.cisd.tms.integration.crypto.pcie.model.RecoverUserKeyRequest; +import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class ResourceUserKeyRestoreServiceTest { + + @TempDir + Path tempDir; + + @Test + void shouldRecoverUserKeysDeclaredInPayloadManifest() throws Exception { + Path payloadDir = tempDir.resolve("payload"); + Files.createDirectories(payloadDir.resolve("keys")); + byte[] key2 = new byte[]{0x02, 0x20}; + byte[] key3 = new byte[]{0x03, 0x30}; + Files.writeString(payloadDir.resolve("keys/user-key-2.json"), userKeyJson(2, key2), StandardCharsets.UTF_8); + Files.writeString(payloadDir.resolve("keys/user-key-3.json"), userKeyJson(3, key3), StandardCharsets.UTF_8); + Files.writeString(payloadDir.resolve("payload-manifest.json"), """ + {"resources":[ + {"kind":"USER_KEY","keys":[ + {"keyIdx":2,"keyType":0,"entryPath":"keys/user-key-2.json"}, + {"keyIdx":3,"keyType":0,"entryPath":"keys/user-key-3.json"} + ]} + ]} + """, StandardCharsets.UTF_8); + PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); + ResourceUserKeyRestoreService service = new ResourceUserKeyRestoreService(pcieCryptoService, new ObjectMapper()); + + service.recover(payloadDir); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RecoverUserKeyRequest.class); + Mockito.verify(pcieCryptoService, Mockito.times(2)).recoverUserKey(captor.capture()); + Assertions.assertEquals(2, captor.getAllValues().get(0).getKeyIndex()); + Assertions.assertEquals(PcieUserKeyType.SIGN.getCode(), captor.getAllValues().get(0).getKeyType()); + Assertions.assertArrayEquals(key2, captor.getAllValues().get(0).getEncryptedKey()); + Assertions.assertEquals(1, captor.getAllValues().get(0).getStoreFlag()); + Assertions.assertEquals(3, captor.getAllValues().get(1).getKeyIndex()); + Assertions.assertArrayEquals(key3, captor.getAllValues().get(1).getEncryptedKey()); + } + + private static String userKeyJson(int keyIdx, byte[] backupData) { + return """ + {"keyIdx":%d,"keyType":0,"encryptedKeyBase64":"%s"} + """.formatted(keyIdx, Base64.getEncoder().encodeToString(backupData)); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java index 873b647..1bc253c 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java @@ -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.impl.ResourceRestoreServiceImpl; @@ -40,6 +42,7 @@ class ResourceRestoreHandoffServiceTest { ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); + ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak"); @@ -62,6 +65,15 @@ class ResourceRestoreHandoffServiceTest { fileRecordEntity.setStoragePath(packagePath.toString()); Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001")) .thenReturn(java.util.Optional.of(fileRecordEntity)); + ResourcePayloadExtractionResult extractionResult = new ResourcePayloadExtractionResult(); + extractionResult.setPayloadDir(tempDir.resolve("resource-restore").resolve("placeholder").resolve("payload")); + Mockito.when(payloadExtractor.extract(Mockito.eq(packagePath), Mockito.any(Path.class))) + .thenAnswer(invocation -> { + Path taskDir = invocation.getArgument(1); + ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult(); + result.setPayloadDir(taskDir.resolve("payload")); + return result; + }); ResourceRestoreService service = new ResourceRestoreServiceImpl( repository, @@ -70,6 +82,7 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, + userKeyRestoreService, applierLauncher, admissionGuard ); @@ -93,6 +106,7 @@ class ResourceRestoreHandoffServiceTest { Assertions.assertEquals("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]", saved.getRestorePlanJson()); Mockito.verify(payloadExtractor).extract(packagePath, taskDir); + Mockito.verify(userKeyRestoreService).recover(taskDir.resolve("payload")); Mockito.verify(applierLauncher).launch(taskDir); } @@ -105,6 +119,7 @@ class ResourceRestoreHandoffServiceTest { ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); + ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse(); @@ -117,6 +132,7 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, + userKeyRestoreService, applierLauncher, admissionGuard ); @@ -137,6 +153,7 @@ class ResourceRestoreHandoffServiceTest { ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); + ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse(); @@ -149,6 +166,7 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, + userKeyRestoreService, applierLauncher, admissionGuard );