diff --git a/README.md b/README.md index d7cafea..4c4ed40 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,10 @@ Open: - `GET /api/v1/resource-backups/{taskId}/download` - `POST /api/v1/resource-restores/precheck` - `POST /api/v1/resource-restores` -- `GET /api/v1/resource-restores/{taskId}` -- `GET /api/v1/resource-restores/{taskId}/steps` -- `GET /api/v1/resource-restores/{taskId}/steps/{stepNo}/log` +- `POST /api/v1/resource-restores/{taskId}/apply` +- `GET /api/v1/resource-restores/latest` + +恢复任务创建接口只完成验签、解密、展开和计划生成;前端只做一次“确认恢复”,确认后先调用创建接口,返回 `READY_TO_APPLY` 后立即调用 apply 接口进入停机恢复。TMS 重启后前端可调用 latest 接口展示最近一次恢复时间、结果和失败原因。 前后端字段、状态枚举和联调流程见 `docs/openapi/resource-backup-restore-frontend-integration.md`。 @@ -269,20 +270,33 @@ Open: - `TMS_CONFIG` - `TMS_DB` - `CMEP_DB` + - `ORG_DB`(标准/间参收发器以 `orgCode` 命名的机构业务库;`DIRECT` 模式不包含) - `CP_CONFIG` - `RECEIVER_LICENSE` + - `STANDARD_CMSP_APP_CONFIG`(标准版 CMSP 应用配置,由默认 `tms.backup.file-resources` 配置匹配 `/home/cmep4i/cmsp/application-prd*.properties`) + - `STANDARD_CMTP_APP_CONFIG`(标准版 CMTP 应用配置,由默认 `tms.backup.file-resources` 配置匹配 `/home/cmep4i/cmtp/application-prd*.properties`) - `USER_KEY`(从实体证书 `KeyEntity.keyIdx` 采集用户密钥备份) - `MQ_REPLAY` - 预检阶段只做包头解析、指纹匹配和验签,不提前解密完整 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、起服务和健康检查 +- Java 启动恢复脚本前会在工作目录生成 `restore-env.sh`、`restore-stop-commands.tsv`、`restore-start-commands.tsv`;停启服务命令来自 `tms.backup.restore-stop-commands` / `tms.backup.restore-start-commands` +- 标准/间参默认恢复后会启动 TMS、CMSP/CMTP 和 nginx;现场需要启动其它软件时,只需在上述命令列表里增删配置项 - shell 侧状态写入 `status.json`,日志写入 `restore.log` - 第一版资源恢复是停机恢复。创建恢复任务后当前TMS服务可能中断,前端应提示用户恢复期间页面/API可能不可用,恢复结果以后续健康检查、重新登录和 `status.json` / `restore.log` 为准。 - `FILE` 回写 manifest 明确声明的配置/License 文件 +- 可通过 `tms.backup.file-resources` 自行增删直接复制恢复的文件类资源: + - `mode=FILE`:单文件,`restore-path` 是目标文件路径 + - `mode=DIR`:目录递归,`restore-path` 是目标目录 + - `mode=GLOB`:通配符匹配同一目录下的一批文件,`restore-path` 是目标目录 + - `product-types` / `mq-types`:可选过滤条件;不配置表示所有产品类型或 MQ 类型都适用 + - `required=true` 时匹配不到或读取失败会导致备份失败,`required=false` 时跳过并写入 manifest - `DATABASE` 恢复: - `db/TMS.sql` - `db/CMEP.sql`(包内存在时) + - `db/.sql`(标准/间参收发器包内存在时,导回同名机构库) +- `TMS_DB` 备份默认通过 `tms.backup.tms-db-excluded-tables` 排除角色、授权、会话和审计表,恢复时不会覆盖新机器安全状态 - `MQ_REPLAY` 通过 `replay-mq.sh` 消费 `mq/mq-restore-context.json`,也可用 `MQ_REPLAY_COMMAND` 委派给现场脚本 - Java 启动恢复脚本前会在工作目录生成 `restore-env.sh`,传递数据库连接、恢复脚本路径、MQ replay 脚本路径和健康检查配置 @@ -291,13 +305,17 @@ Open: - `tms.backup.precheck-store-dir` - `tms.backup.restore-task-root-dir` - `tms.backup.tms-script-path` +- `tms.backup.restore-stop-commands` +- `tms.backup.restore-start-commands` - `tms.backup.restore-apply-script-path` - `tms.backup.allowed-restore-roots` +- `tms.backup.file-resources` - `tms.backup.mysqldump-path` - `tms.backup.mysql-path` - `tms.backup.restore-db-script-path` - `tms.backup.mq-replay-script-path` - `tms.backup.tms-database-name` +- `tms.backup.tms-db-excluded-tables` - `tms.backup.cmep-database-name` - `tms.backup.health-check-url` - `tms.backup.health-check-timeout-seconds` diff --git a/config/application.yml.example b/config/application.yml.example index 5fde141..be13e19 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -50,7 +50,7 @@ tms: mode: LOCAL timeout-sec: 600 log-dir: /home/tms/tmp/tms-init-logs - standard-home-dir: /home/cemp4i + standard-home-dir: /home/cmep4i standard-db-username: root standard-db-password: change-me standard-db-apply-command: /home/tms/bin/apply_standard_db.sh @@ -77,3 +77,119 @@ tms: rabbit-vhost: /RQ standard-rabbit-credential-encrypt-enabled: true standard-rabbit-credential-secret-key: ABCDEFGHIJKLMN1234567890 + backup: + output-dir: /home/tms/tmp/resource-backup-packages + restore-task-root-dir: /home/tms/tmp/resource-restore + tms-script-path: /home/tms/scripts/tms.sh + # 资源恢复停服务命令,可按现场需要增删。 + restore-stop-commands: + - code: STANDARD_NGINX + command: /home/tms/bin/stop_standard_nginx.sh + required: false + product-types: + - ENTERPRISE + - INDIRECT + - code: STANDARD_APPS + command: /home/tms/bin/stop_standard_apps.sh + required: false + product-types: + - ENTERPRISE + - INDIRECT + - code: TMS + command: /home/tms/scripts/tms.sh stop + required: false + # 资源恢复启动服务命令。标准/间参默认启动 TMS、CMSP/CMTP 和 nginx。 + restore-start-commands: + - code: TMS + command: /home/tms/scripts/tms.sh start + required: true + - code: STANDARD_APPS + command: /home/tms/bin/start_standard_apps.sh + required: true + product-types: + - ENTERPRISE + - INDIRECT + - code: STANDARD_NGINX + command: /home/tms/bin/start_standard_nginx.sh + required: true + product-types: + - ENTERPRISE + - INDIRECT + # TMS 库备份排除表。默认跳过角色、授权、会话和审计表,避免恢复时覆盖新机器安全状态。 + tms-db-excluded-tables: + - tms_role_account + - tms_role_ukey_binding + - tms_auth_full_account + - tms_auth_session + - tms_auth_challenge + - tms_auth_audit_log + - tms_operation_audit_log + allowed-restore-roots: + - /home/tms/config + - /home/cmep4i + - /home/cmep4d + - /home/tms/tmp/resource-restore + # 直接复制恢复的文件类资源,可按现场需要增删改。 + # FILE 的 restore-path 是目标文件;DIR/GLOB 的 restore-path 是目标目录。 + file-resources: + - code: TMS_CONFIG + mode: DIR + path: /home/tms/config + restore-path: /home/tms/config + required: true + - code: CP_CONFIG + mode: FILE + path: /home/cmep4i/cpconfig.cfg + restore-path: /home/cmep4i/cpconfig.cfg + product-types: + - ENTERPRISE + - INDIRECT + required: true + - code: CP_CONFIG + mode: FILE + path: /opt/cpconfig.cfg + restore-path: /opt/cpconfig.cfg + product-types: + - DIRECT + required: true + - code: RECEIVER_LICENSE + mode: FILE + path: /home/cmep4i/cmep/license/cmep.license + restore-path: /home/cmep4i/cmep/license/cmep.license + product-types: + - ENTERPRISE + - INDIRECT + required: true + - code: RECEIVER_LICENSE + mode: FILE + path: /home/cmep4d/cmep/license/cmep.license + restore-path: /home/cmep4d/cmep/license/cmep.license + product-types: + - DIRECT + required: true + - code: STANDARD_CMSP_APP_CONFIG + mode: GLOB + path: /home/cmep4i/cmsp/application-prd*.properties + restore-path: /home/cmep4i/cmsp + product-types: + - ENTERPRISE + - INDIRECT + required: true + - code: STANDARD_CMTP_APP_CONFIG + mode: GLOB + path: /home/cmep4i/cmtp/application-prd*.properties + restore-path: /home/cmep4i/cmtp + product-types: + - ENTERPRISE + - INDIRECT + required: true + - code: CUSTOM_CERT + mode: FILE + path: /home/tms/config/custom-cert.pem + restore-path: /home/tms/config/custom-cert.pem + required: false + - code: CUSTOM_APP_CONFIG + mode: GLOB + path: /home/cmep4i/cmsp/custom-*.properties + restore-path: /home/cmep4i/cmsp + required: false diff --git a/docs/openapi/resource-backup-restore-frontend-integration.md b/docs/openapi/resource-backup-restore-frontend-integration.md new file mode 100644 index 0000000..d5b85c7 --- /dev/null +++ b/docs/openapi/resource-backup-restore-frontend-integration.md @@ -0,0 +1,843 @@ +# 资源备份恢复前端对接文档 + +## 1. 对接目标 + +本文说明当前 `tms-framework` 资源备份和资源恢复接口的前端调用方式、接口顺序、页面判断规则和错误处理建议。 + +当前前端只需要支持两个主流程: + +- 创建资源备份包并下载 `.tmsbak` +- 上传 `.tmsbak`,预检通过后执行停机恢复,并在 TMS 重启后展示最近恢复结果 + +恢复侧已经收敛为轻量流程,不再提供恢复任务详情、恢复步骤和恢复步骤日志接口。恢复完成后的页面展示只依赖 `GET /api/v1/resource-restores/latest`。 + +## 2. 通用约定 + +### 2.1 基础路径 + +| 模块 | 基础路径 | +| --- | --- | +| 文件上传 | `/api/v1/files` | +| 资源备份 | `/api/v1/resource-backups` | +| 资源恢复 | `/api/v1/resource-restores` | + +### 2.2 统一响应 + +除备份包下载接口外,其它接口返回 `ApiResponse`: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": {}, + "timestamp": 1777459200000, + "traceId": "optional-trace-id" +} +``` + +前端统一处理规则: + +- HTTP 非 2xx:请求失败,展示 HTTP 错误或后端 `msg` +- `success=false`:业务失败,展示 `msg` +- `success=true`:请求成功,继续按 `data` 中的业务字段判断流程 + +### 2.3 权限和防重放 + +资源备份/恢复接口要求内部登录态,角色为 `OPS_ADMIN` 或 `SUPER_ADMIN`,认证等级为 `FULL`。 + +需要防重放头的接口: + +- `POST /api/v1/resource-backups` +- `POST /api/v1/resource-restores` +- `POST /api/v1/resource-restores/{taskId}/apply` + +请求头示例: + +```http +Authorization: Bearer +X-Request-Timestamp: +X-Request-Nonce: <唯一随机串> +``` + +`GET` 查询类接口和 `POST /api/v1/resource-restores/precheck` 当前不需要防重放头,但仍需要登录态。 + +### 2.4 时间字段 + +时间字段前端按字符串展示即可: + +- Java 任务时间:`2026-04-29T16:00:00` +- shell 状态时间:`2026-04-29T16:00:00+0800` + +## 3. 页面流程总览 + +### 3.1 备份流程 + +1. 用户点击“创建资源备份”。 +2. 前端调用 `POST /api/v1/resource-backups`。 +3. 根据返回的 `data.status` 判断: + - `SUCCESS`:展示“下载备份包” + - `FAILED`:展示 `errorMessage`,可查看备份步骤/日志 + - 其它状态:调用 `GET /api/v1/resource-backups/{taskId}` 轮询 +4. 用户点击“下载”,调用 `GET /api/v1/resource-backups/{taskId}/download`。 +5. 浏览器保存 `.tmsbak` 文件。 + +当前后端创建备份通常同步完成,`POST /api/v1/resource-backups` 可能直接返回 `SUCCESS` 或 `FAILED`。前端保留轮询逻辑即可。 + +### 3.2 恢复流程 + +1. 用户选择 `.tmsbak` 文件。 +2. 前端调用 `POST /api/v1/files/upload` 上传文件,拿到 `fileId`。 +3. 前端调用 `POST /api/v1/resource-restores/precheck`,传入 `fileId`。 +4. 前端展示预检信息,并按 `data.precheckPassed` 判断是否允许下一步。 +5. `precheckPassed=true` 时,前端展示一次“确认恢复”弹窗,弹窗中明确提示会进入停机恢复。 +6. 用户确认后,前端调用 `POST /api/v1/resource-restores` 创建恢复准备任务。 +7. 创建成功且 `data.status=READY_TO_APPLY` 时,前端不要再次弹确认框,直接调用 `POST /api/v1/resource-restores/{taskId}/apply`。 +8. `apply` 接口返回后,TMS 可能很快停机、恢复、重启。前端应提示页面/API 短暂不可用。 +9. TMS 重启后,恢复页面调用 `GET /api/v1/resource-restores/latest` 展示最近恢复结果。 + +### 3.3 恢复流程判断点 + +| 阶段 | 判断字段 | 前端动作 | +| --- | --- | --- | +| 上传备份包 | `data.fileId` 非空 | 保存 `fileId`,进入预检 | +| 预检 | `data.precheckPassed === true` | 允许点击“确认恢复” | +| 预检 | `data.precheckPassed !== true` | 禁止下一步,展示 `blockingIssues` / `warnings` | +| 创建恢复任务 | `data.status === "READY_TO_APPLY"` | 立即调用 apply,不再弹确认框 | +| 执行恢复任务 | `data.status === "APPLYING"` | 展示“恢复中,服务可能重启” | +| 重启后查询 latest | `data.status === "SUCCESS"` | 展示恢复成功 | +| 重启后查询 latest | `data.status === "FAILED"` | 展示恢复失败、`phase`、`message` | +| 重启后查询 latest | `data.status === "NONE"` | 展示暂无恢复记录 | + +## 4. 接口明细 + +### 4.1 上传备份包 + +`POST /api/v1/files/upload` + +请求类型:`multipart/form-data` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | `File` | 是 | 用户选择的 `.tmsbak` 文件 | + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "fileId": "FILE-20260429-000001", + "filename": "RBKP-20260429-160000-ABCDEF.tmsbak", + "size": 1048576 + } +} +``` + +前端只需要保存 `fileId`,不要依赖服务端文件路径。 + +### 4.2 创建资源备份 + +`POST /api/v1/resource-backups` + +需要防重放头。 + +请求体可为空,也可以传备注: + +```json +{ + "remark": "硬件更换前备份" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `remark` | `string` | 否 | 备份备注 | + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "taskId": "RBKP-TASK-20260429-000001", + "backupId": "RBKP-20260429-160000-ABCDEF", + "status": "SUCCESS", + "createdAt": "2026-04-29T16:00:00" + } +} +``` + +前端判断: + +- `status=SUCCESS`:允许下载 +- `status=FAILED`:展示失败原因,可查询任务详情或步骤日志 +- 其它状态:使用 `taskId` 查询任务详情 + +### 4.3 查询备份任务详情 + +`GET /api/v1/resource-backups/{taskId}` + +常用响应字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `taskId` | `string` | 备份任务号 | +| `backupId` | `string` | 备份包身份号,恢复预检后会显示给用户确认 | +| `status` | `ResourceTaskStatus` | 任务状态 | +| `productType` | `ProductType` | 产品类型 | +| `mqType` | `MqType` | MQ 类型 | +| `orgCode` | `string` | 机构号 | +| `packageName` | `string` | 备份包文件名 | +| `packageSize` | `number` | 包大小,字节 | +| `packageHash` | `string` | 包摘要 | +| `remark` | `string` | 创建备份时传入的备注 | +| `errorMessage` | `string` | 失败原因 | +| `createdAt` | `string` | 创建时间 | +| `finishedAt` | `string` | 完成时间 | + +### 4.4 下载备份包 + +`GET /api/v1/resource-backups/{taskId}/download` + +该接口返回文件流,不是 `ApiResponse`。 + +响应头示例: + +```http +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="RBKP-20260429-160000-ABCDEF.tmsbak" +``` + +前端建议: + +- 仅当备份任务 `status=SUCCESS` 时展示下载按钮 +- 使用浏览器下载能力或 `blob` 保存文件 + +### 4.5 查询备份步骤 + +`GET /api/v1/resource-backups/{taskId}/steps` + +用途:备份失败时展示执行上下文。 + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": [ + { + "stepNo": 1, + "stepCode": "PACKAGE_CREATE", + "status": "SUCCESS", + "message": "generated backup package RBKP-20260429-160000-ABCDEF.tmsbak", + "startedAt": "2026-04-29T16:00:00", + "finishedAt": "2026-04-29T16:00:03", + "logAvailable": false + } + ] +} +``` + +### 4.6 查询备份步骤日志 + +`GET /api/v1/resource-backups/{taskId}/steps/{stepNo}/log` + +用途:备份失败或排障时展示详细日志。 + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "taskId": "RBKP-TASK-20260429-000001", + "stepNo": 1, + "stepCode": "PACKAGE_CREATE", + "content": "{\"packageName\":\"RBKP-20260429-160000-ABCDEF.tmsbak\"}", + "truncated": false + } +} +``` + +### 4.7 恢复预检 + +`POST /api/v1/resource-restores/precheck` + +请求体: + +```json +{ + "fileId": "FILE-20260429-000001" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `fileId` | `string` | 是 | 上传接口返回的文件 ID | + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "precheckId": "RPRE-20260429-000001", + "backupId": "RBKP-20260429-160000-ABCDEF", + "sourceProductType": "ENTERPRISE", + "sourceMqType": "RABBITMQ", + "sourceOrgCode": "AAAABBBBXXX", + "sourceBackupTime": "2026-04-29T16:00:00", + "targetProductType": "ENTERPRISE", + "keysetMatched": true, + "signatureValid": true, + "compatible": true, + "precheckPassed": true, + "payloadDecrypted": false, + "warnings": [], + "blockingIssues": [], + "restorePlan": ["JAVA_EXTRACT", "SHELL_APPLY"], + "restoreScopeSummary": { + "resourceCount": 6, + "productType": "ENTERPRISE", + "mqType": "RABBITMQ", + "orgCode": "AAAABBBBXXX" + }, + "expiresAt": "2026-04-29T16:30:00" + } +} +``` + +前端判断: + +- 下一步只看 `precheckPassed` +- `precheckPassed=true`:允许创建恢复任务 +- `precheckPassed=false`:禁止下一步,展示 `blockingIssues`;如只有 `warnings`,按页面需要提示用户 + +说明: + +- `precheckPassed` 是后端聚合结果,当前等价于 `keysetMatched=true`、`signatureValid=true`、`compatible=true` 且 `blockingIssues` 为空 +- `payloadDecrypted=false` 是正常值,预检阶段不解密完整 payload +- 真正解密、展开 payload、路径白名单校验发生在创建恢复任务阶段 + +### 4.8 创建恢复任务 + +`POST /api/v1/resource-restores` + +需要防重放头。 + +请求体: + +```json +{ + "precheckId": "RPRE-20260429-000001", + "confirmBackupId": "RBKP-20260429-160000-ABCDEF", + "deployMode": "DUAL", + "nodes": { + "node01Ip": "192.168.1.11", + "node02Ip": "192.168.1.12" + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `precheckId` | `string` | 是 | 预检接口返回的预检号 | +| `confirmBackupId` | `string` | 是 | 用户确认的备份号,必须等于预检响应 `backupId` | +| `deployMode` | `DeployMode` | 是 | `SINGLE`、`DUAL`、`QUAD` | +| `nodes.node01Ip` | `string` | 是 | 节点 1 IPv4 | +| `nodes.node02Ip` | `string` | `DUAL` / `QUAD` 必填 | 节点 2 IPv4 | +| `nodes.node03Ip` | `string` | `QUAD` 必填 | 节点 3 IPv4 | +| `nodes.node04Ip` | `string` | `QUAD` 必填 | 节点 4 IPv4 | + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "taskId": "RRST-TASK-20260429-000001", + "status": "READY_TO_APPLY", + "createdAt": "2026-04-29T16:05:00" + } +} +``` + +前端判断: + +- `status=READY_TO_APPLY`:立即调用 apply,不再弹确认框 +- 其它状态:展示异常提示,不调用 apply + +后端在该阶段会完成验签、解密、payload 展开、用户密钥恢复、路径白名单校验和恢复计划文件生成,但不会停止 TMS。 + +### 4.9 执行恢复任务 + +`POST /api/v1/resource-restores/{taskId}/apply` + +需要防重放头。 + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "taskId": "RRST-TASK-20260429-000001", + "status": "APPLYING", + "createdAt": "2026-04-29T16:05:00" + } +} +``` + +前端判断: + +- `status=APPLYING`:提示用户恢复执行中,页面和接口可能短暂不可用 +- 之后不要继续轮询恢复步骤,等待 TMS 重启后查 latest + +后端在该阶段会启动后台脚本,执行停服务、文件覆盖、数据库导入、MQ replay、启动服务和健康检查。 + +### 4.10 查询最近恢复结果 + +`GET /api/v1/resource-restores/latest` + +用途:TMS 重启后,恢复页面展示最近一次恢复结果。 + +响应示例: + +```json +{ + "success": true, + "code": 200, + "msg": "成功", + "data": { + "taskId": "RRST-TASK-20260429-000001", + "backupId": "RBKP-20260429-160000-ABCDEF", + "status": "FAILED", + "phase": "DATABASE", + "message": "数据库恢复失败", + "createdAt": "2026-04-29T16:05:00", + "finishedAt": "2026-04-29T16:12:00+0800" + } +} +``` + +前端判断: + +- `status=SUCCESS`:展示最近恢复成功和 `finishedAt` +- `status=FAILED`:展示恢复失败、`phase`、`message` +- `status=APPLYING` 或 `RUNNING`:展示恢复仍在执行或等待刷新 +- `status=NONE`:展示暂无恢复记录 + +## 5. 状态和枚举 + +### 5.1 ProductType + +| 值 | 说明 | +| --- | --- | +| `ENTERPRISE` | 企参模式 | +| `INDIRECT` | 间参模式 | +| `DIRECT` | 直参模式 | +| `UNKNOWN` | 后端无法识别或未配置 | + +### 5.2 MqType + +| 值 | 说明 | +| --- | --- | +| `RABBITMQ` | 标准 RabbitMQ | +| `TLQ` | TLQ | +| `RABBITMQ_TLQ` | RabbitMQ + TLQ | +| `RABBITMQ_CFMQ` | RabbitMQ + CFMQ | + +### 5.3 DeployMode + +| 值 | 说明 | 节点要求 | +| --- | --- | --- | +| `SINGLE` | 单节点部署 | `node01Ip` | +| `DUAL` | 双节点部署 | `node01Ip`、`node02Ip` | +| `QUAD` | 四节点部署 | `node01Ip`、`node02Ip`、`node03Ip`、`node04Ip` | + +第一版中,节点参数只写入恢复上下文用于审计和排障,不自动改写恢复后的配置文件。 + +### 5.4 ResourceTaskStatus + +| 值 | 前端文案建议 | 说明 | +| --- | --- | --- | +| `RUNNING` | 执行中 | 备份或 shell 恢复执行中 | +| `SUCCESS` | 成功 | 任务成功 | +| `FAILED` | 失败 | 任务失败 | +| `READY_TO_APPLY` | 待停机恢复 | 恢复任务已准备好,前端可继续调用 apply | +| `APPLYING` | 恢复执行中 | 恢复脚本已启动或即将启动 | +| `NONE` | 暂无记录 | 仅 latest 接口可能返回 | + +其它预留状态前端可以兜底展示原值。 + +### 5.5 RestorePhase + +| 值 | 说明 | +| --- | --- | +| `APPLY_DELAY` | 后台脚本启动后短暂等待 | +| `SERVICE_STOP` | 停止服务 | +| `FILE` | 恢复文件 | +| `DATABASE` | 导入数据库 | +| `MQ_REPLAY` | 重放 MQ 配置 | +| `SERVICE_START` | 启动服务 | +| `HEALTH_CHECK` | 健康检查 | +| `DONE` | 完成 | + +### 5.6 资源范围说明 + +当前备份/恢复资源包括: + +- `TMS_CONFIG` +- `TMS_DB` +- `CMEP_DB` +- `ORG_DB` +- `CP_CONFIG` +- `RECEIVER_LICENSE` +- `STANDARD_CMSP_APP_CONFIG` +- `STANDARD_CMTP_APP_CONFIG` +- `USER_KEY` +- `MQ_REPLAY` + +`TMS_DB` 默认排除 `tms.backup.tms-db-excluded-tables` 中声明的表。默认排除角色、授权、会话和审计表,避免恢复时覆盖新机器安全状态。 + +## 6. TypeScript 类型建议 + +```ts +export interface ApiResponse { + success: boolean; + code: number; + msg: string; + data: T; + timestamp?: number; + traceId?: string; +} + +export type ProductType = 'ENTERPRISE' | 'INDIRECT' | 'DIRECT' | 'UNKNOWN'; +export type MqType = 'RABBITMQ' | 'TLQ' | 'RABBITMQ_TLQ' | 'RABBITMQ_CFMQ'; +export type DeployMode = 'SINGLE' | 'DUAL' | 'QUAD'; + +export type ResourceTaskStatus = + | 'RUNNING' + | 'SUCCESS' + | 'FAILED' + | 'READY_TO_APPLY' + | 'APPLYING' + | 'NONE' + | string; + +export interface CreateResourceBackupRequest { + remark?: string; +} + +export interface CreateResourceBackupResponse { + taskId: string; + backupId?: string; + status: ResourceTaskStatus; + createdAt?: string; +} + +export interface ResourceBackupTaskDetailResponse { + taskId: string; + backupId?: string; + status: ResourceTaskStatus; + productType?: ProductType; + mqType?: MqType; + orgCode?: string; + packageName?: string; + packageSize?: number; + packageHash?: string; + remark?: string; + errorMessage?: string; + createdAt?: string; + finishedAt?: string; +} + +export interface ResourceRestorePrecheckRequest { + fileId: string; +} + +export interface ResourceRestorePrecheckResponse { + precheckId: string; + backupId: string; + sourceProductType?: ProductType; + sourceMqType?: MqType; + sourceOrgCode?: string; + sourceBackupTime?: string; + targetProductType?: ProductType; + keysetMatched: boolean; + signatureValid: boolean; + compatible: boolean; + precheckPassed: boolean; + payloadDecrypted: boolean; + warnings: string[]; + blockingIssues: string[]; + restorePlan: string[]; + restoreScopeSummary?: unknown; + expiresAt: string; +} + +export interface CreateResourceRestoreRequest { + precheckId: string; + confirmBackupId: string; + deployMode: DeployMode; + nodes: { + node01Ip: string; + node02Ip?: string; + node03Ip?: string; + node04Ip?: string; + }; +} + +export interface CreateResourceRestoreResponse { + taskId: string; + status: ResourceTaskStatus; + createdAt?: string; +} + +export interface ResourceRestoreLatestResponse { + taskId?: string; + backupId?: string; + status: ResourceTaskStatus; + phase?: string; + message?: string; + createdAt?: string; + finishedAt?: string; +} +``` + +## 7. Vue 3 对接示例 + +以下示例按 Vue 3 Composition API 编写。`request`、`message`、`confirmRestore`、`downloadByUrl` 可替换为项目内已有的 axios 封装、弹窗组件和下载工具。 + +### 7.1 防重放头 + +```ts +function replayHeaders() { + return { + 'X-Request-Timestamp': String(Math.floor(Date.now() / 1000)), + 'X-Request-Nonce': crypto.randomUUID() + }; +} +``` + +### 7.2 备份 composable + +```ts +import { computed, ref } from 'vue'; + +export function useResourceBackup() { + const backupTask = ref(); + const backupLoading = ref(false); + + const canDownload = computed(() => backupTask.value?.status === 'SUCCESS'); + const backupFailed = computed(() => backupTask.value?.status === 'FAILED'); + + async function createBackup(remark?: string) { + backupLoading.value = true; + try { + const res = await request.post>( + '/api/v1/resource-backups', + remark ? { remark } : {}, + { headers: replayHeaders() } + ); + if (!res.data.success) throw new Error(res.data.msg); + + backupTask.value = res.data.data; + if (!['SUCCESS', 'FAILED'].includes(res.data.data.status)) { + await refreshBackup(res.data.data.taskId); + } + } finally { + backupLoading.value = false; + } + } + + async function refreshBackup(taskId: string) { + const res = await request.get>( + `/api/v1/resource-backups/${taskId}` + ); + if (!res.data.success) throw new Error(res.data.msg); + backupTask.value = res.data.data; + } + + function downloadBackup() { + if (!backupTask.value || backupTask.value.status !== 'SUCCESS') return; + downloadByUrl(`/api/v1/resource-backups/${backupTask.value.taskId}/download`); + } + + return { + backupTask, + backupLoading, + canDownload, + backupFailed, + createBackup, + refreshBackup, + downloadBackup + }; +} +``` + +页面判断: + +- `canDownload=true` 时展示“下载备份包”。 +- `backupFailed=true` 时展示 `backupTask.errorMessage`,并允许进入备份步骤/日志排查。 +- 备份侧可以继续保留任务详情、步骤、日志查询。 + +### 7.3 恢复 composable + +```ts +import { computed, ref } from 'vue'; + +export function useResourceRestore() { + const selectedFile = ref(); + const precheckResult = ref(); + const latestRestore = ref(); + const restoreLoading = ref(false); + + const canCreateRestore = computed(() => precheckResult.value?.precheckPassed === true); + const precheckBlocked = computed(() => precheckResult.value?.precheckPassed === false); + + async function uploadAndPrecheck(file: File) { + selectedFile.value = file; + restoreLoading.value = true; + try { + const form = new FormData(); + form.append('file', file); + + const upload = await request.post>( + '/api/v1/files/upload', + form + ); + if (!upload.data.success) throw new Error(upload.data.msg); + + const precheck = await request.post>( + '/api/v1/resource-restores/precheck', + { fileId: upload.data.data.fileId } + ); + if (!precheck.data.success) throw new Error(precheck.data.msg); + + precheckResult.value = precheck.data.data; + } finally { + restoreLoading.value = false; + } + } + + async function confirmAndRestore(deployMode: DeployMode, nodes: CreateResourceRestoreRequest['nodes']) { + if (!precheckResult.value || !canCreateRestore.value) return; + + await confirmRestore({ + title: '确认恢复资源', + message: `确认使用备份包 ${precheckResult.value.backupId} 进行恢复?恢复过程中服务会停启,页面可能短暂不可访问。` + }); + + restoreLoading.value = true; + try { + const created = await request.post>( + '/api/v1/resource-restores', + { + precheckId: precheckResult.value.precheckId, + confirmBackupId: precheckResult.value.backupId, + deployMode, + nodes + }, + { headers: replayHeaders() } + ); + if (!created.data.success) throw new Error(created.data.msg); + if (created.data.data.status !== 'READY_TO_APPLY') { + throw new Error('恢复任务状态异常'); + } + + const applied = await request.post>( + `/api/v1/resource-restores/${created.data.data.taskId}/apply`, + {}, + { headers: replayHeaders() } + ); + if (!applied.data.success) throw new Error(applied.data.msg); + + message.info('恢复已开始,TMS 将停机并重启。重启后请返回恢复页面查看最近恢复结果。'); + } finally { + restoreLoading.value = false; + } + } + + async function loadLatestRestore() { + const res = await request.get>( + '/api/v1/resource-restores/latest' + ); + if (!res.data.success) throw new Error(res.data.msg); + latestRestore.value = res.data.data; + } + + return { + selectedFile, + precheckResult, + latestRestore, + restoreLoading, + canCreateRestore, + precheckBlocked, + uploadAndPrecheck, + confirmAndRestore, + loadLatestRestore + }; +} +``` + +页面判断: + +- 文件选择后调用 `uploadAndPrecheck(file)`。 +- “确认恢复”按钮只在 `canCreateRestore=true` 时启用。 +- `precheckBlocked=true` 时展示 `blockingIssues`,不要允许用户继续。 +- `confirmAndRestore` 内部只弹一次确认;用户确认后按顺序调用创建恢复任务和 apply。 +- apply 返回后不要轮询恢复任务详情、恢复步骤或恢复日志。 + +### 7.4 重启后最近恢复结果展示 + +```vue + + + +``` + +## 8. 交互和展示建议 + +- 备份创建按钮点击后立即禁用,避免重复提交。 +- 恢复预检通过后,必须展示备份号 `backupId`,让用户确认正在恢复哪个包。 +- `precheckPassed=false` 时不要允许创建恢复任务。 +- 恢复流程只做一次用户确认;确认后前端按顺序调用创建恢复任务和 apply。 +- apply 返回后不要继续请求恢复步骤接口;恢复侧没有步骤接口。 +- TMS 重启后只调用 latest 展示最近恢复结果。 +- `payloadDecrypted=false` 不表示失败,它表示预检阶段没有解密完整 payload。 +- `TMS_DB` 默认不包含角色、授权、会话和审计表,因此恢复不会覆盖新机器已有登录授权状态。 +- 服务端路径类字段不展示给普通用户,只用于运维排查。 diff --git a/scripts/resource-restore/apply-resource-backup.sh b/scripts/resource-restore/apply-resource-backup.sh index 9e27d72..1e83797 100755 --- a/scripts/resource-restore/apply-resource-backup.sh +++ b/scripts/resource-restore/apply-resource-backup.sh @@ -30,6 +30,8 @@ FILES_PLAN="${WORK_DIR}/restore-files.tsv" DATABASES_PLAN="${WORK_DIR}/restore-databases.tsv" MQ_PLAN="${WORK_DIR}/restore-mq.tsv" ENV_FILE="${WORK_DIR}/restore-env.sh" +STOP_COMMANDS_PLAN="${WORK_DIR}/restore-stop-commands.tsv" +START_COMMANDS_PLAN="${WORK_DIR}/restore-start-commands.tsv" json_escape() { local value="$1" @@ -131,6 +133,26 @@ replay_mq() { done < "${MQ_PLAN}" } +run_service_commands() { + local plan_file="$1" + local phase="$2" + [[ -f "${plan_file}" ]] || return 1 + [[ -s "${plan_file}" ]] || return 1 + while IFS=$'\t' read -r code required command || [[ -n "${code:-}" ]]; do + [[ -n "${code:-}" ]] || continue + [[ -n "${command:-}" ]] || fail "service command is empty: ${code}" "${phase}" + log "${phase} ${code}: ${command}" + if bash -lc "${command}" >> "${LOG_FILE}" 2>&1; then + log "${phase} ${code}: success" + elif [[ "${required}" == "true" ]]; then + fail "${phase} command failed: ${code}" "${phase}" + else + log "[WARN] ${phase} ${code}: failed but ignored" + fi + done < "${plan_file}" + return 0 +} + health_check() { local url="${HEALTH_CHECK_URL:-}" [[ -n "${url}" ]] || { @@ -159,11 +181,19 @@ fi [[ -f "${MANIFEST}" ]] || fail "payload manifest not found" "PRECHECK" +delay_seconds="${RESTORE_APPLY_DELAY_SECONDS:-3}" +if [[ "${delay_seconds}" =~ ^[0-9]+$ ]] && [[ "${delay_seconds}" -gt 0 ]]; then + write_status "RUNNING" "APPLY_DELAY" "restore apply will start" + log "APPLY_DELAY ${delay_seconds}s" + sleep "${delay_seconds}" +fi + # 从这里开始进入停机恢复窗口。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 +if ! run_service_commands "${STOP_COMMANDS_PLAN}" "SERVICE_STOP" \ + && [[ -n "${TMS_SCRIPT_PATH:-}" && -x "${TMS_SCRIPT_PATH}" ]]; then "${TMS_SCRIPT_PATH}" stop >> "${LOG_FILE}" 2>&1 || true fi @@ -181,7 +211,8 @@ replay_mq write_status "RUNNING" "SERVICE_START" "starting services" log "SERVICE_START" -if [[ -n "${TMS_SCRIPT_PATH:-}" && -x "${TMS_SCRIPT_PATH}" ]]; then +if ! run_service_commands "${START_COMMANDS_PLAN}" "SERVICE_START" \ + && [[ -n "${TMS_SCRIPT_PATH:-}" && -x "${TMS_SCRIPT_PATH}" ]]; then "${TMS_SCRIPT_PATH}" start >> "${LOG_FILE}" 2>&1 || true fi diff --git a/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java index 435efeb..54a2607 100644 --- a/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java +++ b/src/main/java/com/cisd/tms/modules/backup/config/ResourceBackupProperties.java @@ -1,7 +1,7 @@ package com.cisd.tms.modules.backup.config; -import com.cisd.tms.modules.backup.dto.support.BackupResourceKind; import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource; +import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand; import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -17,12 +17,21 @@ public class ResourceBackupProperties { private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh"; private String mqReplayScriptPath = "/home/tms/bin/resource-restore/replay-mq.sh"; private String tmsDatabaseName = ""; + private List tmsDbExcludedTables = List.of( + "tms_role_account", + "tms_role_ukey_binding", + "tms_auth_full_account", + "tms_auth_session", + "tms_auth_challenge", + "tms_auth_audit_log", + "tms_operation_audit_log" + ); private String cmepDatabaseName = "CMEP"; private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health"; private int healthCheckTimeoutSeconds = 5; - private int storageEncKeyIndex = 1; - private int authenticitySignKeyIndex = 1; private String restoreApplyScriptPath = "/home/tms/bin/resource-restore/apply-resource-backup.sh"; + private List restoreStopCommands = List.of(); + private List restoreStartCommands = List.of(); private List allowedRestoreRoots = List.of( "/home/tms/config", "/home/cmep4i", @@ -103,6 +112,14 @@ public class ResourceBackupProperties { this.tmsDatabaseName = tmsDatabaseName; } + public List getTmsDbExcludedTables() { + return tmsDbExcludedTables; + } + + public void setTmsDbExcludedTables(List tmsDbExcludedTables) { + this.tmsDbExcludedTables = tmsDbExcludedTables; + } + public String getCmepDatabaseName() { return cmepDatabaseName; } @@ -127,22 +144,6 @@ public class ResourceBackupProperties { this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds; } - public int getStorageEncKeyIndex() { - return storageEncKeyIndex; - } - - public void setStorageEncKeyIndex(int storageEncKeyIndex) { - this.storageEncKeyIndex = storageEncKeyIndex; - } - - public int getAuthenticitySignKeyIndex() { - return authenticitySignKeyIndex; - } - - public void setAuthenticitySignKeyIndex(int authenticitySignKeyIndex) { - this.authenticitySignKeyIndex = authenticitySignKeyIndex; - } - public String getRestoreApplyScriptPath() { return restoreApplyScriptPath; } @@ -151,6 +152,22 @@ public class ResourceBackupProperties { this.restoreApplyScriptPath = restoreApplyScriptPath; } + public List getRestoreStopCommands() { + return restoreStopCommands; + } + + public void setRestoreStopCommands(List restoreStopCommands) { + this.restoreStopCommands = restoreStopCommands; + } + + public List getRestoreStartCommands() { + return restoreStartCommands; + } + + public void setRestoreStartCommands(List restoreStartCommands) { + this.restoreStartCommands = restoreStartCommands; + } + public List getAllowedRestoreRoots() { return allowedRestoreRoots; } @@ -167,33 +184,4 @@ public class ResourceBackupProperties { this.fileResources = fileResources; } - public List getDefaultFileResources() { - ConfiguredFileBackupResource tmsConfig = fileResource( - "TMS_CONFIG", - "DIR", - "/home/tms/config", - "/home/tms/config", - true - ); - ConfiguredFileBackupResource cpConfig = fileResource("CP_CONFIG", "FILE", "", "", true); - ConfiguredFileBackupResource receiverLicense = fileResource("RECEIVER_LICENSE", "FILE", "", "", true); - return List.of(tmsConfig, cpConfig, receiverLicense); - } - - private static ConfiguredFileBackupResource fileResource( - String code, - String mode, - String path, - String restorePath, - boolean required - ) { - ConfiguredFileBackupResource resource = new ConfiguredFileBackupResource(); - resource.setCode(code); - resource.setKind(BackupResourceKind.FILE.name()); - resource.setMode(mode); - resource.setPath(path); - resource.setRestorePath(restorePath); - resource.setRequired(required); - return resource; - } } 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 8e1cc94..2858c05 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 @@ -49,7 +49,7 @@ public class ResourceBackupController { @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) { + public ApiResponse createBackupTask(@Valid @RequestBody(required = false) CreateResourceBackupRequest request) { return ApiResponse.success(resourceBackupService.createBackupTask(request)); } 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 1896e53..c82898b 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 @@ -7,10 +7,8 @@ import com.cisd.tms.modules.auth.security.RequireInternalAuth; import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; import com.cisd.tms.modules.backup.service.ResourcePrecheckService; import com.cisd.tms.modules.backup.service.ResourceRestoreService; import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; @@ -22,7 +20,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -34,7 +31,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/resource-restores") -@Tag(name = "资源恢复", description = "资源恢复预检、任务创建、详情和步骤日志接口") +@Tag(name = "资源恢复", description = "资源恢复预检、任务创建、执行和最近结果接口") @RequiredArgsConstructor public class ResourceRestoreController { @@ -59,35 +56,23 @@ public class ResourceRestoreController { return ApiResponse.success(resourceRestoreService.createRestoreTask(request)); } - @GetMapping("/{taskId}") + @PostMapping("/{taskId}/apply") + @ReplayProtected @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) - @Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。") - public ApiResponse taskDetail( + @AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "执行资源恢复任务") + @Operation(summary = "执行资源恢复任务", description = "用户确认停机恢复后,启动后台恢复脚本。") + public ApiResponse applyRestoreTask( @Parameter(description = "恢复任务号") @PathVariable String taskId ) { - return ApiResponse.success(resourceTaskQueryService.getRestoreTask(taskId)); + return ApiResponse.success(resourceRestoreService.applyRestoreTask(taskId)); } - @GetMapping("/{taskId}/steps") + @GetMapping("/latest") @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) - @Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。") - public ApiResponse> taskSteps( - @Parameter(description = "恢复任务号") - @PathVariable String taskId - ) { - return ApiResponse.success(resourceTaskQueryService.getRestoreTaskSteps(taskId)); + @Operation(summary = "查询最近资源恢复结果", description = "TMS 重启后用于恢复页面展示最近一次恢复时间、结果和失败原因。") + public ApiResponse latestRestore() { + return ApiResponse.success(resourceTaskQueryService.getLatestRestore()); } - @GetMapping("/{taskId}/steps/{stepNo}/log") - @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) - @Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。") - public ApiResponse taskStepLog( - @Parameter(description = "恢复任务号") - @PathVariable String taskId, - @Parameter(description = "步骤序号") - @PathVariable int stepNo - ) { - return ApiResponse.success(resourceTaskQueryService.getRestoreTaskStepLog(taskId, stepNo)); - } } diff --git a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceCryptoKeyConstants.java b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceCryptoKeyConstants.java new file mode 100644 index 0000000..7bc3eff --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceCryptoKeyConstants.java @@ -0,0 +1,12 @@ +package com.cisd.tms.modules.backup.crypto; + +public final class ResourceCryptoKeyConstants { + + public static final int STORAGE_ENC_KEY_INDEX = 1; + public static final int AUTHENTICITY_SIGN_KEY_INDEX = 1; + public static final String STORAGE_ENC_KEY_FINGERPRINT = "USER_KEY_ENC:1"; + public static final String AUTHENTICITY_KEY_FINGERPRINT = "USER_KEY_SIGN:1"; + + private ResourceCryptoKeyConstants() { + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java index cb792c6..dabc996 100644 --- a/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceImpl.java @@ -1,12 +1,7 @@ package com.cisd.tms.modules.backup.crypto; -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.model.EccInternalSignRequest; -import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest; +import com.cisd.tms.integration.crypto.pcie.model.*; import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; -import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import java.security.Security; import java.util.Arrays; import java.util.Base64; @@ -24,7 +19,6 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto private static final String PROVIDER = "BC"; private final PcieCryptoService pcieCryptoService; - private final ResourceBackupProperties resourceBackupProperties; static { if (Security.getProvider(PROVIDER) == null) { @@ -32,12 +26,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto } } - public ResourceEnvelopeCryptoServiceImpl( - PcieCryptoService pcieCryptoService, - ResourceBackupProperties resourceBackupProperties - ) { + public ResourceEnvelopeCryptoServiceImpl(PcieCryptoService pcieCryptoService) { this.pcieCryptoService = pcieCryptoService; - this.resourceBackupProperties = resourceBackupProperties; } @Override @@ -55,7 +45,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto // 用户密钥只负责封装随机 DEK;真正的大 payload 仍走对称加密,避免直接把大文件送进 ECC 接口。 EccInternalEncryptRequest encryptRequest = new EccInternalEncryptRequest(); - encryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex()); + encryptRequest.setKeyIndex(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_INDEX); encryptRequest.setData(sm4Key); BackupDataResult wrappedDek = pcieCryptoService.sdfeInternalEncryptEcc(encryptRequest); @@ -70,8 +60,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto ResourceSealedPayload sealedPayload = new ResourceSealedPayload(); sealedPayload.setCipherBytes(cipher.doFinal(payloadBytes)); sealedPayload.setEnvelope(envelope); - sealedPayload.setStorageEncKeyFingerprint(buildFingerprint("USER_KEY_ENC", resourceBackupProperties.getStorageEncKeyIndex())); - sealedPayload.setAuthenticityKeyFingerprint(buildFingerprint("USER_KEY_SIGN", resourceBackupProperties.getAuthenticitySignKeyIndex())); + sealedPayload.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT); + sealedPayload.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT); return sealedPayload; } catch (Exception ex) { throw new IllegalStateException("资源备份载荷加封失败", ex); @@ -85,11 +75,11 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto if (dataToSign == null) { throw new IllegalArgumentException("dataToSign不能为空"); } - EccInternalSignRequest request = new EccInternalSignRequest(); - request.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex()); - // 内部签名接口这里统一签 SM3 摘要,避免把整段原文直接交给密码卡并固定后续验签输入语义。 - request.setData(sm3(dataToSign)); - return normalize(pcieCryptoService.eccInternalSign(request)); + UserKeySignRequest request = new UserKeySignRequest(); + request.setData(dataToSign); + request.setKeyIndex(ResourceCryptoKeyConstants.AUTHENTICITY_SIGN_KEY_INDEX); + return normalize(pcieCryptoService.userKeySignWithSm3(request)); + } @Override @@ -101,7 +91,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto throw new IllegalArgumentException("signature不能为空"); } EccInternalVerifyRequest verifyRequest = new EccInternalVerifyRequest(); - verifyRequest.setKeyIndex(resourceBackupProperties.getAuthenticitySignKeyIndex()); + verifyRequest.setKeyIndex(ResourceCryptoKeyConstants.AUTHENTICITY_SIGN_KEY_INDEX); verifyRequest.setData(sm3(dataToVerify)); verifyRequest.setSignature(signature); pcieCryptoService.eccInternalVerify(verifyRequest); @@ -127,7 +117,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto int dekLength = parseInt(envelope.get("dekLength"), 16); // 先验签,再解数字信封,再解 payload;顺序固定,避免篡改包先进入明文处理路径。 EccInternalDecryptRequest decryptRequest = new EccInternalDecryptRequest(); - decryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex()); + decryptRequest.setKeyIndex(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_INDEX); decryptRequest.setCipherBlob(wrappedDek); decryptRequest.setOutBufferSize(dekLength); byte[] sm4Key = normalizeAndWipe(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest)); @@ -193,10 +183,6 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto } } - private static String buildFingerprint(String prefix, int keyIndex) { - return prefix + ":" + keyIndex; - } - private static void wipe(byte[] bytes) { if (bytes != null) { Arrays.fill(bytes, (byte) 0); 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 c79d15c..36345ba 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 @@ -1,19 +1,22 @@ package com.cisd.tms.modules.backup.discovery; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource; import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; -import com.cisd.tms.modules.init.config.InitExecutorProperties; import com.cisd.tms.modules.init.entity.InitTaskEntity; import java.util.ArrayList; import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { - private final InitExecutorProperties initExecutorProperties; + private final ResourceBackupProperties resourceBackupProperties; - public ResourceDiscoveryServiceImpl(InitExecutorProperties initExecutorProperties) { - this.initExecutorProperties = initExecutorProperties; + @Autowired + public ResourceDiscoveryServiceImpl(ResourceBackupProperties resourceBackupProperties) { + this.resourceBackupProperties = resourceBackupProperties == null ? new ResourceBackupProperties() : resourceBackupProperties; } @Override @@ -30,16 +33,15 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { // 资源发现只决定“应该备份什么”,不在这里触碰文件系统、数据库或密码卡。 // 真实采集放在 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)); + appendConfiguredFileResources(items, productType, mqType); // 数据库资源在包内固定成 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)); + // 标准收发器初始化还会创建一个以机构号命名的业务库;这份库和 CMEP 一样属于不可重建业务数据。 + String orgCode = requireDatabaseIdentifier(initTask.getOrgCode(), "orgCode"); + items.add(database("ORG_DB", orgCode, "db/" + orgCode + ".sql", true)); } // 实体证书表记录的 keyIdx 对应密码卡内用户签名密钥;备份时会按这些索引逐个调用 backupUserKey。 items.add(userKey(true)); @@ -48,18 +50,35 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { return items; } - private String resolveCpConfigPath(String productType) { - if ("DIRECT".equals(productType)) { - return initExecutorProperties.getDirectCpconfigTargetPath(); + private void appendConfiguredFileResources(List items, String productType, String mqType) { + List resources = resourceBackupProperties.getFileResources(); + if (resources == null || resources.isEmpty()) { + return; } - return initExecutorProperties.getStandardCpconfigPath(); - } - - private String resolveReceiverLicensePath(String productType) { - if ("DIRECT".equals(productType)) { - return initExecutorProperties.getDirectReceiverLicenseTargetPath(); + for (ConfiguredFileBackupResource resource : resources) { + if (!matches(resource.getProductTypes(), productType) || !matches(resource.getMqTypes(), mqType)) { + continue; + } + String code = trim(resource.getCode()); + String path = trim(resource.getPath()); + if (code.isEmpty() || path.isEmpty()) { + continue; + } + String mode = trim(resource.getMode()).isEmpty() ? "FILE" : trim(resource.getMode()).toUpperCase(); + String restorePath = trim(resource.getRestorePath()); + if (restorePath.isEmpty()) { + restorePath = "GLOB".equals(mode) ? globRestoreDir(path) : path; + } + DiscoveredResourceItem item = file( + code, + path, + restorePath, + mode, + resource.isRequired() + ); + removeExistingCode(items, code); + items.add(item); } - return initExecutorProperties.getStandardReceiverLicenseTargetPath(); } private static DiscoveredResourceItem file(String code, String path, String restorePath, String mode, boolean required) { @@ -73,6 +92,29 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { return item; } + private static String globRestoreDir(String pattern) { + String value = trim(pattern); + int slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + if (slash <= 0) { + return "."; + } + return value.substring(0, slash); + } + + private static void removeExistingCode(List items, String code) { + items.removeIf(item -> code.equals(item.getResourceCode())); + } + + private static boolean matches(List configuredValues, String actualValue) { + if (configuredValues == null || configuredValues.isEmpty()) { + return true; + } + String actual = upper(actualValue); + return configuredValues.stream() + .map(ResourceDiscoveryServiceImpl::upper) + .anyMatch(actual::equals); + } + private static DiscoveredResourceItem database(String code, String databaseName, String entryPath, boolean required) { DiscoveredResourceItem item = new DiscoveredResourceItem(); item.setResourceType("DATABASE"); @@ -104,4 +146,19 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService { private static String upper(String value) { return value == null ? "" : value.trim().toUpperCase(); } + + private static String requireDatabaseIdentifier(String value, String label) { + String identifier = trim(value); + if (identifier.isEmpty()) { + throw new IllegalArgumentException(label + "不能为空"); + } + if (!identifier.matches("[A-Za-z0-9_]+")) { + throw new IllegalArgumentException(label + "数据库名无效:" + identifier); + } + return identifier; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } } diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java index f742542..fee899d 100644 --- a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java +++ b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceBackupRequest.java @@ -2,34 +2,12 @@ package com.cisd.tms.modules.backup.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "创建资源备份任务请求") + @Schema(description = "创建资源备份任务请求") public class CreateResourceBackupRequest { - @Schema(description = "是否包含升级历史", example = "true") - private Boolean includeUpgradeHistory; - - @Schema(description = "是否包含审计备份", example = "false") - private Boolean includeAuditBackups; - @Schema(description = "备注", example = "before hardware replacement") private String remark; - public Boolean getIncludeUpgradeHistory() { - return includeUpgradeHistory; - } - - public void setIncludeUpgradeHistory(Boolean includeUpgradeHistory) { - this.includeUpgradeHistory = includeUpgradeHistory; - } - - public Boolean getIncludeAuditBackups() { - return includeAuditBackups; - } - - public void setIncludeAuditBackups(Boolean includeAuditBackups) { - this.includeAuditBackups = includeAuditBackups; - } - public String getRemark() { return remark; } diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java index c4ae9cb..6c9b5bf 100644 --- a/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java +++ b/src/main/java/com/cisd/tms/modules/backup/dto/request/CreateResourceRestoreRequest.java @@ -1,12 +1,17 @@ package com.cisd.tms.modules.backup.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; @Schema(description = "创建资源恢复任务请求") public class CreateResourceRestoreRequest { + private static final String IPV4_REGEX = + "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"; + @NotBlank @Schema(description = "预检号", example = "RPRE-20260408-000001", requiredMode = Schema.RequiredMode.REQUIRED) private String precheckId; @@ -15,9 +20,15 @@ public class CreateResourceRestoreRequest { @Schema(description = "确认的备份号", example = "RBKP-20260408-153000-000001", requiredMode = Schema.RequiredMode.REQUIRED) private String confirmBackupId; - @NotNull - @Schema(description = "是否确认接受恢复风险", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) - private Boolean confirmRiskAccepted; + @NotBlank(message = "deployMode不能为空") + @Pattern(regexp = "SINGLE|DUAL|QUAD", message = "deployMode必须为SINGLE/DUAL/QUAD") + @Schema(description = "目标部署方式", example = "DUAL", requiredMode = Schema.RequiredMode.REQUIRED) + private String deployMode; + + @Valid + @NotNull(message = "nodes不能为空") + @Schema(description = "目标节点 IP 配置", requiredMode = Schema.RequiredMode.REQUIRED) + private Nodes nodes; public String getPrecheckId() { return precheckId; @@ -35,11 +46,72 @@ public class CreateResourceRestoreRequest { this.confirmBackupId = confirmBackupId; } - public Boolean getConfirmRiskAccepted() { - return confirmRiskAccepted; + public String getDeployMode() { + return deployMode; } - public void setConfirmRiskAccepted(Boolean confirmRiskAccepted) { - this.confirmRiskAccepted = confirmRiskAccepted; + public void setDeployMode(String deployMode) { + this.deployMode = deployMode; + } + + public Nodes getNodes() { + return nodes; + } + + public void setNodes(Nodes nodes) { + this.nodes = nodes; + } + + @Schema(description = "目标节点 IP 配置") + public static class Nodes { + + @NotBlank(message = "nodes.node01Ip不能为空") + @Pattern(regexp = IPV4_REGEX, message = "nodes.node01Ip必须是有效IPv4地址") + @Schema(description = "节点 1 IP", example = "192.168.1.11", requiredMode = Schema.RequiredMode.REQUIRED) + private String node01Ip; + + @Pattern(regexp = IPV4_REGEX, message = "nodes.node02Ip必须是有效IPv4地址") + @Schema(description = "节点 2 IP", example = "192.168.1.12") + private String node02Ip; + + @Pattern(regexp = IPV4_REGEX, message = "nodes.node03Ip必须是有效IPv4地址") + @Schema(description = "节点 3 IP", example = "192.168.1.13") + private String node03Ip; + + @Pattern(regexp = IPV4_REGEX, message = "nodes.node04Ip必须是有效IPv4地址") + @Schema(description = "节点 4 IP", example = "192.168.1.14") + private String node04Ip; + + public String getNode01Ip() { + return node01Ip; + } + + public void setNode01Ip(String node01Ip) { + this.node01Ip = node01Ip; + } + + public String getNode02Ip() { + return node02Ip; + } + + public void setNode02Ip(String node02Ip) { + this.node02Ip = node02Ip; + } + + public String getNode03Ip() { + return node03Ip; + } + + public void setNode03Ip(String node03Ip) { + this.node03Ip = node03Ip; + } + + public String getNode04Ip() { + return node04Ip; + } + + public void setNode04Ip(String node04Ip) { + this.node04Ip = node04Ip; + } } } diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreLatestResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreLatestResponse.java new file mode 100644 index 0000000..392535f --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreLatestResponse.java @@ -0,0 +1,71 @@ +package com.cisd.tms.modules.backup.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "最近一次资源恢复结果响应") +public class ResourceRestoreLatestResponse { + + private String taskId; + private String backupId; + private String status; + private String phase; + private String message; + private String createdAt; + private String finishedAt; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java index 53b7878..c621668 100644 --- a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java +++ b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestorePrecheckResponse.java @@ -16,6 +16,7 @@ public class ResourceRestorePrecheckResponse { private Boolean keysetMatched; private Boolean signatureValid; private Boolean compatible; + private Boolean precheckPassed; private Boolean payloadDecrypted; private List warnings; private List blockingIssues; @@ -103,6 +104,14 @@ public class ResourceRestorePrecheckResponse { this.compatible = compatible; } + public Boolean getPrecheckPassed() { + return precheckPassed; + } + + public void setPrecheckPassed(Boolean precheckPassed) { + this.precheckPassed = precheckPassed; + } + public Boolean getPayloadDecrypted() { return payloadDecrypted; } diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java b/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java deleted file mode 100644 index d73aa35..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/dto/response/ResourceRestoreTaskDetailResponse.java +++ /dev/null @@ -1,261 +0,0 @@ -package com.cisd.tms.modules.backup.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -@Schema(description = "资源恢复任务详情响应") -public class ResourceRestoreTaskDetailResponse { - - private String taskId; - private String precheckId; - private String backupId; - private String status; - private String controlPhase; - private String stateSource; - private String sourceDeviceId; - private String sourceProductType; - private String targetProductType; - private String packageFileId; - private String packageName; - private Boolean packagePathVisible; - private String currentStepCode; - private Integer currentStepNo; - private String lastHeartbeatAt; - private String statusFilePath; - private Boolean orphaned; - private List cleanupWarnings; - private List warnings; - private List blockingIssues; - private List restorePlan; - private Object consistencyReport; - private String errorCode; - private String errorMessage; - private String createdBy; - private String createdAt; - private String startedAt; - private String finishedAt; - - public String getTaskId() { - return taskId; - } - - public void setTaskId(String taskId) { - this.taskId = taskId; - } - - public String getPrecheckId() { - return precheckId; - } - - public void setPrecheckId(String precheckId) { - this.precheckId = precheckId; - } - - public String getBackupId() { - return backupId; - } - - public void setBackupId(String backupId) { - this.backupId = backupId; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getControlPhase() { - return controlPhase; - } - - public void setControlPhase(String controlPhase) { - this.controlPhase = controlPhase; - } - - public String getStateSource() { - return stateSource; - } - - public void setStateSource(String stateSource) { - this.stateSource = stateSource; - } - - public String getSourceDeviceId() { - return sourceDeviceId; - } - - public void setSourceDeviceId(String sourceDeviceId) { - this.sourceDeviceId = sourceDeviceId; - } - - public String getSourceProductType() { - return sourceProductType; - } - - public void setSourceProductType(String sourceProductType) { - this.sourceProductType = sourceProductType; - } - - public String getTargetProductType() { - return targetProductType; - } - - public void setTargetProductType(String targetProductType) { - this.targetProductType = targetProductType; - } - - public String getPackageFileId() { - return packageFileId; - } - - public void setPackageFileId(String packageFileId) { - this.packageFileId = packageFileId; - } - - public String getPackageName() { - return packageName; - } - - public void setPackageName(String packageName) { - this.packageName = packageName; - } - - public Boolean getPackagePathVisible() { - return packagePathVisible; - } - - public void setPackagePathVisible(Boolean packagePathVisible) { - this.packagePathVisible = packagePathVisible; - } - - public String getCurrentStepCode() { - return currentStepCode; - } - - public void setCurrentStepCode(String currentStepCode) { - this.currentStepCode = currentStepCode; - } - - public Integer getCurrentStepNo() { - return currentStepNo; - } - - public void setCurrentStepNo(Integer currentStepNo) { - this.currentStepNo = currentStepNo; - } - - public String getLastHeartbeatAt() { - return lastHeartbeatAt; - } - - public void setLastHeartbeatAt(String lastHeartbeatAt) { - this.lastHeartbeatAt = lastHeartbeatAt; - } - - public String getStatusFilePath() { - return statusFilePath; - } - - public void setStatusFilePath(String statusFilePath) { - this.statusFilePath = statusFilePath; - } - - public Boolean getOrphaned() { - return orphaned; - } - - public void setOrphaned(Boolean orphaned) { - this.orphaned = orphaned; - } - - public List getCleanupWarnings() { - return cleanupWarnings; - } - - public void setCleanupWarnings(List cleanupWarnings) { - this.cleanupWarnings = cleanupWarnings; - } - - public List getWarnings() { - return warnings; - } - - public void setWarnings(List warnings) { - this.warnings = warnings; - } - - public List getBlockingIssues() { - return blockingIssues; - } - - public void setBlockingIssues(List blockingIssues) { - this.blockingIssues = blockingIssues; - } - - public List getRestorePlan() { - return restorePlan; - } - - public void setRestorePlan(List restorePlan) { - this.restorePlan = restorePlan; - } - - public Object getConsistencyReport() { - return consistencyReport; - } - - public void setConsistencyReport(Object consistencyReport) { - this.consistencyReport = consistencyReport; - } - - public String getErrorCode() { - return errorCode; - } - - public void setErrorCode(String errorCode) { - this.errorCode = errorCode; - } - - public String getErrorMessage() { - return errorMessage; - } - - public void setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - } - - public String getCreatedBy() { - return createdBy; - } - - public void setCreatedBy(String createdBy) { - this.createdBy = createdBy; - } - - public String getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } - - public String getStartedAt() { - return startedAt; - } - - public void setStartedAt(String startedAt) { - this.startedAt = startedAt; - } - - public String getFinishedAt() { - return finishedAt; - } - - public void setFinishedAt(String finishedAt) { - this.finishedAt = finishedAt; - } -} diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredFileBackupResource.java b/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredFileBackupResource.java index d1c7637..3f7082d 100644 --- a/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredFileBackupResource.java +++ b/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredFileBackupResource.java @@ -1,5 +1,7 @@ package com.cisd.tms.modules.backup.dto.support; +import java.util.List; + public class ConfiguredFileBackupResource { private String code; @@ -7,6 +9,8 @@ public class ConfiguredFileBackupResource { private String mode = "FILE"; private String path; private String restorePath; + private List productTypes = List.of(); + private List mqTypes = List.of(); private boolean required = true; public String getCode() { @@ -49,6 +53,22 @@ public class ConfiguredFileBackupResource { this.restorePath = restorePath; } + public java.util.List getProductTypes() { + return productTypes; + } + + public void setProductTypes(java.util.List productTypes) { + this.productTypes = productTypes; + } + + public java.util.List getMqTypes() { + return mqTypes; + } + + public void setMqTypes(java.util.List mqTypes) { + this.mqTypes = mqTypes; + } + public boolean isRequired() { return required; } diff --git a/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredRestoreCommand.java b/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredRestoreCommand.java new file mode 100644 index 0000000..d186fa3 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/dto/support/ConfiguredRestoreCommand.java @@ -0,0 +1,43 @@ +package com.cisd.tms.modules.backup.dto.support; + +import java.util.List; + +public class ConfiguredRestoreCommand { + + private String code; + private String command; + private boolean required = true; + private List productTypes = List.of(); + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public List getProductTypes() { + return productTypes; + } + + public void setProductTypes(List productTypes) { + this.productTypes = productTypes; + } +} diff --git a/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java b/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java deleted file mode 100644 index 9fea7b3..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/entity/ResourceTaskStepEntity.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.cisd.tms.modules.backup.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.cisd.tms.infrastructure.persistence.entity.BaseEntity; -import java.time.LocalDateTime; - -@TableName("tms_resource_task_step") -public class ResourceTaskStepEntity extends BaseEntity { - - private String taskId; - private String taskType; - private Integer stepNo; - private String stepCode; - private String status; - private String message; - private String logPath; - private Integer exitCode; - private LocalDateTime startTime; - private LocalDateTime finishTime; - - public String getTaskId() { - return taskId; - } - - public void setTaskId(String taskId) { - this.taskId = taskId; - } - - public String getTaskType() { - return taskType; - } - - public void setTaskType(String taskType) { - this.taskType = taskType; - } - - public Integer getStepNo() { - return stepNo; - } - - public void setStepNo(Integer stepNo) { - this.stepNo = stepNo; - } - - public String getStepCode() { - return stepCode; - } - - public void setStepCode(String stepCode) { - this.stepCode = stepCode; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - public String getLogPath() { - return logPath; - } - - public void setLogPath(String logPath) { - this.logPath = logPath; - } - - public Integer getExitCode() { - return exitCode; - } - - public void setExitCode(Integer exitCode) { - this.exitCode = exitCode; - } - - public LocalDateTime getStartTime() { - return startTime; - } - - public void setStartTime(LocalDateTime startTime) { - this.startTime = startTime; - } - - public LocalDateTime getFinishTime() { - return finishTime; - } - - public void setFinishTime(LocalDateTime finishTime) { - this.finishTime = finishTime; - } -} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java deleted file mode 100644 index 158d7bf..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceControlPhase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.cisd.tms.modules.backup.enums; - -/** - * 恢复任务控制阶段。 - */ -public enum ResourceControlPhase { - TMS_PRECHECK, - JAVA_EXTRACT, - SHELL_APPLY, - DONE -} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java deleted file mode 100644 index 71fdd20..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceStepStatus.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cisd.tms.modules.backup.enums; - -/** - * 资源任务步骤状态。 - */ -public enum ResourceStepStatus { - PENDING, - RUNNING, - SUCCESS, - SUCCESS_WITH_WARNINGS, - FAILED, - SKIPPED -} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java deleted file mode 100644 index 5c71880..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskStatus.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.cisd.tms.modules.backup.enums; - -/** - * 资源备份/恢复任务主状态。 - */ -public enum ResourceTaskStatus { - PENDING, - PRECHECKING, - PRECHECK_FAILED, - READY, - APPLYING, - VERIFYING, - SUCCESS, - FAILED, - CANCELLED -} diff --git a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java b/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java deleted file mode 100644 index 008b198..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/enums/ResourceTaskType.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.cisd.tms.modules.backup.enums; - -/** - * 资源任务类型。 - */ -public enum ResourceTaskType { - RESOURCE_BACKUP, - RESOURCE_RESTORE -} diff --git a/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java b/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java deleted file mode 100644 index 1e78738..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/mapper/ResourceTaskStepMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.cisd.tms.modules.backup.mapper; - -import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX; -import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; -import java.util.List; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -@Mapper -public interface ResourceTaskStepMapper extends BaseMapperX { - - @Select( - "SELECT id, task_id, task_type, step_no, step_code, status, message, log_path, exit_code, " - + "create_time, update_time, start_time, finish_time " - + "FROM tms_resource_task_step WHERE task_id = #{taskId} ORDER BY step_no ASC" - ) - List selectByTaskIdOrderByStepNo(@Param("taskId") String taskId); -} diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java b/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java index c91383e..83b830d 100644 --- a/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/DatabaseBackupCollector.java @@ -6,6 +6,8 @@ public interface DatabaseBackupCollector { CollectedDatabaseDump collectCmepDatabaseDump(); + CollectedDatabaseDump collectDatabaseDump(String databaseName, String fileName); + class CollectedDatabaseDump { private String databaseName; diff --git a/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java b/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java index f9fffcb..2471dbb 100644 --- a/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java +++ b/src/main/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollector.java @@ -34,7 +34,7 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector { @Override public CollectedDatabaseDump collectTmsDatabaseDump() { MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getTmsDatabaseName(), "TMS"); - return collectDatabaseDump(target, "TMS.sql"); + return collectDatabaseDump(target, "TMS.sql", resourceBackupProperties.getTmsDbExcludedTables()); } @Override @@ -43,7 +43,21 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector { return collectDatabaseDump(target, "CMEP.sql"); } + @Override + public CollectedDatabaseDump collectDatabaseDump(String databaseName, String fileName) { + MysqlConnectionTarget target = resolveTarget(databaseName, databaseName); + return collectDatabaseDump(target, fileName); + } + private CollectedDatabaseDump collectDatabaseDump(MysqlConnectionTarget target, String fileName) { + return collectDatabaseDump(target, fileName, List.of()); + } + + private CollectedDatabaseDump collectDatabaseDump( + MysqlConnectionTarget target, + String fileName, + List excludedTables + ) { List command = new ArrayList<>(); command.add(resourceBackupProperties.getMysqldumpPath()); command.add("--host=" + target.host()); @@ -52,8 +66,14 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector { if (!target.password().isEmpty()) { command.add("--password=" + target.password()); } - command.add("--single-transaction"); +// command.add("--single-transaction"); command.add("--skip-lock-tables"); + for (String table : excludedTables == null ? List.of() : excludedTables) { + String ignoreTable = normalizeIgnoreTable(target.databaseName(), table); + if (!ignoreTable.isEmpty()) { + command.add("--ignore-table=" + ignoreTable); + } + } command.add("--databases"); command.add(target.databaseName()); @@ -126,6 +146,17 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector { return value == null ? "" : value.trim(); } + private static String normalizeIgnoreTable(String databaseName, String table) { + String value = trim(table); + if (value.isEmpty()) { + return ""; + } + if (value.contains(".")) { + return value; + } + return trim(databaseName) + "." + value; + } + private record MysqlConnectionTarget(String host, int port, String username, String password, String databaseName) { } } 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 ed59b40..7cf423a 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 @@ -14,8 +14,10 @@ import java.io.InputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.time.LocalDateTime; import java.util.Comparator; import java.util.LinkedHashMap; @@ -271,8 +273,6 @@ public class ResourcePackageServiceImpl implements ResourcePackageService { payloadManifest.put("mqType", initTask.getMqType()); payloadManifest.put("orgCode", initTask.getOrgCode()); payloadManifest.put("remark", request == null ? null : request.getRemark()); - payloadManifest.put("includeUpgradeHistory", request != null && Boolean.TRUE.equals(request.getIncludeUpgradeHistory())); - payloadManifest.put("includeAuditBackups", request != null && Boolean.TRUE.equals(request.getIncludeAuditBackups())); List> resourceEntries = new ArrayList<>(); int collectedCount = 0; @@ -332,6 +332,9 @@ 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()); + if ("GLOB".equalsIgnoreCase(trim(item.getMode()))) { + return collectGlobFileItem(zipOutputStream, item, resourceEntry); + } // 文件类资源在备份端只允许采集真实存在的文件/目录;恢复端还会再次校验 restorePath 白名单。 Path resolvedPath = toResolvedPath(item.getResolvedPath()); if (resolvedPath == null || !Files.exists(resolvedPath)) { @@ -376,6 +379,51 @@ public class ResourcePackageServiceImpl implements ResourcePackageService { return archivedFiles.size(); } + private int collectGlobFileItem( + ZipOutputStream zipOutputStream, + DiscoveredResourceItem item, + Map resourceEntry + ) throws IOException { + Path globParent = globParent(item.getResolvedPath()); + if (globParent == null || !Files.isDirectory(globParent)) { + assertOptionalResource(item, "PATH_NOT_FOUND"); + resourceEntry.put("collected", Boolean.FALSE); + resourceEntry.put("skipReason", "PATH_NOT_FOUND"); + return 0; + } + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + trim(item.getResolvedPath())); + List matchedFiles; + try (Stream pathStream = Files.list(globParent)) { + matchedFiles = pathStream + .filter(Files::isRegularFile) + .filter(path -> matcher.matches(path.normalize())) + .sorted(Comparator.naturalOrder()) + .toList(); + } + if (matchedFiles.isEmpty()) { + assertOptionalResource(item, "GLOB_NOT_MATCHED"); + resourceEntry.put("collected", Boolean.FALSE); + resourceEntry.put("skipReason", "GLOB_NOT_MATCHED"); + return 0; + } + List> archivedFiles = new ArrayList<>(); + for (Path matchedFile : matchedFiles) { + Map fileEntry = writeRegularFileEntry( + zipOutputStream, + item, + matchedFile, + matchedFile.getFileName().toString() + ); + String restoreDir = trim(item.getRestorePath()).isEmpty() ? globParent.toString() : trim(item.getRestorePath()); + fileEntry.put("restorePath", Path.of(restoreDir).resolve(matchedFile.getFileName()).normalize().toString()); + archivedFiles.add(fileEntry); + } + resourceEntry.put("collected", Boolean.TRUE); + resourceEntry.put("fileCount", archivedFiles.size()); + resourceEntry.put("files", archivedFiles); + return archivedFiles.size(); + } + private int collectDatabaseItem( ZipOutputStream zipOutputStream, DiscoveredResourceItem item, @@ -466,6 +514,16 @@ public class ResourcePackageServiceImpl implements ResourcePackageService { if ("CMEP_DB".equalsIgnoreCase(resourceCode) || "CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) { return databaseBackupCollector.collectCmepDatabaseDump(); } + if ("ORG_DB".equalsIgnoreCase(resourceCode) || "ORG_DB_DUMP".equalsIgnoreCase(resourceCode)) { + String databaseName = trim(item.getDatabaseName()).isEmpty() ? trim(item.getResolvedPath()) : trim(item.getDatabaseName()); + if (databaseName.isEmpty()) { + return null; + } + String fileName = trim(item.getEntryPath()).isEmpty() + ? databaseName + ".sql" + : Path.of(trim(item.getEntryPath())).getFileName().toString(); + return databaseBackupCollector.collectDatabaseDump(databaseName, fileName); + } return null; } @@ -531,6 +589,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService { return Path.of(value).normalize(); } + private static Path globParent(String pattern) { + String value = trim(pattern); + if (value.isEmpty()) { + return null; + } + int slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + if (slash < 0) { + return Path.of(".").normalize(); + } + return Path.of(value.substring(0, slash)).normalize(); + } + private static T writeUnchecked(CheckedSupplier supplier) { try { return supplier.get(); diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java deleted file mode 100644 index 10b93bf..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceTaskStepRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.cisd.tms.modules.backup.repository; - -import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; -import java.util.List; - -public interface ResourceTaskStepRepository { - - void save(ResourceTaskStepEntity entity); - - List findByTaskIdOrderByStepNo(String taskId); -} diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java deleted file mode 100644 index c368067..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceTaskStepRepositoryImpl.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.cisd.tms.modules.backup.repository.impl; - -import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; -import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper; -import com.cisd.tms.modules.backup.repository.ResourceTaskStepRepository; -import java.util.List; -import org.springframework.stereotype.Repository; - -@Repository -public class ResourceTaskStepRepositoryImpl implements ResourceTaskStepRepository { - - private final ResourceTaskStepMapper resourceTaskStepMapper; - - public ResourceTaskStepRepositoryImpl(ResourceTaskStepMapper resourceTaskStepMapper) { - this.resourceTaskStepMapper = resourceTaskStepMapper; - } - - @Override - public void save(ResourceTaskStepEntity entity) { - resourceTaskStepMapper.insert(entity); - } - - @Override - public List findByTaskIdOrderByStepNo(String taskId) { - List steps = resourceTaskStepMapper.selectByTaskIdOrderByStepNo(taskId); - return steps == null ? List.of() : steps; - } -} diff --git a/src/main/java/com/cisd/tms/modules/backup/restore/ActiveRestoreStateStore.java b/src/main/java/com/cisd/tms/modules/backup/restore/ActiveRestoreStateStore.java new file mode 100644 index 0000000..2b2f748 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/restore/ActiveRestoreStateStore.java @@ -0,0 +1,288 @@ +package com.cisd.tms.modules.backup.restore; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class ActiveRestoreStateStore { + + private static final String ACTIVE_FILE_NAME = "active-restore.json"; + + private final ResourceBackupProperties properties; + private final ObjectMapper objectMapper; + + public ActiveRestoreStateStore(ResourceBackupProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + public void write(ResourceRestoreTaskEntity entity, String status) { + ActiveRestoreSnapshot snapshot = fromEntity(entity, status); + Path path = activeFilePath(); + try { + Files.createDirectories(path.getParent()); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), snapshot); + } catch (IOException ex) { + throw new IllegalStateException("写入当前资源恢复状态失败:" + path, ex); + } + } + + public Optional read() { + Path path = activeFilePath(); + if (!Files.isRegularFile(path)) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(path.toFile(), ActiveRestoreSnapshot.class)); + } catch (IOException ex) { + throw new IllegalStateException("读取当前资源恢复状态失败:" + path, ex); + } + } + + private Path activeFilePath() { + return Path.of(properties.getRestoreTaskRootDir()).resolve(ACTIVE_FILE_NAME).normalize(); + } + + private static ActiveRestoreSnapshot fromEntity(ResourceRestoreTaskEntity entity, String status) { + return new ActiveRestoreSnapshot( + entity.getTaskId(), + entity.getPrecheckId(), + entity.getBackupId(), + status, + entity.getSourceDeviceId(), + entity.getSourceProductType(), + entity.getTargetProductType(), + entity.getPackageFileId(), + entity.getPackagePath(), + entity.getStagingDir(), + entity.getRunnerStatePath(), + entity.getStorageEncKeyFingerprint(), + entity.getAuthenticityKeyFingerprint(), + entity.getRestorePlanJson(), + entity.getCreateBy(), + text(entity.getCreateTime()), + text(entity.getStartTime()), + text(entity.getFinishTime()) + ); + } + + private static String text(LocalDateTime value) { + return value == null ? null : value.toString(); + } + + public static class ActiveRestoreSnapshot { + + private String taskId; + private String precheckId; + private String backupId; + private String status; + private String sourceDeviceId; + private String sourceProductType; + private String targetProductType; + private String packageFileId; + private String packagePath; + private String stagingDir; + private String runnerStatePath; + private String storageEncKeyFingerprint; + private String authenticityKeyFingerprint; + private String restorePlanJson; + private String createBy; + private String createdAt; + private String startedAt; + private String finishedAt; + + public ActiveRestoreSnapshot() { + } + + public ActiveRestoreSnapshot( + String taskId, + String precheckId, + String backupId, + String status, + String sourceDeviceId, + String sourceProductType, + String targetProductType, + String packageFileId, + String packagePath, + String stagingDir, + String runnerStatePath, + String storageEncKeyFingerprint, + String authenticityKeyFingerprint, + String restorePlanJson, + String createBy, + String createdAt, + String startedAt, + String finishedAt + ) { + this.taskId = taskId; + this.precheckId = precheckId; + this.backupId = backupId; + this.status = status; + this.sourceDeviceId = sourceDeviceId; + this.sourceProductType = sourceProductType; + this.targetProductType = targetProductType; + this.packageFileId = packageFileId; + this.packagePath = packagePath; + this.stagingDir = stagingDir; + this.runnerStatePath = runnerStatePath; + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + this.restorePlanJson = restorePlanJson; + this.createBy = createBy; + this.createdAt = createdAt; + this.startedAt = startedAt; + this.finishedAt = finishedAt; + } + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getPrecheckId() { + return precheckId; + } + + public void setPrecheckId(String precheckId) { + this.precheckId = precheckId; + } + + public String getBackupId() { + return backupId; + } + + public void setBackupId(String backupId) { + this.backupId = backupId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getSourceDeviceId() { + return sourceDeviceId; + } + + public void setSourceDeviceId(String sourceDeviceId) { + this.sourceDeviceId = sourceDeviceId; + } + + public String getSourceProductType() { + return sourceProductType; + } + + public void setSourceProductType(String sourceProductType) { + this.sourceProductType = sourceProductType; + } + + public String getTargetProductType() { + return targetProductType; + } + + public void setTargetProductType(String targetProductType) { + this.targetProductType = targetProductType; + } + + public String getPackageFileId() { + return packageFileId; + } + + public void setPackageFileId(String packageFileId) { + this.packageFileId = packageFileId; + } + + public String getPackagePath() { + return packagePath; + } + + public void setPackagePath(String packagePath) { + this.packagePath = packagePath; + } + + public String getStagingDir() { + return stagingDir; + } + + public void setStagingDir(String stagingDir) { + this.stagingDir = stagingDir; + } + + public String getRunnerStatePath() { + return runnerStatePath; + } + + public void setRunnerStatePath(String runnerStatePath) { + this.runnerStatePath = runnerStatePath; + } + + public String getStorageEncKeyFingerprint() { + return storageEncKeyFingerprint; + } + + public void setStorageEncKeyFingerprint(String storageEncKeyFingerprint) { + this.storageEncKeyFingerprint = storageEncKeyFingerprint; + } + + public String getAuthenticityKeyFingerprint() { + return authenticityKeyFingerprint; + } + + public void setAuthenticityKeyFingerprint(String authenticityKeyFingerprint) { + this.authenticityKeyFingerprint = authenticityKeyFingerprint; + } + + public String getRestorePlanJson() { + return restorePlanJson; + } + + public void setRestorePlanJson(String restorePlanJson) { + this.restorePlanJson = restorePlanJson; + } + + public String getCreateBy() { + return createBy; + } + + public void setCreateBy(String createBy) { + this.createBy = createBy; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + } +} 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 df953f9..d13c65c 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 @@ -1,6 +1,7 @@ package com.cisd.tms.modules.backup.restore; import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand; import java.io.IOException; import java.net.URI; import java.nio.file.Files; @@ -35,10 +36,24 @@ public class LightweightRestoreApplierLauncher { } public void launch(Path workDir) { + launch(workDir, ""); + } + + public void launch(Path workDir, String targetProductType) { Path normalizedWorkDir = workDir.toAbsolutePath().normalize(); // shell 脚本需要数据库连接、恢复脚本路径、健康检查地址等运行时参数。 // 这些信息来自当前 TMS 配置,写入任务目录下的 restore-env.sh,避免在命令行暴露复杂参数。 writeRestoreEnv(normalizedWorkDir); + writeCommandPlan( + normalizedWorkDir.resolve("restore-stop-commands.tsv"), + properties.getRestoreStopCommands(), + targetProductType + ); + writeCommandPlan( + normalizedWorkDir.resolve("restore-start-commands.tsv"), + properties.getRestoreStartCommands(), + targetProductType + ); Path logFile = normalizedWorkDir.resolve("restore-launch.log"); // Java 阶段到这里已经完成包验签、解密、payload 展开和用户密钥恢复。 // 后续进入停机恢复脚本,当前 TMS 服务可能被脚本停止。 @@ -54,6 +69,35 @@ public class LightweightRestoreApplierLauncher { } } + private void writeCommandPlan(Path planFile, List commands, String targetProductType) { + try { + StringBuilder builder = new StringBuilder(); + for (ConfiguredRestoreCommand command : commands == null ? List.of() : commands) { + String shellCommand = trim(command.getCommand()); + if (shellCommand.isEmpty() || !matchesProductType(command.getProductTypes(), targetProductType)) { + continue; + } + builder.append(defaultIfBlank(command.getCode(), "COMMAND")) + .append('\t') + .append(command.isRequired()) + .append('\t') + .append(shellCommand.replace('\t', ' ')) + .append(System.lineSeparator()); + } + Files.writeString(planFile, builder.toString()); + } catch (IOException ex) { + throw new IllegalStateException("写入恢复停启命令计划失败:" + planFile, ex); + } + } + + private static boolean matchesProductType(List productTypes, String targetProductType) { + if (productTypes == null || productTypes.isEmpty()) { + return true; + } + String target = trim(targetProductType); + return productTypes.stream().anyMatch(item -> trim(item).equalsIgnoreCase(target)); + } + private void writeRestoreEnv(Path workDir) { try { Files.createDirectories(workDir); @@ -71,6 +115,7 @@ public class LightweightRestoreApplierLauncher { values.put("DB_PASSWORD", trim(datasourcePassword)); values.put("HEALTH_CHECK_URL", trim(properties.getHealthCheckUrl())); values.put("HEALTH_CHECK_TIMEOUT_SECONDS", String.valueOf(properties.getHealthCheckTimeoutSeconds())); + values.put("RESTORE_APPLY_DELAY_SECONDS", "3"); StringBuilder builder = new StringBuilder("# generated by TMS resource restore\n"); for (Map.Entry entry : values.entrySet()) { @@ -105,6 +150,10 @@ public class LightweightRestoreApplierLauncher { return "'" + trim(value).replace("'", "'\\''") + "'"; } + private static String defaultIfBlank(String candidate, String fallback) { + return trim(candidate).isEmpty() ? fallback : candidate; + } + private static String trim(String value) { return value == null ? "" : value.trim(); } diff --git a/src/main/java/com/cisd/tms/modules/backup/restore/ProcessBuilderRestoreCommandInvoker.java b/src/main/java/com/cisd/tms/modules/backup/restore/ProcessBuilderRestoreCommandInvoker.java index c3937ec..9dc5ae4 100644 --- a/src/main/java/com/cisd/tms/modules/backup/restore/ProcessBuilderRestoreCommandInvoker.java +++ b/src/main/java/com/cisd/tms/modules/backup/restore/ProcessBuilderRestoreCommandInvoker.java @@ -24,13 +24,10 @@ public class ProcessBuilderRestoreCommandInvoker implements RestoreCommandInvoke if (logFile != null) { pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile())); } - Process process = pb.start(); - return process.waitFor(); + pb.start(); + return 0; } catch (IOException ex) { throw new IllegalStateException("启动恢复命令失败:" + String.join(" ", command), ex); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("restore command 被中断: " + String.join(" ", command), ex); } } } diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java index 18ceddf..2e00c8f 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourceRestoreService.java @@ -6,4 +6,6 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; public interface ResourceRestoreService { CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request); + + CreateResourceRestoreResponse applyRestoreTask(String taskId); } diff --git a/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java b/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java index 8302b82..290fd95 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryService.java @@ -1,15 +1,8 @@ package com.cisd.tms.modules.backup.service; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; -import java.util.List; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse; public interface ResourceTaskQueryService { - ResourceRestoreTaskDetailResponse getRestoreTask(String taskId); - - List getRestoreTaskSteps(String taskId); - - ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo); + ResourceRestoreLatestResponse getLatestRestore(); } 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 48f6fa9..e18bd9c 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 @@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.dto.response.ResourceKeysetStatusResponse; import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.crypto.ResourceCryptoKeyConstants; import com.cisd.tms.modules.backup.packagex.RequiredBackupResourceMissingException; import com.cisd.tms.modules.backup.packagex.ResourcePackageResult; import com.cisd.tms.modules.backup.packagex.ResourcePackageService; @@ -39,7 +40,6 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { private final ResourcePackageService resourcePackageService; private final ResourceBackupTaskRepository resourceBackupTaskRepository; private final ResourceTaskIdGenerator resourceTaskIdGenerator; - private final com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties; private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard; @Override @@ -50,9 +50,9 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { response.setIkReady(false); response.setStorageEncKeyReady(false); response.setAuthenticityKeyReady(false); - response.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex()); - response.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex()); - response.setSource("CONFIGURED_KEY_INDEX"); + response.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT); + response.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT); + response.setSource("FIXED_KEY_INDEX"); return response; } @@ -80,6 +80,8 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { entity.setSourceDeviceId(resolveSourceDeviceId()); entity.setRemark(request == null ? null : request.getRemark()); entity.setCreateBy("SYSTEM"); + entity.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT); + entity.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setStartTime(now); 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 e58380b..1e8c179 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 @@ -5,7 +5,7 @@ import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.exception.BizException; import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; -import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.crypto.ResourceCryptoKeyConstants; import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult; import com.cisd.tms.modules.backup.packagex.ResourcePackageService; import com.cisd.tms.modules.backup.service.ResourcePrecheckService; @@ -30,7 +30,6 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService { private final ResourcePrecheckStateStore resourcePrecheckStateStore; private final ResourceTaskIdGenerator resourceTaskIdGenerator; private final CisdPresetProperties cisdPresetProperties; - private final ResourceBackupProperties resourceBackupProperties; @Override public ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request) { @@ -61,6 +60,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService { response.setPayloadDecrypted(false); response.setWarnings(List.of()); response.setBlockingIssues(List.of()); + response.setPrecheckPassed(isPrecheckPassed(response)); // 第一版恢复计划固定为两段:Java 负责验签/解密/展开/用户密钥恢复,shell 负责停服后的落盘恢复。 response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY")); response.setRestoreScopeSummary(buildScopeSummary(inspectResult)); @@ -84,11 +84,11 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService { } private String expectedStorageFingerprint() { - return "USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex(); + return ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT; } private String expectedAuthenticityFingerprint() { - return "USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex(); + return ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT; } private static java.util.Map buildScopeSummary(ResourcePackageInspectResult inspectResult) { @@ -106,4 +106,11 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService { } return targetProductType.equalsIgnoreCase(sourceProductType); } + + private static boolean isPrecheckPassed(ResourceRestorePrecheckResponse response) { + return Boolean.TRUE.equals(response.getKeysetMatched()) + && Boolean.TRUE.equals(response.getSignatureValid()) + && Boolean.TRUE.equals(response.getCompatible()) + && (response.getBlockingIssues() == null || response.getBlockingIssues().isEmpty()); + } } 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 77d1707..4821630 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 @@ -10,19 +10,25 @@ 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.ActiveRestoreStateStore; import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher; import com.cisd.tms.modules.backup.service.ResourceRestoreService; import com.cisd.tms.modules.backup.support.ResourcePrecheckContext; import com.cisd.tms.modules.backup.support.ResourcePrecheckStateStore; import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.crypto.ResourceCryptoKeyConstants; import com.cisd.tms.modules.file.entity.FileRecordEntity; import com.cisd.tms.modules.file.repository.FileRecordRepository; +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.time.DateTimeException; import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,6 +46,8 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { private final ResourceUserKeyRestoreService resourceUserKeyRestoreService; private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher; private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard; + private final ObjectMapper objectMapper; + private final ActiveRestoreStateStore activeRestoreStateStore; @Override @@ -47,9 +55,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { // 恢复会导入用户密钥、覆盖配置文件、导入数据库并停启服务, // 必须在入口处和其它高风险任务互斥。 resourceTaskAdmissionGuard.assertCanStartRestore(); - if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) { - throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险"); - } + validateTargetNodes(request); // 恢复创建必须消费预检结果,确保“用户确认的 backupId”与预检看到的是同一个包。 ResourcePrecheckContext context = resourcePrecheckStateStore.find(request.getPrecheckId()) .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在")); @@ -71,6 +77,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { createDirectories(taskDir); // Java 阶段完成包的完整验签、解密和 payload 展开,并生成 shell 可消费的 TSV 计划文件。 ResourcePayloadExtractionResult extractionResult = resourcePayloadExtractor.extract(Path.of(packagePath), taskDir); + writeTargetNodeContext(taskDir, request); // 用户密钥恢复必须放在 shell 停服/导库之前:数据库和证书数据恢复后会引用这些 keyIdx。 // 如果这里失败,不保存 APPLYING 任务、不启动 shell,避免出现“数据已覆盖但密钥不可用”的半恢复状态。 resourceUserKeyRestoreService.recover(extractionResult.getPayloadDir()); @@ -79,8 +86,8 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { entity.setTaskId(taskId); entity.setPrecheckId(precheck.getPrecheckId()); entity.setBackupId(precheck.getBackupId()); - // 轻量恢复模型下,Java 已完成包验签/解密/展开,后续由 shell apply 脚本接管实际覆盖/导入。 - entity.setStatus("APPLYING"); + // 创建接口只准备恢复并返回任务号,避免在 HTTP 响应返回前停掉当前 TMS。 + entity.setStatus("READY_TO_APPLY"); entity.setSourceDeviceId("UNKNOWN"); entity.setSourceProductType(precheck.getSourceProductType()); entity.setTargetProductType(precheck.getTargetProductType()); @@ -88,17 +95,17 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { entity.setPackagePath(packagePath); entity.setStagingDir(taskDir.resolve("payload").toString()); entity.setRunnerStatePath(taskDir.resolve("status.json").toString()); - entity.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex()); - entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex()); + entity.setStorageEncKeyFingerprint(ResourceCryptoKeyConstants.STORAGE_ENC_KEY_FINGERPRINT); + entity.setAuthenticityKeyFingerprint(ResourceCryptoKeyConstants.AUTHENTICITY_KEY_FINGERPRINT); entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]"); entity.setCreateBy("SYSTEM"); entity.setCreateTime(now); entity.setUpdateTime(now); - // 只有 Java 解包和用户密钥恢复都成功后,任务才进入 APPLYING。 - // 后续状态由 shell 写 status.json,查询接口会读取该状态文件展示实时恢复进度。 + // 只有 Java 解包和用户密钥恢复都成功后,任务才进入 READY_TO_APPLY。 + // 前端一次确认后可连续调用 create 和 apply;shell 只在 apply 阶段启动, + // 避免创建接口返回前停掉当前 TMS。 resourceRestoreTaskRepository.save(entity); - // shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。 - lightweightRestoreApplierLauncher.launch(taskDir); + activeRestoreStateStore.write(entity, entity.getStatus()); CreateResourceRestoreResponse response = new CreateResourceRestoreResponse(); response.setTaskId(taskId); @@ -107,6 +114,36 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { return response; } + @Override + public CreateResourceRestoreResponse applyRestoreTask(String taskId) { + ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) + .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId))); + String status = trim(entity.getStatus()); + if ("SUCCESS".equalsIgnoreCase(status) || "FAILED".equalsIgnoreCase(status)) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务已结束:" + trim(taskId)); + } + if (!"READY_TO_APPLY".equalsIgnoreCase(status) && !"APPLYING".equalsIgnoreCase(status)) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务状态不允许执行:" + status); + } + LocalDateTime now = LocalDateTime.now(); + entity.setStatus("APPLYING"); + if (entity.getStartTime() == null) { + entity.setStartTime(now); + } + entity.setUpdateTime(now); + resourceRestoreTaskRepository.update(entity); + activeRestoreStateStore.write(entity, entity.getStatus()); + Path taskDir = resolveTaskDir(entity); + // shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。 + lightweightRestoreApplierLauncher.launch(taskDir, entity.getTargetProductType()); + + CreateResourceRestoreResponse response = new CreateResourceRestoreResponse(); + response.setTaskId(entity.getTaskId()); + response.setStatus(entity.getStatus()); + response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString()); + return response; + } + private static void createDirectories(Path taskDir) { try { Files.createDirectories(taskDir); @@ -115,10 +152,71 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { } } + private void writeTargetNodeContext(Path taskDir, CreateResourceRestoreRequest request) { + Path contextPath = taskDir.resolve("restore-context.json").normalize(); + try { + Map context = Files.isRegularFile(contextPath) + ? objectMapper.readValue(contextPath.toFile(), new TypeReference>() { + }) + : new LinkedHashMap<>(); + context.put("targetDeployMode", trim(request.getDeployMode()).toUpperCase()); + context.put("targetNodes", targetNodes(request.getNodes())); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(contextPath.toFile(), context); + } catch (IOException ex) { + throw new IllegalStateException("写入恢复目标节点上下文失败:" + contextPath, ex); + } + } + + private static Map targetNodes(CreateResourceRestoreRequest.Nodes nodes) { + Map values = new LinkedHashMap<>(); + putIfNotBlank(values, "node01Ip", nodes.getNode01Ip()); + putIfNotBlank(values, "node02Ip", nodes.getNode02Ip()); + putIfNotBlank(values, "node03Ip", nodes.getNode03Ip()); + putIfNotBlank(values, "node04Ip", nodes.getNode04Ip()); + return values; + } + + private static void putIfNotBlank(Map values, String key, String value) { + String text = trim(value); + if (!text.isEmpty()) { + values.put(key, text); + } + } + private static String trim(String value) { return value == null ? "" : value.trim(); } + private static Path resolveTaskDir(ResourceRestoreTaskEntity entity) { + String runnerStatePath = trim(entity.getRunnerStatePath()); + if (runnerStatePath.isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务状态文件路径为空"); + } + return Path.of(runnerStatePath).normalize().getParent(); + } + + private static void validateTargetNodes(CreateResourceRestoreRequest request) { + String deployMode = trim(request.getDeployMode()).toUpperCase(); + CreateResourceRestoreRequest.Nodes nodes = request.getNodes(); + if (nodes == null) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes不能为空"); + } + if (trim(nodes.getNode01Ip()).isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node01Ip不能为空"); + } + if (("DUAL".equals(deployMode) || "QUAD".equals(deployMode)) && trim(nodes.getNode02Ip()).isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node02Ip不能为空"); + } + if ("QUAD".equals(deployMode)) { + if (trim(nodes.getNode03Ip()).isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node03Ip不能为空"); + } + if (trim(nodes.getNode04Ip()).isEmpty()) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "nodes.node04Ip不能为空"); + } + } + } + private static void validatePrecheck(ResourceRestorePrecheckResponse precheck) { if (precheck == null) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在"); 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 05d76ea..0c76262 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 @@ -18,7 +18,7 @@ import org.springframework.stereotype.Component; public class ResourceTaskAdmissionGuard { private static final Set ACTIVE_BACKUP_STATUSES = Set.of("RUNNING"); - private static final Set ACTIVE_RESTORE_STATUSES = Set.of("APPLYING", "RUNNING", "HANDOFF", "PENDING"); + private static final Set ACTIVE_RESTORE_STATUSES = Set.of("READY_TO_APPLY", "APPLYING", "RUNNING", "HANDOFF", "PENDING"); private final ResourceBackupTaskRepository resourceBackupTaskRepository; private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java index 87035cd..d7c148f 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskQueryServiceImpl.java @@ -1,20 +1,17 @@ package com.cisd.tms.modules.backup.service.impl; -import com.cisd.tms.common.enums.ErrorCode; -import com.cisd.tms.common.exception.BizException; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse; import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore; import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader; import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader.RestoreStatusSnapshot; import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,160 +21,144 @@ import org.springframework.stereotype.Service; public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService { private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; - private final ObjectMapper objectMapper; private final RestoreStatusFileReader restoreStatusFileReader; + private final ActiveRestoreStateStore activeRestoreStateStore; @Override - public ResourceRestoreTaskDetailResponse getRestoreTask(String taskId) { - ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) - .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId))); - ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse(); - response.setTaskId(entity.getTaskId()); - response.setPrecheckId(entity.getPrecheckId()); - response.setBackupId(entity.getBackupId()); - response.setStatus(entity.getStatus()); - response.setControlPhase("PRECHECKED"); - response.setStateSource("DB"); - response.setSourceDeviceId(entity.getSourceDeviceId()); - response.setSourceProductType(entity.getSourceProductType()); - response.setTargetProductType(entity.getTargetProductType()); - response.setPackageFileId(entity.getPackageFileId()); - response.setPackagePathVisible(Boolean.FALSE); - response.setStatusFilePath(entity.getRunnerStatePath()); - response.setRestorePlan(readStringList(entity.getRestorePlanJson())); - response.setConsistencyReport(readObject(entity.getConsistencyReportJson())); - response.setErrorCode(entity.getErrorCode()); - response.setErrorMessage(entity.getErrorMessage()); - response.setCreatedBy(entity.getCreateBy()); - response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString()); - response.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString()); - response.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString()); - mergeLightweightStatus(response, entity); - return response; - } - - @Override - public List getRestoreTaskSteps(String taskId) { - ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) - .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId))); - List restorePlan = readStringList(entity.getRestorePlanJson()); - Path taskDir = resolveTaskDir(entity); - RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(taskDir); - List responses = new ArrayList<>(); - for (int i = 0; i < restorePlan.size(); i++) { - String stepCode = restorePlan.get(i); - int stepNo = i + 1; - ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse(); - item.setStepNo(stepNo); - item.setStepCode(stepCode); - applyLightweightStepState(item, snapshot, stepCode); - responses.add(item); + public ResourceRestoreLatestResponse getLatestRestore() { + Optional active = activeRestoreStateStore.read(); + if (active.isPresent()) { + return latestFromActive(active.get()); } - return responses; - } - - @Override - public ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo) { - ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId)) - .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId))); - List restorePlan = readStringList(entity.getRestorePlanJson()); - String stepCode = stepNo > 0 && stepNo <= restorePlan.size() ? restorePlan.get(stepNo - 1) : ""; - RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity)); - ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse(); - response.setTaskId(trim(taskId)); - response.setStepNo(stepNo); - response.setStepCode(stepCode); - response.setContent(snapshot.log()); - response.setTruncated(false); - return response; + return latestFromDatabase(); } private static String trim(String value) { return value == null ? "" : value.trim(); } - private void mergeLightweightStatus(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) { - RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity)); - if (trim(snapshot.status()).isEmpty()) { + private ResourceRestoreLatestResponse latestFromActive(ActiveRestoreStateStore.ActiveRestoreSnapshot active) { + RestoreStatusSnapshot status = restoreStatusFileReader.read(resolveTaskDir(active.getRunnerStatePath())); + String latestStatus = defaultIfBlank(status.status(), active.getStatus()); + repairMissingTask(active, status, latestStatus); + ResourceRestoreLatestResponse response = new ResourceRestoreLatestResponse(); + response.setTaskId(active.getTaskId()); + response.setBackupId(active.getBackupId()); + response.setStatus(latestStatus); + response.setPhase(status.phase()); + response.setMessage(displayMessage(status.message(), latestStatus)); + response.setCreatedAt(active.getCreatedAt()); + response.setFinishedAt(isTerminal(latestStatus) ? defaultIfBlank(status.updatedAt(), active.getFinishedAt()) : null); + return response; + } + + private ResourceRestoreLatestResponse latestFromDatabase() { + ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findAll().stream() + .filter(item -> !trim(item.getTaskId()).isEmpty()) + .max(Comparator.comparing(item -> item.getCreateTime() == null ? LocalDateTime.MIN : item.getCreateTime())) + .orElse(null); + ResourceRestoreLatestResponse response = new ResourceRestoreLatestResponse(); + if (entity == null) { + response.setStatus("NONE"); + response.setMessage("暂无资源恢复记录"); + return response; + } + RestoreStatusSnapshot status = restoreStatusFileReader.read(resolveTaskDir(entity)); + String latestStatus = defaultIfBlank(status.status(), entity.getStatus()); + response.setTaskId(entity.getTaskId()); + response.setBackupId(entity.getBackupId()); + response.setStatus(latestStatus); + response.setPhase(status.phase()); + response.setMessage(displayMessage(status.message(), latestStatus)); + response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString()); + response.setFinishedAt(isTerminal(latestStatus) + ? defaultIfBlank(status.updatedAt(), entity.getFinishTime() == null ? null : entity.getFinishTime().toString()) + : null); + return response; + } + + private void repairMissingTask( + ActiveRestoreStateStore.ActiveRestoreSnapshot active, + RestoreStatusSnapshot status, + String latestStatus + ) { + if (trim(active.getTaskId()).isEmpty() || resourceRestoreTaskRepository.findByTaskId(active.getTaskId()).isPresent()) { return; } - response.setStatus(defaultIfBlank(snapshot.status(), response.getStatus())); - response.setControlPhase(defaultIfBlank(snapshot.phase(), response.getControlPhase())); - response.setStateSource("SHELL_STATUS"); - response.setCurrentStepCode(defaultIfBlank(snapshot.phase(), response.getCurrentStepCode())); - response.setCurrentStepNo(currentStepNo(snapshot.phase(), response.getRestorePlan())); - response.setLastHeartbeatAt(defaultIfBlank(snapshot.updatedAt(), response.getLastHeartbeatAt())); - response.setErrorMessage(defaultIfBlank(snapshot.message(), response.getErrorMessage())); + ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity(); + entity.setTaskId(active.getTaskId()); + entity.setPrecheckId(defaultIfBlank(active.getPrecheckId(), active.getTaskId())); + entity.setBackupId(defaultIfBlank(active.getBackupId(), "UNKNOWN")); + entity.setStatus(defaultIfBlank(latestStatus, "UNKNOWN")); + entity.setSourceDeviceId(active.getSourceDeviceId()); + entity.setSourceProductType(defaultIfBlank(active.getSourceProductType(), "UNKNOWN")); + entity.setTargetProductType(defaultIfBlank(active.getTargetProductType(), "UNKNOWN")); + entity.setPackageFileId(defaultIfBlank(active.getPackageFileId(), "UNKNOWN")); + entity.setPackagePath(active.getPackagePath()); + entity.setStagingDir(active.getStagingDir()); + entity.setRunnerStatePath(active.getRunnerStatePath()); + entity.setStorageEncKeyFingerprint(defaultIfBlank(active.getStorageEncKeyFingerprint(), "UNKNOWN")); + entity.setAuthenticityKeyFingerprint(defaultIfBlank(active.getAuthenticityKeyFingerprint(), "UNKNOWN")); + entity.setRestorePlanJson(defaultIfBlank(active.getRestorePlanJson(), "[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]")); + entity.setErrorMessage(status.message()); + entity.setCreateBy(defaultIfBlank(active.getCreateBy(), "SYSTEM")); + entity.setCreateTime(parseLocalDateTime(active.getCreatedAt(), LocalDateTime.now())); + entity.setStartTime(parseLocalDateTime(active.getStartedAt(), null)); + entity.setFinishTime(isTerminal(latestStatus) ? parseLocalDateTime(status.updatedAt(), null) : null); + entity.setUpdateTime(LocalDateTime.now()); + resourceRestoreTaskRepository.save(entity); } private Path resolveStatePath(String statusFilePath) { return trim(statusFilePath).isEmpty() ? null : Path.of(statusFilePath).normalize(); } + private Path resolveTaskDir(String statusFilePath) { + Path statePath = resolveStatePath(statusFilePath); + return statePath == null ? null : statePath.getParent(); + } + private Path resolveTaskDir(ResourceRestoreTaskEntity entity) { Path statePath = resolveStatePath(entity.getRunnerStatePath()); return statePath == null ? null : statePath.getParent(); } - private void applyLightweightStepState(ResourceTaskStepItemResponse item, RestoreStatusSnapshot snapshot, String stepCode) { - item.setLogAvailable(!trim(snapshot.log()).isEmpty()); - if (trim(snapshot.status()).isEmpty()) { - item.setStatus("PENDING"); - return; - } - if ("JAVA_EXTRACT".equals(stepCode)) { - item.setStatus("SUCCESS"); - return; - } - if ("SHELL_APPLY".equals(stepCode)) { - item.setStatus(snapshot.status()); - item.setMessage(snapshot.message()); - return; - } - if (stepCode.equals(snapshot.phase())) { - item.setStatus(snapshot.status()); - item.setMessage(snapshot.message()); - return; - } - item.setStatus("PENDING"); - } - - private List readStringList(String json) { - if (trim(json).isEmpty()) { - return List.of(); - } - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } catch (Exception ex) { - throw new IllegalStateException("解析恢复计划JSON失败", ex); - } - } - - private Object readObject(String json) { - if (trim(json).isEmpty()) { - return null; - } - try { - return objectMapper.readValue(json, Object.class); - } catch (Exception ex) { - throw new IllegalStateException("解析一致性报告JSON失败", ex); - } - } - private static String defaultIfBlank(String candidate, String fallback) { return trim(candidate).isEmpty() ? fallback : candidate; } - private static Integer currentStepNo(String phase, List restorePlan) { - if (restorePlan == null || restorePlan.isEmpty()) { - return null; - } - for (int i = 0; i < restorePlan.size(); i++) { - if (restorePlan.get(i).equals(phase)) { - return i + 1; - } - } - return restorePlan.contains("SHELL_APPLY") ? restorePlan.indexOf("SHELL_APPLY") + 1 : null; + private static boolean isTerminal(String status) { + String value = trim(status); + return "SUCCESS".equalsIgnoreCase(value) || "FAILED".equalsIgnoreCase(value); } + + private static String displayMessage(String message, String status) { + String text = trim(message); + if (!text.isEmpty()) { + return text; + } + if ("SUCCESS".equalsIgnoreCase(trim(status))) { + return "恢复成功"; + } + if ("FAILED".equalsIgnoreCase(trim(status))) { + return "恢复失败"; + } + if ("NONE".equalsIgnoreCase(trim(status))) { + return "暂无资源恢复记录"; + } + return "恢复执行中"; + } + + private static LocalDateTime parseLocalDateTime(String value, LocalDateTime fallback) { + String text = trim(value); + if (text.isEmpty()) { + return fallback; + } + try { + return LocalDateTime.parse(text); + } catch (DateTimeException ex) { + return fallback; + } + } + } diff --git a/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java b/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java deleted file mode 100644 index 886e62d..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/support/ResourceTaskStepLogService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.cisd.tms.modules.backup.support; - -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; -import java.nio.file.Path; - -public interface ResourceTaskStepLogService { - - ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode); -} diff --git a/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java b/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java deleted file mode 100644 index 5ded2e2..0000000 --- a/src/main/java/com/cisd/tms/modules/backup/support/file/FileSystemResourceTaskStepLogService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.cisd.tms.modules.backup.support.file; - -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; -import com.cisd.tms.modules.backup.support.ResourceTaskStepLogService; -import java.nio.file.Files; -import java.nio.file.Path; -import org.springframework.stereotype.Component; - -@Component -public class FileSystemResourceTaskStepLogService implements ResourceTaskStepLogService { - - @Override - public ResourceTaskStepLogResponse read(Path taskDir, String taskId, int stepNo, String stepCode) { - ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse(); - response.setTaskId(taskId); - response.setStepNo(stepNo); - response.setStepCode(stepCode); - if (taskDir == null || stepCode == null || stepCode.isBlank()) { - response.setContent(""); - response.setTruncated(false); - return response; - } - Path logFile = taskDir.resolve("steps").resolve(String.format("%02d-%s.log", stepNo, stepCode)); - try { - if (!Files.isRegularFile(logFile)) { - response.setContent(""); - response.setTruncated(false); - return response; - } - response.setContent(Files.readString(logFile)); - response.setTruncated(false); - return response; - } catch (Exception ex) { - throw new IllegalStateException("读取资源任务步骤日志失败", ex); - } - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4ed971c..97234ff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -194,6 +194,41 @@ tms: output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages} # 轻量恢复 apply 脚本启停 TMS 时使用的脚本路径。 tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh} + # 轻量恢复停服务命令。未配置时 apply 脚本会回退到 tms-script-path stop。 + # command 支持现场脚本路径或完整 shell 命令;required=false 表示失败只记录 warning。 + restore-stop-commands: + - code: STANDARD_NGINX + command: ${TMS_BACKUP_RESTORE_STOP_STANDARD_NGINX_COMMAND:${tms.init.executor.standard-nginx-stop-command}} + required: ${TMS_BACKUP_RESTORE_STOP_STANDARD_NGINX_REQUIRED:false} + product-types: + - ENTERPRISE + - INDIRECT + - code: STANDARD_APPS + command: ${TMS_BACKUP_RESTORE_STOP_STANDARD_APPS_COMMAND:${tms.init.executor.standard-app-stop-command}} + required: ${TMS_BACKUP_RESTORE_STOP_STANDARD_APPS_REQUIRED:false} + product-types: + - ENTERPRISE + - INDIRECT + - code: TMS + command: ${TMS_BACKUP_RESTORE_STOP_TMS_COMMAND:${tms.backup.tms-script-path} stop} + required: ${TMS_BACKUP_RESTORE_STOP_TMS_REQUIRED:false} + # 轻量恢复启动服务命令。标准/间参默认启动 TMS、CMSP/CMTP 和 nginx。 + restore-start-commands: + - code: TMS + command: ${TMS_BACKUP_RESTORE_START_TMS_COMMAND:${tms.backup.tms-script-path} start} + required: ${TMS_BACKUP_RESTORE_START_TMS_REQUIRED:true} + - code: STANDARD_APPS + command: ${TMS_BACKUP_RESTORE_START_STANDARD_APPS_COMMAND:${tms.init.executor.standard-app-start-command}} + required: ${TMS_BACKUP_RESTORE_START_STANDARD_APPS_REQUIRED:true} + product-types: + - ENTERPRISE + - INDIRECT + - code: STANDARD_NGINX + command: ${TMS_BACKUP_RESTORE_START_STANDARD_NGINX_COMMAND:${tms.init.executor.standard-nginx-start-command}} + required: ${TMS_BACKUP_RESTORE_START_STANDARD_NGINX_REQUIRED:true} + product-types: + - ENTERPRISE + - INDIRECT # 轻量恢复 apply 脚本路径;Java 完成验签/解密/展开后调用该脚本执行覆盖、SQL、MQ replay。 restore-apply-script-path: ${TMS_BACKUP_RESTORE_APPLY_SCRIPT_PATH:/home/tms/bin/resource-restore/apply-resource-backup.sh} # 文件恢复白名单根路径;manifest 中 FILE 恢复路径必须落在这些目录下。 @@ -202,6 +237,61 @@ tms: - ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4I:/home/cmep4i} - ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4D:/home/cmep4d} - ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_WORK:/home/tms/tmp/resource-restore} + # 现场可追加或覆盖直接复制恢复的文件类资源。 + # mode=FILE 表示单文件,mode=DIR 表示目录递归,mode=GLOB 表示按通配符匹配同一目录下的一批文件。 + # restore-path 对 FILE 是目标文件路径,对 DIR/GLOB 是目标目录;目标仍必须落在 allowed-restore-roots 下。 + file-resources: + - code: TMS_CONFIG + mode: DIR + path: ${TMS_BACKUP_TMS_CONFIG_PATH:${tms.init.executor.tms-config-dir}} + restore-path: ${TMS_BACKUP_TMS_CONFIG_RESTORE_PATH:${tms.init.executor.tms-config-dir}} + required: ${TMS_BACKUP_TMS_CONFIG_REQUIRED:true} + - code: CP_CONFIG + mode: FILE + path: ${TMS_BACKUP_STANDARD_CP_CONFIG_PATH:${tms.init.executor.standard-cpconfig-path}} + restore-path: ${TMS_BACKUP_STANDARD_CP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-cpconfig-path}} + product-types: + - ENTERPRISE + - INDIRECT + required: ${TMS_BACKUP_STANDARD_CP_CONFIG_REQUIRED:true} + - code: CP_CONFIG + mode: FILE + path: ${TMS_BACKUP_DIRECT_CP_CONFIG_PATH:${tms.init.executor.direct-cpconfig-target-path}} + restore-path: ${TMS_BACKUP_DIRECT_CP_CONFIG_RESTORE_PATH:${tms.init.executor.direct-cpconfig-target-path}} + product-types: + - DIRECT + required: ${TMS_BACKUP_DIRECT_CP_CONFIG_REQUIRED:true} + - code: RECEIVER_LICENSE + mode: FILE + path: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_PATH:${tms.init.executor.standard-receiver-license-target-path}} + restore-path: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_RESTORE_PATH:${tms.init.executor.standard-receiver-license-target-path}} + product-types: + - ENTERPRISE + - INDIRECT + required: ${TMS_BACKUP_STANDARD_RECEIVER_LICENSE_REQUIRED:true} + - code: RECEIVER_LICENSE + mode: FILE + path: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_PATH:${tms.init.executor.direct-receiver-license-target-path}} + restore-path: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_RESTORE_PATH:${tms.init.executor.direct-receiver-license-target-path}} + product-types: + - DIRECT + required: ${TMS_BACKUP_DIRECT_RECEIVER_LICENSE_REQUIRED:true} + - code: STANDARD_CMSP_APP_CONFIG + mode: GLOB + path: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_PATH:${tms.init.executor.standard-home-dir}/cmsp/application-prd*.properties} + restore-path: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-home-dir}/cmsp} + product-types: + - ENTERPRISE + - INDIRECT + required: ${TMS_BACKUP_STANDARD_CMSP_APP_CONFIG_REQUIRED:true} + - code: STANDARD_CMTP_APP_CONFIG + mode: GLOB + path: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_PATH:${tms.init.executor.standard-home-dir}/cmtp/application-prd*.properties} + restore-path: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_RESTORE_PATH:${tms.init.executor.standard-home-dir}/cmtp} + product-types: + - ENTERPRISE + - INDIRECT + required: ${TMS_BACKUP_STANDARD_CMTP_APP_CONFIG_REQUIRED:true} # 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。 mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump} # 恢复 TMS 库时使用的 mysql 客户端命令。 @@ -212,6 +302,13 @@ tms: mq-replay-script-path: ${TMS_BACKUP_MQ_REPLAY_SCRIPT_PATH:/home/tms/bin/resource-restore/replay-mq.sh} # 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。 tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:} + # TMS 库备份排除表。默认不备份角色、授权、会话和审计表,避免恢复时覆盖新机器安全状态。 + tms-db-excluded-tables: + - ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_ACCOUNT:tms_role_account} + - ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_UKEY:tms_role_ukey_binding} + - ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_FULL:tms_auth_full_account} + - ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_SESSION:tms_auth_session} + - ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_CHALLENGE:tms_auth_challenge} # 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。 cmep-database-name: ${TMS_BACKUP_CMEP_DATABASE_NAME:CMEP} # 恢复完成后的最小健康检查地址。 @@ -229,9 +326,9 @@ tms: internal-token: ${TMS_INTERNAL_TOKEN:change-me-internal-token} internal-auth: # 是否启用 /api/** 的内部登录鉴权;开发联调时可临时关闭。 - enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:true} + enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:false} # 关闭内部鉴权后,注入请求上下文的调试角色。 - debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:KEY_ADMIN} + debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:OPS_ADMIN} # 关闭内部鉴权后,注入请求上下文的调试认证等级。 debug-auth-level: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_AUTH_LEVEL:FULL} # 关闭内部鉴权后,注入请求上下文的调试 session token。 diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 938ba6c..920b71f 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -115,26 +115,6 @@ CREATE TABLE IF NOT EXISTS tms_resource_restore_task ( KEY idx_tms_resource_restore_task_status (status), KEY idx_tms_resource_restore_task_create_time (create_time) ); - -CREATE TABLE IF NOT EXISTS tms_resource_task_step ( - id BIGINT PRIMARY KEY, - task_id VARCHAR(64) NOT NULL, - task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE', - step_no INT NOT NULL, - step_code VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - message VARCHAR(512) NULL, - log_path VARCHAR(1024) NULL, - exit_code INT NULL, - create_time DATETIME(3) NOT NULL, - update_time DATETIME(3) NOT NULL, - start_time DATETIME(3) NULL, - finish_time DATETIME(3) NULL, - UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no), - KEY idx_tms_resource_task_step_task_id (task_id), - KEY idx_tms_resource_task_step_task_type (task_type) -); - -- ----------------------------------------------------------------------------- -- 认证与设备基础表 -- ----------------------------------------------------------------------------- diff --git a/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql b/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql index 464e299..4a11a18 100644 --- a/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql +++ b/src/main/resources/db/migration/V6__add_resource_backup_restore_tables.sql @@ -62,22 +62,3 @@ CREATE TABLE IF NOT EXISTS tms_resource_restore_task ( KEY idx_tms_resource_restore_task_status (status), KEY idx_tms_resource_restore_task_create_time (create_time) ); - -CREATE TABLE IF NOT EXISTS tms_resource_task_step ( - id BIGINT PRIMARY KEY, - task_id VARCHAR(64) NOT NULL, - task_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE_BACKUP / RESOURCE_RESTORE', - step_no INT NOT NULL, - step_code VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - message VARCHAR(512) NULL, - log_path VARCHAR(1024) NULL, - exit_code INT NULL, - create_time DATETIME(3) NOT NULL, - update_time DATETIME(3) NOT NULL, - start_time DATETIME(3) NULL, - finish_time DATETIME(3) NULL, - UNIQUE KEY uk_tms_resource_task_step_task_no (task_id, step_no), - KEY idx_tms_resource_task_step_task_id (task_id), - KEY idx_tms_resource_task_step_task_type (task_type) -); diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java index 74b5b14..9ddb7d5 100644 --- a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupDtoContractTest.java @@ -2,7 +2,6 @@ package com.cisd.tms.modules.backup; import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -12,15 +11,25 @@ class ResourceBackupDtoContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Test - void shouldAcceptBackupCreateRequestFields() { + void shouldOnlyExposeOptionalRemarkOnBackupCreateRequest() throws Exception { CreateResourceBackupRequest request = new CreateResourceBackupRequest(); - request.setIncludeUpgradeHistory(Boolean.TRUE); - request.setIncludeAuditBackups(Boolean.FALSE); request.setRemark("before hardware replacement"); - Assertions.assertEquals(Boolean.TRUE, request.getIncludeUpgradeHistory()); - Assertions.assertEquals(Boolean.FALSE, request.getIncludeAuditBackups()); Assertions.assertEquals("before hardware replacement", request.getRemark()); + Assertions.assertEquals(1, CreateResourceBackupRequest.class.getDeclaredFields().length); + Assertions.assertThrows( + NoSuchMethodException.class, + () -> CreateResourceBackupRequest.class.getDeclaredMethod("getIncludeUpgradeHistory") + ); + Assertions.assertThrows( + NoSuchMethodException.class, + () -> CreateResourceBackupRequest.class.getDeclaredMethod("getIncludeAuditBackups") + ); + + String json = objectMapper.writeValueAsString(new CreateResourceBackupRequest()); + + Assertions.assertFalse(json.contains("includeUpgradeHistory")); + Assertions.assertFalse(json.contains("includeAuditBackups")); } @Test @@ -32,6 +41,7 @@ class ResourceBackupDtoContractTest { response.setKeysetMatched(true); response.setSignatureValid(true); response.setCompatible(true); + response.setPrecheckPassed(true); response.setPayloadDecrypted(false); String json = objectMapper.writeValueAsString(response); @@ -40,23 +50,35 @@ class ResourceBackupDtoContractTest { Assertions.assertTrue(json.contains("\"backupId\":\"RBKP-20260408-153000-000001\"")); Assertions.assertTrue(json.contains("\"sourceProductType\":\"ENTERPRISE\"")); Assertions.assertTrue(json.contains("\"keysetMatched\":true")); + Assertions.assertTrue(json.contains("\"precheckPassed\":true")); Assertions.assertTrue(json.contains("\"payloadDecrypted\":false")); } @Test - void shouldSerializeRestoreTaskDetailControlFields() throws Exception { - ResourceRestoreTaskDetailResponse response = new ResourceRestoreTaskDetailResponse(); - response.setTaskId("RRST-TASK-20260408-000001"); - response.setStatus("APPLYING"); - response.setControlPhase("SHELL_APPLY"); - response.setStateSource("SHELL_STATUS"); - response.setCurrentStepCode("SHELL_APPLY"); + void shouldSerializeRestoreCreateTargetNodeFields() throws Exception { + com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest request = + new com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest(); + request.setPrecheckId("RPRE-20260408-000001"); + request.setConfirmBackupId("RBKP-20260408-153000-000001"); + request.setDeployMode("DUAL"); + com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.Nodes nodes = + new com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.Nodes(); + nodes.setNode01Ip("192.168.1.11"); + nodes.setNode02Ip("192.168.1.12"); + request.setNodes(nodes); - String json = objectMapper.writeValueAsString(response); + String json = objectMapper.writeValueAsString(request); - Assertions.assertTrue(json.contains("\"taskId\":\"RRST-TASK-20260408-000001\"")); - Assertions.assertTrue(json.contains("\"controlPhase\":\"SHELL_APPLY\"")); - Assertions.assertTrue(json.contains("\"stateSource\":\"SHELL_STATUS\"")); - Assertions.assertTrue(json.contains("\"currentStepCode\":\"SHELL_APPLY\"")); + Assertions.assertThrows( + NoSuchMethodException.class, + () -> com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class + .getDeclaredMethod("getConfirmRiskAccepted") + ); + Assertions.assertFalse(json.contains("confirmRiskAccepted")); + Assertions.assertTrue(json.contains("\"deployMode\":\"DUAL\"")); + Assertions.assertTrue(json.contains("\"nodes\"")); + Assertions.assertTrue(json.contains("\"node01Ip\":\"192.168.1.11\"")); + Assertions.assertTrue(json.contains("\"node02Ip\":\"192.168.1.12\"")); } + } diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java index af4c07f..51edbd5 100644 --- a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java @@ -15,7 +15,9 @@ class ResourceBackupEndToEndContractTest { Assertions.assertTrue(backupController.contains("/api/v1/resource-backups")); Assertions.assertTrue(restoreController.contains("/api/v1/resource-restores")); Assertions.assertTrue(restoreController.contains("/precheck")); - Assertions.assertTrue(restoreController.contains("/steps/{stepNo}/log")); + Assertions.assertTrue(restoreController.contains("/{taskId}/apply")); + Assertions.assertTrue(restoreController.contains("/latest")); + Assertions.assertFalse(restoreController.contains("/steps/{stepNo}/log")); } @Test @@ -23,6 +25,6 @@ class ResourceBackupEndToEndContractTest { String readme = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/README.md")); Assertions.assertTrue(readme.contains("停机恢复")); - Assertions.assertTrue(readme.contains("创建恢复任务后当前TMS服务可能中断")); + Assertions.assertTrue(readme.contains("确认后调用 apply 接口进入停机恢复")); } } diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupPropertiesTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupPropertiesTest.java index 4a2c51e..33dd7c0 100644 --- a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupPropertiesTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupPropertiesTest.java @@ -3,6 +3,7 @@ package com.cisd.tms.modules.backup; import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import com.cisd.tms.modules.backup.dto.support.BackupResourceKind; import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource; +import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -21,9 +22,12 @@ class ResourceBackupPropertiesTest { properties.getMqReplayScriptPath() ); Assertions.assertTrue(properties.getAllowedRestoreRoots().contains("/home/tms/config")); - Assertions.assertTrue(properties.getDefaultFileResources().stream() - .anyMatch(item -> "TMS_CONFIG".equals(item.getCode()) - && BackupResourceKind.FILE.name().equals(item.getKind()))); + Assertions.assertTrue(properties.getFileResources().isEmpty()); + Assertions.assertTrue(properties.getRestoreStopCommands().isEmpty()); + Assertions.assertTrue(properties.getRestoreStartCommands().isEmpty()); + Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_account")); + Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_ukey_binding")); + Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_auth_session")); } @Test @@ -35,11 +39,37 @@ class ResourceBackupPropertiesTest { custom.setMode("FILE"); custom.setPath("/home/tms/certs/custom.crt"); custom.setRestorePath("/home/tms/certs/custom.crt"); + custom.setProductTypes(java.util.List.of("ENTERPRISE", "INDIRECT")); custom.setRequired(false); properties.setFileResources(java.util.List.of(custom)); Assertions.assertEquals("CUSTOM_CERT", properties.getFileResources().get(0).getCode()); + Assertions.assertEquals(java.util.List.of("ENTERPRISE", "INDIRECT"), properties.getFileResources().get(0).getProductTypes()); Assertions.assertFalse(properties.getFileResources().get(0).isRequired()); } + + @Test + void shouldAcceptConfiguredRestoreStartAndStopCommands() { + ResourceBackupProperties properties = new ResourceBackupProperties(); + ConfiguredRestoreCommand stop = restoreCommand("STANDARD_APPS", "/home/tms/bin/stop_standard_apps.sh", false); + ConfiguredRestoreCommand start = restoreCommand("STANDARD_APPS", "/home/tms/bin/start_standard_apps.sh", true); + + properties.setRestoreStopCommands(java.util.List.of(stop)); + properties.setRestoreStartCommands(java.util.List.of(start)); + + Assertions.assertEquals("STANDARD_APPS", properties.getRestoreStopCommands().get(0).getCode()); + Assertions.assertFalse(properties.getRestoreStopCommands().get(0).isRequired()); + Assertions.assertEquals("/home/tms/bin/start_standard_apps.sh", properties.getRestoreStartCommands().get(0).getCommand()); + Assertions.assertEquals(java.util.List.of("ENTERPRISE", "INDIRECT"), properties.getRestoreStartCommands().get(0).getProductTypes()); + } + + private static ConfiguredRestoreCommand restoreCommand(String code, String command, boolean required) { + ConfiguredRestoreCommand item = new ConfiguredRestoreCommand(); + item.setCode(code); + item.setCommand(command); + item.setRequired(required); + item.setProductTypes(java.util.List.of("ENTERPRISE", "INDIRECT")); + return item; + } } diff --git a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java index 92ab21a..0ceb409 100644 --- a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupControllerTest.java @@ -6,7 +6,6 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse; import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; import com.cisd.tms.modules.backup.service.ResourceBackupService; import com.cisd.tms.modules.backup.service.ResourcePrecheckService; import com.cisd.tms.modules.backup.service.ResourceRestoreService; @@ -42,7 +41,7 @@ class ResourceBackupControllerTest { CreateResourceBackupResponse createBackup = new CreateResourceBackupResponse(); createBackup.setTaskId("RBKP-TASK-20260408-000001"); createBackup.setStatus("PENDING"); - Mockito.when(backupService.createBackupTask(Mockito.any(CreateResourceBackupRequest.class))).thenReturn(createBackup); + Mockito.when(backupService.createBackupTask(Mockito.nullable(CreateResourceBackupRequest.class))).thenReturn(createBackup); ResourceBackupTaskDetailResponse backupDetail = new ResourceBackupTaskDetailResponse(); backupDetail.setTaskId("RBKP-TASK-20260408-000001"); @@ -70,13 +69,6 @@ class ResourceBackupControllerTest { createRestore.setStatus("PENDING"); Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(createRestore); - ResourceRestoreTaskDetailResponse restoreDetail = new ResourceRestoreTaskDetailResponse(); - restoreDetail.setTaskId("RRST-TASK-20260408-000001"); - restoreDetail.setStatus("APPLYING"); - restoreDetail.setControlPhase("SHELL_APPLY"); - restoreDetail.setStateSource("SHELL_STATUS"); - Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260408-000001")).thenReturn(restoreDetail); - MockMvc mockMvc = MockMvcBuilders.standaloneSetup( new ResourceBackupController(backupService), new ResourceRestoreController(precheckService, restoreService, taskQueryService) @@ -84,7 +76,11 @@ class ResourceBackupControllerTest { mockMvc.perform(post("/api/v1/resource-backups") .contentType(MediaType.APPLICATION_JSON) - .content("{\"includeUpgradeHistory\":true,\"includeAuditBackups\":false,\"remark\":\"before hardware replacement\"}")) + .content("{\"remark\":\"before hardware replacement\"}")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RBKP-TASK-20260408-000001\""))); + + mockMvc.perform(post("/api/v1/resource-backups")) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RBKP-TASK-20260408-000001\""))); @@ -106,13 +102,8 @@ class ResourceBackupControllerTest { mockMvc.perform(post("/api/v1/resource-restores") .contentType(MediaType.APPLICATION_JSON) - .content("{\"precheckId\":\"RPRE-20260408-000001\",\"confirmBackupId\":\"RBKP-20260408-153000-000001\",\"confirmRiskAccepted\":true}")) + .content("{\"precheckId\":\"RPRE-20260408-000001\",\"confirmBackupId\":\"RBKP-20260408-153000-000001\",\"deployMode\":\"DUAL\",\"nodes\":{\"node01Ip\":\"192.168.1.11\",\"node02Ip\":\"192.168.1.12\"}}")) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260408-000001\""))); - - mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260408-000001")) - .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\""))) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\"stateSource\":\"SHELL_STATUS\""))); } } diff --git a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java index 341074d..fa3dce0 100644 --- a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceRestoreControllerTest.java @@ -3,14 +3,11 @@ package com.cisd.tms.modules.backup.controller; import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceRestoreTaskDetailResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; import com.cisd.tms.modules.backup.service.ResourcePrecheckService; import com.cisd.tms.modules.backup.service.ResourceRestoreService; import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; -import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.http.MediaType; @@ -25,7 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class ResourceRestoreControllerTest { @Test - void shouldExposeRestoreEndpointsIncludingStepsAndLogs() throws Exception { + void shouldExposeTwoPhaseRestoreEndpointsAndLatestResult() throws Exception { ResourcePrecheckService precheckService = Mockito.mock(ResourcePrecheckService.class); ResourceRestoreService restoreService = Mockito.mock(ResourceRestoreService.class); ResourceTaskQueryService taskQueryService = Mockito.mock(ResourceTaskQueryService.class); @@ -37,28 +34,20 @@ class ResourceRestoreControllerTest { CreateResourceRestoreResponse create = new CreateResourceRestoreResponse(); create.setTaskId("RRST-TASK-20260414-000001"); - create.setStatus("APPLYING"); + create.setStatus("READY_TO_APPLY"); Mockito.when(restoreService.createRestoreTask(Mockito.any(CreateResourceRestoreRequest.class))).thenReturn(create); - - ResourceRestoreTaskDetailResponse detail = new ResourceRestoreTaskDetailResponse(); - detail.setTaskId("RRST-TASK-20260414-000001"); - detail.setControlPhase("SHELL_APPLY"); - detail.setStateSource("SHELL_STATUS"); - Mockito.when(taskQueryService.getRestoreTask("RRST-TASK-20260414-000001")).thenReturn(detail); - - ResourceTaskStepItemResponse step = new ResourceTaskStepItemResponse(); - step.setStepNo(2); - step.setStepCode("SHELL_APPLY"); - step.setStatus("SUCCESS"); - Mockito.when(taskQueryService.getRestoreTaskSteps("RRST-TASK-20260414-000001")).thenReturn(List.of(step)); - - ResourceTaskStepLogResponse log = new ResourceTaskStepLogResponse(); - log.setTaskId("RRST-TASK-20260414-000001"); - log.setStepNo(2); - log.setStepCode("SHELL_APPLY"); - log.setContent("apply ok"); - log.setTruncated(false); - Mockito.when(taskQueryService.getRestoreTaskStepLog("RRST-TASK-20260414-000001", 2)).thenReturn(log); + CreateResourceRestoreResponse apply = new CreateResourceRestoreResponse(); + apply.setTaskId("RRST-TASK-20260414-000001"); + apply.setStatus("APPLYING"); + Mockito.when(restoreService.applyRestoreTask("RRST-TASK-20260414-000001")).thenReturn(apply); + ResourceRestoreLatestResponse latest = new ResourceRestoreLatestResponse(); + latest.setTaskId("RRST-TASK-20260414-000001"); + latest.setBackupId("RBKP-20260414-000001"); + latest.setStatus("FAILED"); + latest.setPhase("DATABASE"); + latest.setMessage("数据库恢复失败"); + latest.setFinishedAt("2026-04-14T10:05:00+0800"); + Mockito.when(taskQueryService.getLatestRestore()).thenReturn(latest); MockMvc mockMvc = MockMvcBuilders.standaloneSetup( new ResourceRestoreController(precheckService, restoreService, taskQueryService) @@ -72,20 +61,23 @@ class ResourceRestoreControllerTest { mockMvc.perform(post("/api/v1/resource-restores") .contentType(MediaType.APPLICATION_JSON) - .content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"confirmRiskAccepted\":true}")) + .content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"deployMode\":\"DUAL\",\"nodes\":{\"node01Ip\":\"192.168.1.11\",\"node02Ip\":\"192.168.1.12\"}}")) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("\"taskId\":\"RRST-TASK-20260414-000001\""))); - mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001")) + mockMvc.perform(post("/api/v1/resource-restores/RRST-TASK-20260414-000001/apply")) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\"controlPhase\":\"SHELL_APPLY\""))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"APPLYING\""))); - mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001/steps")) + mockMvc.perform(get("/api/v1/resource-restores/latest")) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\"stepCode\":\"SHELL_APPLY\""))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"FAILED\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"phase\":\"DATABASE\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"message\":\"数据库恢复失败\""))); - mockMvc.perform(get("/api/v1/resource-restores/RRST-TASK-20260414-000001/steps/2/log")) - .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\"content\":\"apply ok\""))); + mockMvc.perform(post("/api/v1/resource-restores") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"precheckId\":\"RPRE-20260414-000001\",\"confirmBackupId\":\"RBKP-20260414-000001\",\"deployMode\":\"DUAL\"}")) + .andExpect(status().isBadRequest()); } } diff --git a/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java index f90b87a..dcc2010 100644 --- a/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/crypto/ResourceEnvelopeCryptoServiceTest.java @@ -3,10 +3,9 @@ package com.cisd.tms.modules.backup.crypto; 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.model.EccInternalSignRequest; import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest; +import com.cisd.tms.integration.crypto.pcie.model.UserKeySignRequest; import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService; -import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import java.util.Arrays; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Assertions; @@ -19,9 +18,6 @@ class ResourceEnvelopeCryptoServiceTest { @Test void shouldWrapDekWithEncUserKeyAndSignWithAuthUserKey() { PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); - ResourceBackupProperties properties = new ResourceBackupProperties(); - properties.setStorageEncKeyIndex(7); - properties.setAuthenticitySignKeyIndex(8); AtomicReference capturedDek = new AtomicReference<>(); AtomicReference decryptableDek = new AtomicReference<>(); @@ -33,7 +29,7 @@ class ResourceEnvelopeCryptoServiceTest { decryptableDek.set(Arrays.copyOf(request.getData(), request.getData().length)); return backup(new byte[]{0x11, 0x22, 0x33}); }); - Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any(EccInternalSignRequest.class))) + Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any(UserKeySignRequest.class))) .thenReturn(backup(new byte[]{0x44, 0x55, 0x66})); Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) .thenAnswer(invocation -> { @@ -42,7 +38,7 @@ class ResourceEnvelopeCryptoServiceTest { return backup(decryptedDek); }); - ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties); + ResourceEnvelopeCryptoServiceImpl service = new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService); byte[] payload = "payload-data".getBytes(java.nio.charset.StandardCharsets.UTF_8); byte[] signSource = "manifest|envelope|cipher".getBytes(java.nio.charset.StandardCharsets.UTF_8); @@ -55,19 +51,19 @@ class ResourceEnvelopeCryptoServiceTest { ArgumentCaptor encryptCaptor = ArgumentCaptor.forClass(EccInternalEncryptRequest.class); Mockito.verify(pcieCryptoService).sdfeInternalEncryptEcc(encryptCaptor.capture()); - Assertions.assertEquals(7, encryptCaptor.getValue().getKeyIndex()); + Assertions.assertEquals(1, encryptCaptor.getValue().getKeyIndex()); - ArgumentCaptor signCaptor = ArgumentCaptor.forClass(EccInternalSignRequest.class); - Mockito.verify(pcieCryptoService).eccInternalSign(signCaptor.capture()); - Assertions.assertEquals(8, signCaptor.getValue().getKeyIndex()); + ArgumentCaptor signCaptor = ArgumentCaptor.forClass(UserKeySignRequest.class); + Mockito.verify(pcieCryptoService).userKeySignWithSm3(signCaptor.capture()); + Assertions.assertEquals(1, signCaptor.getValue().getKeyIndex()); ArgumentCaptor verifyCaptor = ArgumentCaptor.forClass(EccInternalVerifyRequest.class); Mockito.verify(pcieCryptoService).eccInternalVerify(verifyCaptor.capture()); - Assertions.assertEquals(8, verifyCaptor.getValue().getKeyIndex()); + Assertions.assertEquals(1, verifyCaptor.getValue().getKeyIndex()); ArgumentCaptor decryptCaptor = ArgumentCaptor.forClass(EccInternalDecryptRequest.class); Mockito.verify(pcieCryptoService).sdfeInternalDecryptEcc(decryptCaptor.capture()); - Assertions.assertEquals(7, decryptCaptor.getValue().getKeyIndex()); + Assertions.assertEquals(1, decryptCaptor.getValue().getKeyIndex()); Assertions.assertTrue(isAllZero(capturedDek.get()), "generated SM4 DEK should be wiped after sealing"); Assertions.assertTrue(isAllZero(decryptResultDek.get()), "decrypted SM4 DEK result buffer should be wiped after unsealing"); } 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 d9c9902..7101137 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 @@ -1,7 +1,8 @@ package com.cisd.tms.modules.backup.discovery; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource; import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem; -import com.cisd.tms.modules.init.config.InitExecutorProperties; import com.cisd.tms.modules.init.entity.InitTaskEntity; import java.util.List; import org.junit.jupiter.api.Assertions; @@ -11,15 +12,20 @@ class ResourceDiscoveryServiceTest { @Test void shouldDiscoverStandardRabbitResourcesWithoutExcludedArtifacts() { - ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties()); + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(defaultBackupProperties()); List items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ")); Assertions.assertTrue(containsCode(items, "TMS_CONFIG")); Assertions.assertTrue(containsCode(items, "TMS_DB")); Assertions.assertTrue(containsCode(items, "CMEP_DB")); + Assertions.assertTrue(containsCode(items, "ORG_DB")); + Assertions.assertTrue(hasDatabaseName(items, "ORG_DB", "AAAABBBBXXX")); + Assertions.assertTrue(hasEntryPath(items, "ORG_DB", "db/AAAABBBBXXX.sql")); Assertions.assertTrue(containsCode(items, "CP_CONFIG")); Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE")); + Assertions.assertTrue(containsCode(items, "STANDARD_CMSP_APP_CONFIG")); + Assertions.assertTrue(containsCode(items, "STANDARD_CMTP_APP_CONFIG")); Assertions.assertTrue(containsCode(items, "USER_KEY")); Assertions.assertTrue(containsCode(items, "MQ_REPLAY")); Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS")); @@ -38,7 +44,7 @@ class ResourceDiscoveryServiceTest { @Test void shouldUseDirectPathsForDirectTlqResources() { - ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(sampleProperties()); + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(defaultBackupProperties()); List items = service.discover(sampleInitTask("DIRECT", "RABBITMQ_TLQ")); @@ -50,6 +56,70 @@ class ResourceDiscoveryServiceTest { Assertions.assertTrue(hasRequired(items, "USER_KEY")); Assertions.assertTrue(hasRequired(items, "MQ_REPLAY")); Assertions.assertFalse(containsCode(items, "CMEP_DB")); + Assertions.assertFalse(containsCode(items, "ORG_DB")); + Assertions.assertFalse(containsCode(items, "STANDARD_CMSP_APP_CONFIG")); + Assertions.assertFalse(containsCode(items, "STANDARD_CMTP_APP_CONFIG")); + } + + @Test + void shouldNotDiscoverDirectCopyFileResourcesWhenFileResourcesAreEmpty() { + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(new ResourceBackupProperties()); + + List items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ")); + + Assertions.assertFalse(containsCode(items, "TMS_CONFIG")); + Assertions.assertFalse(containsCode(items, "CP_CONFIG")); + Assertions.assertFalse(containsCode(items, "RECEIVER_LICENSE")); + Assertions.assertFalse(containsCode(items, "STANDARD_CMSP_APP_CONFIG")); + Assertions.assertTrue(containsCode(items, "TMS_DB")); + Assertions.assertTrue(containsCode(items, "CMEP_DB")); + Assertions.assertTrue(containsCode(items, "ORG_DB")); + Assertions.assertTrue(containsCode(items, "USER_KEY")); + Assertions.assertTrue(containsCode(items, "MQ_REPLAY")); + } + + @Test + void shouldAppendConfiguredFileResources() { + ResourceBackupProperties backupProperties = new ResourceBackupProperties(); + ConfiguredFileBackupResource custom = new ConfiguredFileBackupResource(); + custom.setCode("CUSTOM_CERT"); + custom.setMode("FILE"); + custom.setPath("/home/tms/config/custom-cert.pem"); + custom.setRestorePath("/home/tms/config/custom-cert.pem"); + custom.setRequired(false); + backupProperties.setFileResources(List.of(custom)); + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(backupProperties); + + List items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ")); + + Assertions.assertTrue(hasPath(items, "CUSTOM_CERT", "/home/tms/config/custom-cert.pem")); + Assertions.assertFalse(hasRequired(items, "CUSTOM_CERT")); + } + + @Test + void shouldDiscoverStandardAppConfigOnlyWhenConfiguredAsFileResources() { + ResourceBackupProperties backupProperties = new ResourceBackupProperties(); + ConfiguredFileBackupResource cmsp = new ConfiguredFileBackupResource(); + cmsp.setCode("STANDARD_CMSP_APP_CONFIG"); + cmsp.setMode("GLOB"); + cmsp.setPath("/home/cmep4i/cmsp/application-prd*.properties"); + cmsp.setRestorePath("/home/cmep4i/cmsp"); + cmsp.setRequired(true); + ConfiguredFileBackupResource cmtp = new ConfiguredFileBackupResource(); + cmtp.setCode("STANDARD_CMTP_APP_CONFIG"); + cmtp.setMode("GLOB"); + cmtp.setPath("/home/cmep4i/cmtp/application-prd*.properties"); + cmtp.setRestorePath("/home/cmep4i/cmtp"); + cmtp.setRequired(true); + backupProperties.setFileResources(List.of(cmsp, cmtp)); + ResourceDiscoveryService service = new ResourceDiscoveryServiceImpl(backupProperties); + + List items = service.discover(sampleInitTask("ENTERPRISE", "RABBITMQ")); + + Assertions.assertTrue(hasPath(items, "STANDARD_CMSP_APP_CONFIG", "/home/cmep4i/cmsp/application-prd*.properties")); + Assertions.assertTrue(hasPath(items, "STANDARD_CMTP_APP_CONFIG", "/home/cmep4i/cmtp/application-prd*.properties")); + Assertions.assertTrue(hasMode(items, "STANDARD_CMSP_APP_CONFIG", "GLOB")); + Assertions.assertTrue(hasRestorePath(items, "STANDARD_CMTP_APP_CONFIG", "/home/cmep4i/cmtp")); } private static InitTaskEntity sampleInitTask(String productType, String mqType) { @@ -64,20 +134,35 @@ class ResourceDiscoveryServiceTest { return entity; } - private static InitExecutorProperties sampleProperties() { - InitExecutorProperties properties = new InitExecutorProperties(); - properties.setTmsConfigDir("/home/tms/config"); - properties.setStandardCpconfigPath("/home/cmep4i/cpconfig.cfg"); - properties.setDirectCpconfigTargetPath("/opt/cpconfig.cfg"); - properties.setStandardReceiverLicenseTargetPath("/home/cmep4i/cmep/license/cmep.license"); - properties.setDirectReceiverLicenseTargetPath("/home/cmep4d/cmep/license/cmep.license"); - properties.setStandardTlqLicenseTargetPath("/home/tlq/tlqconf.zip"); - properties.setDirectTlqLicenseTargetPath("/home/tlq/TLQ8/license.dat"); - properties.setCfmqTargetDir("/pmts/cfg"); - properties.setRabbitmqAdminCommand("/usr/sbin/rabbitmqadmin"); + private static ResourceBackupProperties defaultBackupProperties() { + ResourceBackupProperties properties = new ResourceBackupProperties(); + ConfiguredFileBackupResource tmsConfig = configured("TMS_CONFIG", "DIR", "/home/tms/config", "/home/tms/config", true); + ConfiguredFileBackupResource standardCpConfig = configured("CP_CONFIG", "FILE", "/home/cmep4i/cpconfig.cfg", "/home/cmep4i/cpconfig.cfg", true); + standardCpConfig.setProductTypes(List.of("ENTERPRISE", "INDIRECT")); + ConfiguredFileBackupResource directCpConfig = configured("CP_CONFIG", "FILE", "/opt/cpconfig.cfg", "/opt/cpconfig.cfg", true); + directCpConfig.setProductTypes(List.of("DIRECT")); + ConfiguredFileBackupResource standardLicense = configured("RECEIVER_LICENSE", "FILE", "/home/cmep4i/cmep/license/cmep.license", "/home/cmep4i/cmep/license/cmep.license", true); + standardLicense.setProductTypes(List.of("ENTERPRISE", "INDIRECT")); + ConfiguredFileBackupResource directLicense = configured("RECEIVER_LICENSE", "FILE", "/home/cmep4d/cmep/license/cmep.license", "/home/cmep4d/cmep/license/cmep.license", true); + directLicense.setProductTypes(List.of("DIRECT")); + ConfiguredFileBackupResource cmsp = configured("STANDARD_CMSP_APP_CONFIG", "GLOB", "/home/cmep4i/cmsp/application-prd*.properties", "/home/cmep4i/cmsp", true); + cmsp.setProductTypes(List.of("ENTERPRISE", "INDIRECT")); + ConfiguredFileBackupResource cmtp = configured("STANDARD_CMTP_APP_CONFIG", "GLOB", "/home/cmep4i/cmtp/application-prd*.properties", "/home/cmep4i/cmtp", true); + cmtp.setProductTypes(List.of("ENTERPRISE", "INDIRECT")); + properties.setFileResources(List.of(tmsConfig, standardCpConfig, directCpConfig, standardLicense, directLicense, cmsp, cmtp)); return properties; } + private static ConfiguredFileBackupResource configured(String code, String mode, String path, String restorePath, boolean required) { + ConfiguredFileBackupResource resource = new ConfiguredFileBackupResource(); + resource.setCode(code); + resource.setMode(mode); + resource.setPath(path); + resource.setRestorePath(restorePath); + resource.setRequired(required); + return resource; + } + private static boolean containsCode(List items, String code) { return items.stream().anyMatch(item -> code.equals(item.getResourceCode())); } @@ -86,6 +171,22 @@ class ResourceDiscoveryServiceTest { return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getResolvedPath())); } + private static boolean hasRestorePath(List items, String code, String path) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getRestorePath())); + } + + private static boolean hasMode(List items, String code, String mode) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && mode.equals(item.getMode())); + } + + private static boolean hasDatabaseName(List items, String code, String databaseName) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && databaseName.equals(item.getDatabaseName())); + } + + private static boolean hasEntryPath(List items, String code, String entryPath) { + return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && entryPath.equals(item.getEntryPath())); + } + private static boolean hasRequired(List items, String code) { return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && item.isRequired()); } diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollectorTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollectorTest.java new file mode 100644 index 0000000..83e3d2e --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/MysqlDatabaseBackupCollectorTest.java @@ -0,0 +1,57 @@ +package com.cisd.tms.modules.backup.packagex; + +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MysqlDatabaseBackupCollectorTest { + + @TempDir + Path tempDir; + + @Test + void shouldExcludeConfiguredTablesOnlyWhenDumpingTmsDatabase() throws Exception { + Path argsLog = tempDir.resolve("mysqldump-args.log"); + Path mysqldump = fakeMysqldump(argsLog); + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setMysqldumpPath(mysqldump.toString()); + properties.setTmsDbExcludedTables(List.of( + "tms_role_account", + "tms_auth_session", + "CustomDb.already_qualified" + )); + MysqlDatabaseBackupCollector collector = new MysqlDatabaseBackupCollector( + properties, + "jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false", + "root", + "secret" + ); + + DatabaseBackupCollector.CollectedDatabaseDump tmsDump = collector.collectTmsDatabaseDump(); + collector.collectDatabaseDump("ORG001", "ORG001.sql"); + + Assertions.assertEquals("TMS", tmsDump.getDatabaseName()); + Assertions.assertEquals("TMS.sql", tmsDump.getFileName()); + Assertions.assertEquals("-- synthetic dump\n", new String(tmsDump.getContent())); + String[] invocations = Files.readString(argsLog).split(System.lineSeparator()); + Assertions.assertTrue(invocations[0].contains("--ignore-table=TMS.tms_role_account")); + Assertions.assertTrue(invocations[0].contains("--ignore-table=TMS.tms_auth_session")); + Assertions.assertTrue(invocations[0].contains("--ignore-table=CustomDb.already_qualified")); + Assertions.assertFalse(invocations[1].contains("--ignore-table=")); + } + + private Path fakeMysqldump(Path argsLog) throws Exception { + Path script = tempDir.resolve("mysqldump"); + Files.writeString(script, """ + #!/usr/bin/env bash + printf '%%s\\n' "$*" >> "%s" + printf -- '-- synthetic dump\\n' + """.formatted(argsLog)); + script.toFile().setExecutable(true); + return script; + } +} 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 3fb35e6..14dd93b 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 @@ -50,7 +50,7 @@ class ResourcePackageServiceTest { EccInternalEncryptRequest request = invocation.getArgument(0); return backup(request.getData()); }); - Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any())) + Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any())) .thenReturn(backup(new byte[]{0x11, 0x22, 0x33})); Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) .thenAnswer(invocation -> { @@ -60,7 +60,7 @@ class ResourcePackageServiceTest { Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any()); ResourcePackageService service = new ResourcePackageServiceImpl( properties, - new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService), mockDatabaseBackupCollector(), new ObjectMapper() ); @@ -73,6 +73,7 @@ class ResourcePackageServiceTest { resource("TMS_CONFIG", payloadFile.toString()), databaseResource("TMS_DB", "TMS", "db/TMS.sql"), databaseResource("CMEP_DB", "CMEP", "db/CMEP.sql"), + databaseResource("ORG_DB", "AAAABBBBXXX", "db/AAAABBBBXXX.sql"), mqReplayResource() ), sampleRequest() @@ -85,7 +86,7 @@ class ResourcePackageServiceTest { Assertions.assertFalse(result.getPackageHash().isBlank()); Assertions.assertFalse(result.getStorageEncKeyFingerprint().isBlank()); Assertions.assertFalse(result.getAuthenticityKeyFingerprint().isBlank()); - Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":4")); + Assertions.assertTrue(result.getManifestSummaryJson().contains("\"resourceCount\":5")); try (ZipFile zipFile = new ZipFile(packagePath.toFile())) { Assertions.assertNotNull(zipFile.getEntry("manifest.json")); @@ -103,7 +104,7 @@ class ResourcePackageServiceTest { @SuppressWarnings("unchecked") java.util.List> resources = (java.util.List>) inspectResult.getPayload().get("resources"); - Assertions.assertEquals(4, resources.size()); + Assertions.assertEquals(5, resources.size()); Assertions.assertEquals(Boolean.TRUE, resources.get(0).get("collected")); Assertions.assertTrue(String.valueOf(resources.get(0).get("entryPath")).contains("application.yml")); Assertions.assertEquals("TMS_DB", resources.get(1).get("resourceCode")); @@ -112,9 +113,13 @@ class ResourcePackageServiceTest { Assertions.assertEquals("CMEP_DB", resources.get(2).get("resourceCode")); Assertions.assertEquals("DATABASE", resources.get(2).get("kind")); Assertions.assertEquals("db/CMEP.sql", resources.get(2).get("entryPath")); - Assertions.assertEquals("MQ_REPLAY", resources.get(3).get("resourceCode")); - Assertions.assertEquals("MQ_REPLAY", resources.get(3).get("kind")); - Assertions.assertEquals("mq/mq-restore-context.json", resources.get(3).get("entryPath")); + Assertions.assertEquals("ORG_DB", resources.get(3).get("resourceCode")); + Assertions.assertEquals("DATABASE", resources.get(3).get("kind")); + Assertions.assertEquals("AAAABBBBXXX", resources.get(3).get("databaseName")); + Assertions.assertEquals("db/AAAABBBBXXX.sql", resources.get(3).get("entryPath")); + Assertions.assertEquals("MQ_REPLAY", resources.get(4).get("resourceCode")); + Assertions.assertEquals("MQ_REPLAY", resources.get(4).get("kind")); + Assertions.assertEquals("mq/mq-restore-context.json", resources.get(4).get("entryPath")); try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) { java.util.Map entries = new java.util.LinkedHashMap<>(); @@ -128,6 +133,8 @@ class ResourcePackageServiceTest { Assertions.assertTrue(entries.get("db/TMS.sql").contains("INSERT INTO t_demo")); Assertions.assertTrue(entries.containsKey("db/CMEP.sql")); Assertions.assertTrue(entries.get("db/CMEP.sql").contains("INSERT INTO c_demo")); + Assertions.assertTrue(entries.containsKey("db/AAAABBBBXXX.sql")); + Assertions.assertTrue(entries.get("db/AAAABBBBXXX.sql").contains("INSERT INTO org_demo")); Assertions.assertTrue(entries.containsKey("mq/mq-restore-context.json")); Assertions.assertTrue(entries.get("mq/mq-restore-context.json").contains("\"channelUsername\":\"tms\"")); } @@ -140,7 +147,7 @@ class ResourcePackageServiceTest { PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); ResourcePackageService service = new ResourcePackageServiceImpl( properties, - new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService), mockDatabaseBackupCollector(), new ObjectMapper() ); @@ -160,6 +167,58 @@ class ResourcePackageServiceTest { Assertions.assertTrue(exception.getMessage().contains("PATH_NOT_FOUND")); } + @Test + void shouldCollectGlobFileResourcesWithRestoreDirectory() throws Exception { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setOutputDir(tempDir.toString()); + Path cmspDir = tempDir.resolve("cmsp"); + Files.createDirectories(cmspDir); + Files.writeString(cmspDir.resolve("application-prd.properties"), "spring.rabbitmq.username=user1"); + Files.writeString(cmspDir.resolve("application-prd-node2.properties"), "spring.rabbitmq.username=user2"); + Files.writeString(cmspDir.resolve("application-dev.properties"), "spring.rabbitmq.username=dev"); + PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class); + Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class))) + .thenAnswer(invocation -> backup(invocation.getArgument(0).getData())); + Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) + .thenAnswer(invocation -> backup(invocation.getArgument(0).getCipherBlob())); + Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any())) + .thenReturn(backup(new byte[]{0x11, 0x22, 0x33})); + Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any()); + ResourcePackageService service = new ResourcePackageServiceImpl( + properties, + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService), + mockDatabaseBackupCollector(), + new ObjectMapper() + ); + DiscoveredResourceItem globResource = resource( + "STANDARD_CMSP_APP_CONFIG", + cmspDir.resolve("application-prd*.properties").toString() + ); + globResource.setMode("GLOB"); + globResource.setRestorePath("/home/cmep4i/cmsp"); + + ResourcePackageResult result = service.createPackage( + "RBKP-TASK-20260413-000001", + "RBKP-20260413-153000-000001", + sampleInitTask(), + List.of(globResource), + sampleRequest() + ); + + ResourcePackageInspectResult inspectResult = service.inspectPackage(Path.of(result.getPackagePath())); + @SuppressWarnings("unchecked") + java.util.List> resources = (java.util.List>) inspectResult.getPayload().get("resources"); + @SuppressWarnings("unchecked") + java.util.List> files = (java.util.List>) resources.get(0).get("files"); + Assertions.assertEquals(2, files.size()); + Assertions.assertTrue(files.stream().anyMatch(file -> + "resources/STANDARD_CMSP_APP_CONFIG/application-prd.properties".equals(file.get("entryPath")) + && "/home/cmep4i/cmsp/application-prd.properties".equals(file.get("restorePath")))); + Assertions.assertTrue(files.stream().anyMatch(file -> + "resources/STANDARD_CMSP_APP_CONFIG/application-prd-node2.properties".equals(file.get("entryPath")) + && "/home/cmep4i/cmsp/application-prd-node2.properties".equals(file.get("restorePath")))); + } + @Test void shouldCollectUserKeysFromEntityCertificateKeyIndexes() throws Exception { ResourceBackupProperties properties = new ResourceBackupProperties(); @@ -167,7 +226,7 @@ class ResourcePackageServiceTest { 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())) + Mockito.when(pcieCryptoService.userKeySignWithSm3(Mockito.any())) .thenReturn(backup(new byte[]{0x11, 0x22, 0x33})); Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class))) .thenAnswer(invocation -> backup(invocation.getArgument(0).getCipherBlob())); @@ -180,7 +239,7 @@ class ResourcePackageServiceTest { Mockito.when(keyEntityRepository.findAll()).thenReturn(List.of(keyEntity(3), keyEntity(2), keyEntity(2))); ResourcePackageService service = new ResourcePackageServiceImpl( properties, - new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties), + new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService), mockDatabaseBackupCollector(), new ResourceUserKeyBackupCollector(keyEntityRepository, pcieCryptoService, new ObjectMapper()), new MqReplayContextBuilder(new ObjectMapper()), @@ -297,6 +356,11 @@ class ResourcePackageServiceTest { cmepDump.setContent("CREATE TABLE c_demo(id bigint);\nINSERT INTO c_demo VALUES (2);\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)); Mockito.when(collector.collectTmsDatabaseDump()).thenReturn(tmsDump); Mockito.when(collector.collectCmepDatabaseDump()).thenReturn(cmepDump); + DatabaseBackupCollector.CollectedDatabaseDump orgDump = new DatabaseBackupCollector.CollectedDatabaseDump(); + orgDump.setDatabaseName("AAAABBBBXXX"); + orgDump.setFileName("AAAABBBBXXX.sql"); + orgDump.setContent("CREATE TABLE org_demo(id bigint);\nINSERT INTO org_demo VALUES (3);\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Mockito.when(collector.collectDatabaseDump("AAAABBBBXXX", "AAAABBBBXXX.sql")).thenReturn(orgDump); return collector; } @@ -309,8 +373,6 @@ class ResourcePackageServiceTest { private static CreateResourceBackupRequest sampleRequest() { CreateResourceBackupRequest request = new CreateResourceBackupRequest(); - request.setIncludeUpgradeHistory(true); - request.setIncludeAuditBackups(false); request.setRemark("before hardware replacement"); return request; } diff --git a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractorTest.java b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractorTest.java index 79a7978..563dcde 100644 --- a/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractorTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/packagex/ResourcePayloadExtractorTest.java @@ -29,6 +29,7 @@ class ResourcePayloadExtractorTest { {"code":"TMS_CONFIG","kind":"FILE","restorePath":"%s/config", "files":[{"entryPath":"files/TMS_CONFIG/application.yml","restorePath":"%s/config/application.yml"}]}, {"code":"TMS_DB","kind":"DATABASE","entryPath":"db/TMS.sql"}, + {"code":"ORG_DB","kind":"DATABASE","databaseName":"AAAABBBBXXX","entryPath":"db/AAAABBBBXXX.sql"}, {"code":"MQ_REPLAY","kind":"MQ_REPLAY","entryPath":"mq/mq-restore-context.json"} ]} """.formatted(tempDir, tempDir), @@ -53,7 +54,8 @@ class ResourcePayloadExtractorTest { Files.readString(workDir.resolve("restore-files.tsv")) ); Assertions.assertEquals( - "db/TMS.sql\tTMS" + System.lineSeparator(), + "db/TMS.sql\tTMS" + System.lineSeparator() + + "db/AAAABBBBXXX.sql\tAAAABBBBXXX" + System.lineSeparator(), Files.readString(workDir.resolve("restore-databases.tsv")) ); Assertions.assertEquals( diff --git a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java index bfca9cb..afe38bf 100644 --- a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskRepositoryTest.java @@ -2,15 +2,11 @@ package com.cisd.tms.modules.backup.repository; import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; -import com.cisd.tms.modules.backup.entity.ResourceTaskStepEntity; import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper; import com.cisd.tms.modules.backup.mapper.ResourceRestoreTaskMapper; -import com.cisd.tms.modules.backup.mapper.ResourceTaskStepMapper; import com.cisd.tms.modules.backup.repository.impl.ResourceBackupTaskRepositoryImpl; import com.cisd.tms.modules.backup.repository.impl.ResourceRestoreTaskRepositoryImpl; -import com.cisd.tms.modules.backup.repository.impl.ResourceTaskStepRepositoryImpl; import java.lang.reflect.Method; -import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.apache.ibatis.annotations.Select; @@ -82,33 +78,6 @@ class ResourceTaskRepositoryTest { Assertions.assertEquals("NONE", found.getErrorCode()); } - @Test - void shouldSaveAndQueryTaskStepsByTaskId() { - ResourceTaskStepMapper mapper = Mockito.mock(ResourceTaskStepMapper.class); - ResourceTaskStepRepository repository = new ResourceTaskStepRepositoryImpl(mapper); - ResourceTaskStepEntity entity = new ResourceTaskStepEntity(); - entity.setTaskId("RRST-001"); - entity.setTaskType("RESOURCE_RESTORE"); - entity.setStepNo(1); - entity.setStepCode("SHELL_APPLY"); - entity.setStatus("SUCCESS"); - entity.setLogPath("/home/tms/tmp/resource-restore/RRST-001/restore.log"); - entity.setExitCode(0); - - repository.save(entity); - - Mockito.verify(mapper).insert(entity); - Mockito.when(mapper.selectByTaskIdOrderByStepNo("RRST-001")).thenReturn(List.of(entity)); - - List steps = repository.findByTaskIdOrderByStepNo("RRST-001"); - - Assertions.assertEquals(1, steps.size()); - Assertions.assertEquals("RESOURCE_RESTORE", steps.get(0).getTaskType()); - Assertions.assertEquals("SHELL_APPLY", steps.get(0).getStepCode()); - Assertions.assertEquals("/home/tms/tmp/resource-restore/RRST-001/restore.log", steps.get(0).getLogPath()); - Assertions.assertEquals(0, steps.get(0).getExitCode()); - } - @Test void shouldDeclareExplicitSelectColumnsInMappers() throws Exception { assertSelectSql( @@ -132,16 +101,6 @@ class ResourceTaskRepositoryTest { "consistency_report_json", "error_code" ); - assertSelectSql( - ResourceTaskStepMapper.class.getMethod("selectByTaskIdOrderByStepNo", String.class), - "tms_resource_task_step", - "task_id", - "task_type", - "step_no", - "step_code", - "log_path", - "exit_code" - ); } private static void assertSelectSql(Method method, String tableName, String... requiredColumns) { diff --git a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java index da18b2d..c869b10 100644 --- a/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/repository/ResourceTaskSchemaContractTest.java @@ -27,12 +27,7 @@ class ResourceTaskSchemaContractTest { "source_product_type VARCHAR(16) NOT NULL", "target_product_type VARCHAR(16) NOT NULL", "consistency_report_json JSON NULL", - "error_code VARCHAR(64) NULL", - "CREATE TABLE IF NOT EXISTS tms_resource_task_step", - "task_type VARCHAR(32) NOT NULL", - "step_code VARCHAR(64) NOT NULL", - "log_path VARCHAR(1024) NULL", - "exit_code INT NULL"); + "error_code VARCHAR(64) NULL"); assertContainsAll(v1Sql, "CREATE TABLE IF NOT EXISTS tms_resource_backup_task", @@ -49,46 +44,10 @@ class ResourceTaskSchemaContractTest { "source_product_type VARCHAR(16) NOT NULL", "target_product_type VARCHAR(16) NOT NULL", "consistency_report_json JSON NULL", - "error_code VARCHAR(64) NULL", - "CREATE TABLE IF NOT EXISTS tms_resource_task_step", - "task_type VARCHAR(32) NOT NULL", - "step_code VARCHAR(64) NOT NULL", - "log_path VARCHAR(1024) NULL", - "exit_code INT NULL"); + "error_code VARCHAR(64) NULL"); - assertEnumConstants( - "com.cisd.tms.modules.backup.enums.ResourceTaskStatus", - "PENDING", - "PRECHECKING", - "PRECHECK_FAILED", - "READY", - "APPLYING", - "VERIFYING", - "SUCCESS", - "FAILED", - "CANCELLED" - ); - assertEnumConstants( - "com.cisd.tms.modules.backup.enums.ResourceTaskType", - "RESOURCE_BACKUP", - "RESOURCE_RESTORE" - ); - assertEnumConstants( - "com.cisd.tms.modules.backup.enums.ResourceControlPhase", - "TMS_PRECHECK", - "JAVA_EXTRACT", - "SHELL_APPLY", - "DONE" - ); - assertEnumConstants( - "com.cisd.tms.modules.backup.enums.ResourceStepStatus", - "PENDING", - "RUNNING", - "SUCCESS", - "SUCCESS_WITH_WARNINGS", - "FAILED", - "SKIPPED" - ); + Assertions.assertFalse(v6Sql.contains("tms_resource_task_step")); + Assertions.assertFalse(v1Sql.contains("tms_resource_task_step")); } private static String readClasspathResource(String path) throws Exception { @@ -103,13 +62,4 @@ class ResourceTaskSchemaContractTest { } } - @SuppressWarnings({"rawtypes", "unchecked"}) - private static void assertEnumConstants(String className, String... expectedNames) throws Exception { - Class enumClass = Class.forName(className); - Assertions.assertTrue(enumClass.isEnum(), className + " should be an enum"); - Class typedEnumClass = (Class) enumClass.asSubclass(Enum.class); - for (String expectedName : expectedNames) { - Enum.valueOf(typedEnumClass, expectedName); - } - } } diff --git a/src/test/java/com/cisd/tms/modules/backup/restore/ApplyResourceBackupScriptContractTest.java b/src/test/java/com/cisd/tms/modules/backup/restore/ApplyResourceBackupScriptContractTest.java index 1f18313..0fb80ff 100644 --- a/src/test/java/com/cisd/tms/modules/backup/restore/ApplyResourceBackupScriptContractTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/restore/ApplyResourceBackupScriptContractTest.java @@ -128,6 +128,29 @@ class ApplyResourceBackupScriptContractTest { Assertions.assertTrue(log.contains("http://127.0.0.1:8080/actuator/health")); } + @Test + void shouldRunConfiguredStopAndStartCommandsInOrder() throws Exception { + Path workDir = tempDir.resolve("work"); + Path payloadDir = workDir.resolve("payload"); + Files.createDirectories(payloadDir); + Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}"); + Path commandLog = tempDir.resolve("service-commands.log"); + Path stopScript = fakeScript("stop-standard-apps.sh", commandLog); + Path startTmsScript = fakeScript("start-tms.sh", commandLog); + Path startStandardScript = fakeScript("start-standard-apps.sh", commandLog); + Files.writeString(workDir.resolve("restore-stop-commands.tsv"), + "STANDARD_APPS\ttrue\t" + stopScript + System.lineSeparator()); + Files.writeString(workDir.resolve("restore-start-commands.tsv"), + "TMS\ttrue\t" + startTmsScript + System.lineSeparator() + + "STANDARD_APPS\ttrue\t" + startStandardScript + System.lineSeparator()); + + runScript(workDir, Map.of()); + + String log = Files.readString(commandLog); + Assertions.assertTrue(log.indexOf("stop-standard-apps.sh") < log.indexOf("start-tms.sh")); + Assertions.assertTrue(log.indexOf("start-tms.sh") < log.indexOf("start-standard-apps.sh")); + } + private static void runScript(Path workDir, Map environment) throws Exception { ProcessBuilder builder = new ProcessBuilder( "bash", diff --git a/src/test/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncherTest.java b/src/test/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncherTest.java index 32d3d9a..cf3473b 100644 --- a/src/test/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncherTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/restore/LightweightRestoreApplierLauncherTest.java @@ -1,6 +1,7 @@ package com.cisd.tms.modules.backup.restore; import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -19,6 +20,22 @@ class LightweightRestoreApplierLauncherTest { properties.setRestoreApplyScriptPath("/home/tms/bin/resource-restore/apply-resource-backup.sh"); properties.setRestoreDbScriptPath("/home/tms/bin/resource-restore/restore-db.sh"); properties.setMqReplayScriptPath("/home/tms/bin/resource-restore/replay-mq.sh"); + ConfiguredRestoreCommand tmsStart = restoreCommand("TMS", "/home/tms/scripts/tms.sh start", true); + ConfiguredRestoreCommand standardStart = restoreCommand( + "STANDARD_APPS", + "/home/tms/bin/start_standard_apps.sh", + true, + "ENTERPRISE", + "INDIRECT" + ); + ConfiguredRestoreCommand directOnlyStart = restoreCommand( + "DIRECT_ONLY", + "/home/tms/bin/start_direct_receiver.sh", + false, + "DIRECT" + ); + properties.setRestoreStartCommands(List.of(tmsStart, standardStart, directOnlyStart)); + properties.setRestoreStopCommands(List.of(restoreCommand("TMS", "/home/tms/scripts/tms.sh stop", false))); RecordingInvoker invoker = new RecordingInvoker(); LightweightRestoreApplierLauncher launcher = new LightweightRestoreApplierLauncher( properties, @@ -29,7 +46,7 @@ class LightweightRestoreApplierLauncherTest { ); Path workDir = tempDir.resolve("RRST-001"); - launcher.launch(workDir); + launcher.launch(workDir, "ENTERPRISE"); Assertions.assertEquals(List.of( "/bin/bash", @@ -48,6 +65,28 @@ class LightweightRestoreApplierLauncherTest { Assertions.assertTrue(env.contains("export DB_PORT='4000'")); Assertions.assertTrue(env.contains("export DB_USER='root'")); Assertions.assertTrue(env.contains("export DB_PASSWORD='p'\\''ass'")); + + String stopCommands = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-stop-commands.tsv")); + Assertions.assertTrue(stopCommands.contains("TMS\tfalse\t/home/tms/scripts/tms.sh stop")); + + String startCommands = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-start-commands.tsv")); + Assertions.assertTrue(startCommands.contains("TMS\ttrue\t/home/tms/scripts/tms.sh start")); + Assertions.assertTrue(startCommands.contains("STANDARD_APPS\ttrue\t/home/tms/bin/start_standard_apps.sh")); + Assertions.assertFalse(startCommands.contains("DIRECT_ONLY")); + } + + private static ConfiguredRestoreCommand restoreCommand( + String code, + String command, + boolean required, + String... productTypes + ) { + ConfiguredRestoreCommand item = new ConfiguredRestoreCommand(); + item.setCode(code); + item.setCommand(command); + item.setRequired(required); + item.setProductTypes(List.of(productTypes)); + return item; } private static final class RecordingInvoker implements RestoreCommandInvoker { diff --git a/src/test/java/com/cisd/tms/modules/backup/restore/RestoreCommandInvokerTest.java b/src/test/java/com/cisd/tms/modules/backup/restore/RestoreCommandInvokerTest.java index ca630d1..eb73a3a 100644 --- a/src/test/java/com/cisd/tms/modules/backup/restore/RestoreCommandInvokerTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/restore/RestoreCommandInvokerTest.java @@ -24,6 +24,34 @@ class RestoreCommandInvokerTest { ); Assertions.assertEquals(0, exitCode); + waitUntilLogContains(logFile, "hello-restore"); Assertions.assertEquals("hello-restore", Files.readString(logFile)); } + + @Test + void shouldStartCommandWithoutWaitingForCompletion() { + ProcessBuilderRestoreCommandInvoker invoker = new ProcessBuilderRestoreCommandInvoker(); + Path logFile = tempDir.resolve("restore-detached.log"); + + long started = System.nanoTime(); + int exitCode = invoker.run( + List.of("/bin/zsh", "-lc", "sleep 2; printf 'done'"), + logFile, + tempDir + ); + long elapsedMillis = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + + Assertions.assertEquals(0, exitCode); + Assertions.assertTrue(elapsedMillis < 1000, "restore command should be detached from caller"); + } + + private static void waitUntilLogContains(Path logFile, String expected) throws Exception { + long deadline = System.currentTimeMillis() + 1000; + while (System.currentTimeMillis() < deadline) { + if (Files.isRegularFile(logFile) && Files.readString(logFile).contains(expected)) { + return; + } + Thread.sleep(20); + } + } } diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java index 903140e..955594e 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java @@ -1,6 +1,5 @@ package com.cisd.tms.modules.backup.service; -import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import com.cisd.tms.modules.backup.discovery.ResourceDiscoveryService; import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse; @@ -17,6 +16,7 @@ import com.cisd.tms.modules.init.entity.InitTaskEntity; import com.cisd.tms.modules.init.repository.InitTaskRepository; import java.util.List; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -59,7 +59,6 @@ class ResourceBackupServiceTest { resourcePackageService, resourceBackupTaskRepository, new ResourceTaskIdGenerator(), - new ResourceBackupProperties(), admissionGuard ); @@ -107,7 +106,6 @@ class ResourceBackupServiceTest { resourcePackageService, resourceBackupTaskRepository, new ResourceTaskIdGenerator(), - new ResourceBackupProperties(), admissionGuard ); @@ -124,6 +122,62 @@ class ResourceBackupServiceTest { Assertions.assertNull(saved.getPackagePath()); } + @Test + void shouldPersistRunningTaskWithConfiguredKeyFingerprintsBeforePackaging() { + InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class); + ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class); + ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class); + ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); + + InitTaskEntity initTask = sampleInitTask(); + List discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true)); + ResourcePackageResult packageResult = new ResourcePackageResult(); + packageResult.setPackageName("RBKP-20260413-153000-000001.tmsbak"); + packageResult.setPackagePath("/tmp/resource-backup/RBKP-TASK-20260413-000001/RBKP-20260413-153000-000001.tmsbak"); + packageResult.setPackageSize(2048L); + packageResult.setPackageHash("SM3-HASH"); + packageResult.setStorageEncKeyFingerprint("PACKAGE-STORAGE-KEY"); + packageResult.setAuthenticityKeyFingerprint("PACKAGE-AUTH-KEY"); + packageResult.setManifestSummaryJson("{\"resourceCount\":1}"); + + AtomicReference runningStatus = new AtomicReference<>(); + AtomicReference runningStorageFingerprint = new AtomicReference<>(); + AtomicReference runningAuthenticityFingerprint = new AtomicReference<>(); + Mockito.doAnswer(invocation -> { + ResourceBackupTaskEntity entity = invocation.getArgument(0); + runningStatus.set(entity.getStatus()); + runningStorageFingerprint.set(entity.getStorageEncKeyFingerprint()); + runningAuthenticityFingerprint.set(entity.getAuthenticityKeyFingerprint()); + return null; + }).when(resourceBackupTaskRepository).save(Mockito.any(ResourceBackupTaskEntity.class)); + + Mockito.when(initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")).thenReturn(Optional.of(initTask)); + Mockito.when(resourceDiscoveryService.discover(initTask)).thenReturn(discoveredItems); + Mockito.when(resourcePackageService.createPackage( + Mockito.anyString(), + Mockito.anyString(), + Mockito.same(initTask), + Mockito.eq(discoveredItems), + Mockito.any(CreateResourceBackupRequest.class) + )).thenReturn(packageResult); + + ResourceBackupService service = new ResourceBackupServiceImpl( + initTaskRepository, + resourceDiscoveryService, + resourcePackageService, + resourceBackupTaskRepository, + new ResourceTaskIdGenerator(), + admissionGuard + ); + + service.createBackupTask(sampleRequest()); + + Assertions.assertEquals("RUNNING", runningStatus.get()); + Assertions.assertEquals("USER_KEY_ENC:1", runningStorageFingerprint.get()); + Assertions.assertEquals("USER_KEY_SIGN:1", runningAuthenticityFingerprint.get()); + } + private static InitTaskEntity sampleInitTask() { InitTaskEntity entity = new InitTaskEntity(); entity.setTaskId("INIT-20260408-000001"); @@ -147,8 +201,6 @@ class ResourceBackupServiceTest { private static CreateResourceBackupRequest sampleRequest() { CreateResourceBackupRequest request = new CreateResourceBackupRequest(); - request.setIncludeUpgradeHistory(true); - request.setIncludeAuditBackups(false); request.setRemark("before hardware replacement"); return request; } diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckLightweightServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckLightweightServiceTest.java index fb004f1..770a6c8 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckLightweightServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourcePrecheckLightweightServiceTest.java @@ -1,7 +1,6 @@ package com.cisd.tms.modules.backup.service; import com.cisd.tms.common.config.properties.CisdPresetProperties; -import com.cisd.tms.modules.backup.config.ResourceBackupProperties; import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse; import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult; @@ -30,9 +29,6 @@ class ResourcePrecheckLightweightServiceTest { void shouldExposeLightweightRestorePlanWithoutDecryptingPayload() { FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePackageService packageService = Mockito.mock(ResourcePackageService.class); - ResourceBackupProperties backupProperties = new ResourceBackupProperties(); - backupProperties.setStorageEncKeyIndex(7); - backupProperties.setAuthenticitySignKeyIndex(9); CisdPresetProperties presetProperties = new CisdPresetProperties(); presetProperties.setProductType("ENTERPRISE"); InMemoryResourcePrecheckStateStore store = new InMemoryResourcePrecheckStateStore(); @@ -50,8 +46,7 @@ class ResourcePrecheckLightweightServiceTest { packageService, store, new ResourceTaskIdGenerator(), - presetProperties, - backupProperties + presetProperties ); ResourceRestorePrecheckRequest request = new ResourceRestorePrecheckRequest(); request.setFileId("FILE-20260429-000001"); @@ -59,6 +54,7 @@ class ResourcePrecheckLightweightServiceTest { ResourceRestorePrecheckResponse response = service.precheck(request); Assertions.assertEquals(List.of("JAVA_EXTRACT", "SHELL_APPLY"), response.getRestorePlan()); + Assertions.assertTrue(response.getPrecheckPassed()); Assertions.assertFalse(response.getPayloadDecrypted()); Assertions.assertTrue(response.getSignatureValid()); Assertions.assertTrue(store.find(response.getPrecheckId()).isPresent()); @@ -72,8 +68,8 @@ class ResourcePrecheckLightweightServiceTest { manifest.put("orgCode", "AAAABBBBXXX"); manifest.put("backupTime", "2026-04-29T10:00:00"); manifest.put("resourceCount", 3); - manifest.put("storageEncKeyFingerprint", "USER_KEY_ENC:7"); - manifest.put("authenticityKeyFingerprint", "USER_KEY_SIGN:9"); + manifest.put("storageEncKeyFingerprint", "USER_KEY_ENC:1"); + manifest.put("authenticityKeyFingerprint", "USER_KEY_SIGN:1"); ResourcePackageInspectResult result = new ResourcePackageInspectResult(); result.setManifest(manifest); return result; 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 1bc253c..4c5d455 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 @@ -10,6 +10,7 @@ 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.ActiveRestoreStateStore; import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher; import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl; import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard; @@ -34,7 +35,7 @@ class ResourceRestoreHandoffServiceTest { Path tempDir; @Test - void shouldCreateRestoreTaskExtractPayloadAndLaunchLightweightApplier() throws Exception { + void shouldCreateRestoreTaskExtractPayloadAndWaitForApplyConfirmation() throws Exception { ResourceBackupProperties properties = new ResourceBackupProperties(); properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString()); properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString()); @@ -70,6 +71,8 @@ class ResourceRestoreHandoffServiceTest { Mockito.when(payloadExtractor.extract(Mockito.eq(packagePath), Mockito.any(Path.class))) .thenAnswer(invocation -> { Path taskDir = invocation.getArgument(1); + Files.createDirectories(taskDir); + Files.writeString(taskDir.resolve("restore-context.json"), "{\"schemaVersion\":\"1.0\"}"); ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult(); result.setPayloadDir(taskDir.resolve("payload")); return result; @@ -84,13 +87,12 @@ class ResourceRestoreHandoffServiceTest { payloadExtractor, userKeyRestoreService, applierLauncher, - admissionGuard + admissionGuard, + new ObjectMapper(), + new ActiveRestoreStateStore(properties, new ObjectMapper()) ); - CreateResourceRestoreRequest request = new CreateResourceRestoreRequest(); - request.setPrecheckId("RPRE-20260413-000001"); - request.setConfirmBackupId("RBKP-20260413-153000-000001"); - request.setConfirmRiskAccepted(true); + CreateResourceRestoreRequest request = validCreateRequest(); CreateResourceRestoreResponse response = service.createRestoreTask(request); @@ -100,14 +102,77 @@ class ResourceRestoreHandoffServiceTest { Mockito.verify(repository).save(captor.capture()); ResourceRestoreTaskEntity saved = captor.getValue(); Assertions.assertEquals(packagePath.toString(), saved.getPackagePath()); - Assertions.assertEquals("APPLYING", saved.getStatus()); + Assertions.assertEquals("READY_TO_APPLY", saved.getStatus()); Assertions.assertEquals(taskDir.resolve("payload").toString(), saved.getStagingDir()); Assertions.assertEquals(taskDir.resolve("status.json").toString(), saved.getRunnerStatePath()); Assertions.assertEquals("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]", saved.getRestorePlanJson()); + String restoreContext = Files.readString(taskDir.resolve("restore-context.json")); + Assertions.assertTrue(restoreContext.contains("\"targetDeployMode\" : \"DUAL\"")); + Assertions.assertTrue(restoreContext.contains("\"node01Ip\" : \"192.168.1.11\"")); + Assertions.assertTrue(restoreContext.contains("\"node02Ip\" : \"192.168.1.12\"")); Mockito.verify(payloadExtractor).extract(packagePath, taskDir); Mockito.verify(userKeyRestoreService).recover(taskDir.resolve("payload")); - Mockito.verify(applierLauncher).launch(taskDir); + Mockito.verifyNoInteractions(applierLauncher); + + String activeRestore = Files.readString(tempDir.resolve("resource-restore").resolve("active-restore.json")); + Assertions.assertTrue(activeRestore.contains("\"taskId\" : \"" + response.getTaskId() + "\"")); + Assertions.assertTrue(activeRestore.contains("\"status\" : \"READY_TO_APPLY\"")); + } + + @Test + void shouldApplyPreparedRestoreTaskAndLaunchLightweightApplier() throws Exception { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString()); + 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 taskDir = tempDir.resolve("resource-restore").resolve("RRST-TASK-20260413-000001"); + Files.createDirectories(taskDir); + ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity(); + entity.setTaskId("RRST-TASK-20260413-000001"); + entity.setPrecheckId("RPRE-20260413-000001"); + entity.setBackupId("RBKP-20260413-153000-000001"); + entity.setStatus("READY_TO_APPLY"); + entity.setSourceProductType("ENTERPRISE"); + entity.setTargetProductType("ENTERPRISE"); + entity.setPackageFileId("FILE-20260413-000001"); + entity.setPackagePath(tempDir.resolve("download").resolve("RBKP.tmsbak").toString()); + entity.setStagingDir(taskDir.resolve("payload").toString()); + entity.setRunnerStatePath(taskDir.resolve("status.json").toString()); + entity.setStorageEncKeyFingerprint("USER_KEY_ENC:1"); + entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:1"); + entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]"); + entity.setCreateBy("SYSTEM"); + entity.setCreateTime(LocalDateTime.parse("2026-04-13T15:30:00")); + Mockito.when(repository.findByTaskId("RRST-TASK-20260413-000001")).thenReturn(java.util.Optional.of(entity)); + ResourceRestoreService service = new ResourceRestoreServiceImpl( + repository, + fileRecordRepository, + new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper()), + new ResourceTaskIdGenerator(), + properties, + payloadExtractor, + userKeyRestoreService, + applierLauncher, + admissionGuard, + new ObjectMapper(), + new ActiveRestoreStateStore(properties, new ObjectMapper()) + ); + + CreateResourceRestoreResponse response = service.applyRestoreTask("RRST-TASK-20260413-000001"); + + Assertions.assertEquals("RRST-TASK-20260413-000001", response.getTaskId()); + Assertions.assertEquals("APPLYING", response.getStatus()); + Mockito.verify(repository).update(entity); + Mockito.verify(applierLauncher).launch(taskDir, "ENTERPRISE"); + Assertions.assertEquals("APPLYING", entity.getStatus()); + Assertions.assertNotNull(entity.getStartTime()); + String activeRestore = Files.readString(tempDir.resolve("resource-restore").resolve("active-restore.json")); + Assertions.assertTrue(activeRestore.contains("\"status\" : \"APPLYING\"")); } @Test @@ -134,7 +199,9 @@ class ResourceRestoreHandoffServiceTest { payloadExtractor, userKeyRestoreService, applierLauncher, - admissionGuard + admissionGuard, + new ObjectMapper(), + new ActiveRestoreStateStore(properties, new ObjectMapper()) ); BizException exception = Assertions.assertThrows(BizException.class, @@ -168,7 +235,9 @@ class ResourceRestoreHandoffServiceTest { payloadExtractor, userKeyRestoreService, applierLauncher, - admissionGuard + admissionGuard, + new ObjectMapper(), + new ActiveRestoreStateStore(properties, new ObjectMapper()) ); BizException exception = Assertions.assertThrows(BizException.class, @@ -178,6 +247,42 @@ class ResourceRestoreHandoffServiceTest { Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher); } + @Test + void shouldRejectDualRestoreWhenNode02IpIsMissing() { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setPrecheckStoreDir(tempDir.resolve("prechecks").toString()); + properties.setRestoreTaskRootDir(tempDir.resolve("resource-restore").toString()); + FileSystemResourcePrecheckStateStore store = new FileSystemResourcePrecheckStateStore(properties, new ObjectMapper()); + 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); + store.save(new ResourcePrecheckContext("FILE-20260413-000001", validPrecheckResponse())); + ResourceRestoreService service = new ResourceRestoreServiceImpl( + repository, + fileRecordRepository, + store, + new ResourceTaskIdGenerator(), + properties, + payloadExtractor, + userKeyRestoreService, + applierLauncher, + admissionGuard, + new ObjectMapper(), + new ActiveRestoreStateStore(properties, new ObjectMapper()) + ); + CreateResourceRestoreRequest request = validCreateRequest(); + request.getNodes().setNode02Ip(""); + + BizException exception = Assertions.assertThrows(BizException.class, + () -> service.createRestoreTask(request)); + + Assertions.assertTrue(exception.getMessage().contains("nodes.node02Ip不能为空")); + Mockito.verifyNoInteractions(repository, fileRecordRepository, payloadExtractor, applierLauncher); + } + private static ResourceRestorePrecheckResponse validPrecheckResponse() { ResourceRestorePrecheckResponse precheckResponse = new ResourceRestorePrecheckResponse(); precheckResponse.setPrecheckId("RPRE-20260413-000001"); @@ -196,7 +301,11 @@ class ResourceRestoreHandoffServiceTest { CreateResourceRestoreRequest request = new CreateResourceRestoreRequest(); request.setPrecheckId("RPRE-20260413-000001"); request.setConfirmBackupId("RBKP-20260413-153000-000001"); - request.setConfirmRiskAccepted(true); + request.setDeployMode("DUAL"); + CreateResourceRestoreRequest.Nodes nodes = new CreateResourceRestoreRequest.Nodes(); + nodes.setNode01Ip("192.168.1.11"); + nodes.setNode02Ip("192.168.1.12"); + request.setNodes(nodes); return request; } } diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java index 28c662f..368ac18 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskQueryServiceTest.java @@ -1,16 +1,16 @@ package com.cisd.tms.modules.backup.service; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; -import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; +import com.cisd.tms.modules.backup.config.ResourceBackupProperties; +import com.cisd.tms.modules.backup.dto.response.ResourceRestoreLatestResponse; import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.restore.ActiveRestoreStateStore; import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader; import com.cisd.tms.modules.backup.service.impl.ResourceTaskQueryServiceImpl; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -23,74 +23,42 @@ class ResourceTaskQueryServiceTest { Path tempDir; @Test - void shouldPreferLightweightStatusFileWhenPresent() throws Exception { + void shouldReturnLatestRestoreFromActiveFilesAndRepairMissingDbRecord() throws Exception { + ResourceBackupProperties properties = resourceBackupProperties(); Path taskDir = tempDir.resolve("RRST-TASK-20260429-000001"); Files.createDirectories(taskDir); Files.writeString(taskDir.resolve("status.json"), """ { - "status":"RUNNING", - "phase":"MQ_REPLAY", - "message":"replaying mq", - "updatedAt":"2026-04-29T10:00:00+0800" + "status":"FAILED", + "phase":"DATABASE", + "message":"数据库恢复失败", + "updatedAt":"2026-04-29T10:05:00+0800" } """); - Files.writeString(taskDir.resolve("restore.log"), "line1\nline2\n"); - + ActiveRestoreStateStore activeStore = new ActiveRestoreStateStore(properties, new ObjectMapper()); ResourceRestoreTaskEntity entity = restoreTask(taskDir); + activeStore.write(entity, "APPLYING"); ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); - Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.of(entity)); - + Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.empty()); ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl( repository, - new ObjectMapper(), - new RestoreStatusFileReader(new ObjectMapper()) + new RestoreStatusFileReader(new ObjectMapper()), + activeStore ); - var detail = service.getRestoreTask("RRST-TASK-20260429-000001"); + ResourceRestoreLatestResponse latest = service.getLatestRestore(); - Assertions.assertEquals("RUNNING", detail.getStatus()); - Assertions.assertEquals("MQ_REPLAY", detail.getControlPhase()); - Assertions.assertEquals("SHELL_STATUS", detail.getStateSource()); - Assertions.assertEquals("MQ_REPLAY", detail.getCurrentStepCode()); - Assertions.assertEquals("2026-04-29T10:00:00+0800", detail.getLastHeartbeatAt()); - Assertions.assertEquals("replaying mq", detail.getErrorMessage()); - } - - @Test - void shouldExposeLightweightStepsAndRestoreLog() throws Exception { - Path taskDir = tempDir.resolve("RRST-TASK-20260429-000001"); - Files.createDirectories(taskDir); - Files.writeString(taskDir.resolve("status.json"), """ - { - "status":"RUNNING", - "phase":"MQ_REPLAY", - "message":"replaying mq", - "updatedAt":"2026-04-29T10:00:00+0800" - } - """); - Files.writeString(taskDir.resolve("restore.log"), "line1\nline2\n"); - - ResourceRestoreTaskEntity entity = restoreTask(taskDir); - ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class); - Mockito.when(repository.findByTaskId("RRST-TASK-20260429-000001")).thenReturn(Optional.of(entity)); - - ResourceTaskQueryService service = new ResourceTaskQueryServiceImpl( - repository, - new ObjectMapper(), - new RestoreStatusFileReader(new ObjectMapper()) - ); - - List steps = service.getRestoreTaskSteps("RRST-TASK-20260429-000001"); - Assertions.assertEquals(2, steps.size()); - Assertions.assertEquals("SUCCESS", steps.get(0).getStatus()); - Assertions.assertEquals("RUNNING", steps.get(1).getStatus()); - Assertions.assertEquals("replaying mq", steps.get(1).getMessage()); - Assertions.assertEquals(Boolean.TRUE, steps.get(1).getLogAvailable()); - - ResourceTaskStepLogResponse log = service.getRestoreTaskStepLog("RRST-TASK-20260429-000001", 2); - Assertions.assertEquals("SHELL_APPLY", log.getStepCode()); - Assertions.assertEquals("line1\nline2\n", log.getContent()); - Assertions.assertFalse(log.getTruncated()); + Assertions.assertEquals("RRST-TASK-20260429-000001", latest.getTaskId()); + Assertions.assertEquals("RBKP-20260429-153000-000001", latest.getBackupId()); + Assertions.assertEquals("FAILED", latest.getStatus()); + Assertions.assertEquals("DATABASE", latest.getPhase()); + Assertions.assertEquals("数据库恢复失败", latest.getMessage()); + Assertions.assertEquals("2026-04-29T10:05:00+0800", latest.getFinishedAt()); + Mockito.verify(repository).save(Mockito.argThat(repaired -> + "RRST-TASK-20260429-000001".equals(repaired.getTaskId()) + && "FAILED".equals(repaired.getStatus()) + && "数据库恢复失败".equals(repaired.getErrorMessage()) + )); } private static ResourceRestoreTaskEntity restoreTask(Path taskDir) { @@ -108,4 +76,10 @@ class ResourceTaskQueryServiceTest { entity.setCreateTime(LocalDateTime.parse("2026-04-29T09:59:00")); return entity; } + + private ResourceBackupProperties resourceBackupProperties() { + ResourceBackupProperties properties = new ResourceBackupProperties(); + properties.setRestoreTaskRootDir(tempDir.toString()); + return properties; + } }