资源备份流程整理

This commit is contained in:
waner
2026-05-09 11:45:12 +08:00
parent 8229b3e44e
commit 524824bf02
62 changed files with 2856 additions and 1178 deletions
+21 -3
View File
@@ -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 <workDir>`,由 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/<orgCode>.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`
+117 -1
View File
@@ -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
@@ -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<T>`:
```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 <token>
X-Request-Timestamp: <unix秒时间戳>
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<T> {
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<ResourceBackupTaskDetailResponse | CreateResourceBackupResponse>();
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<ApiResponse<CreateResourceBackupResponse>>(
'/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<ApiResponse<ResourceBackupTaskDetailResponse>>(
`/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<File>();
const precheckResult = ref<ResourceRestorePrecheckResponse>();
const latestRestore = ref<ResourceRestoreLatestResponse>();
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<ApiResponse<{ fileId: string }>>(
'/api/v1/files/upload',
form
);
if (!upload.data.success) throw new Error(upload.data.msg);
const precheck = await request.post<ApiResponse<ResourceRestorePrecheckResponse>>(
'/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<ApiResponse<CreateResourceRestoreResponse>>(
'/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<ApiResponse<CreateResourceRestoreResponse>>(
`/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<ApiResponse<ResourceRestoreLatestResponse>>(
'/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
<script setup lang="ts">
import { computed, onMounted } from 'vue';
const { latestRestore, loadLatestRestore } = useResourceRestore();
onMounted(loadLatestRestore);
const latestText = computed(() => {
const latest = latestRestore.value;
if (!latest || latest.status === 'NONE') return '暂无资源恢复记录';
if (latest.status === 'SUCCESS') return `最近恢复成功:${latest.finishedAt ?? ''}`;
if (latest.status === 'FAILED') {
return `最近恢复失败:${latest.phase ?? ''} ${latest.message ?? ''}`;
}
return `最近恢复状态:${latest.status}`;
});
</script>
<template>
<div>{{ latestText }}</div>
</template>
```
## 8. 交互和展示建议
- 备份创建按钮点击后立即禁用,避免重复提交。
- 恢复预检通过后,必须展示备份号 `backupId`,让用户确认正在恢复哪个包。
- `precheckPassed=false` 时不要允许创建恢复任务。
- 恢复流程只做一次用户确认;确认后前端按顺序调用创建恢复任务和 apply。
- apply 返回后不要继续请求恢复步骤接口;恢复侧没有步骤接口。
- TMS 重启后只调用 latest 展示最近恢复结果。
- `payloadDecrypted=false` 不表示失败,它表示预检阶段没有解密完整 payload。
- `TMS_DB` 默认不包含角色、授权、会话和审计表,因此恢复不会覆盖新机器已有登录授权状态。
- 服务端路径类字段不展示给普通用户,只用于运维排查。
@@ -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
@@ -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<String> 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<ConfiguredRestoreCommand> restoreStopCommands = List.of();
private List<ConfiguredRestoreCommand> restoreStartCommands = List.of();
private List<String> allowedRestoreRoots = List.of(
"/home/tms/config",
"/home/cmep4i",
@@ -103,6 +112,14 @@ public class ResourceBackupProperties {
this.tmsDatabaseName = tmsDatabaseName;
}
public List<String> getTmsDbExcludedTables() {
return tmsDbExcludedTables;
}
public void setTmsDbExcludedTables(List<String> 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<ConfiguredRestoreCommand> getRestoreStopCommands() {
return restoreStopCommands;
}
public void setRestoreStopCommands(List<ConfiguredRestoreCommand> restoreStopCommands) {
this.restoreStopCommands = restoreStopCommands;
}
public List<ConfiguredRestoreCommand> getRestoreStartCommands() {
return restoreStartCommands;
}
public void setRestoreStartCommands(List<ConfiguredRestoreCommand> restoreStartCommands) {
this.restoreStartCommands = restoreStartCommands;
}
public List<String> getAllowedRestoreRoots() {
return allowedRestoreRoots;
}
@@ -167,33 +184,4 @@ public class ResourceBackupProperties {
this.fileResources = fileResources;
}
public List<ConfiguredFileBackupResource> 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;
}
}
@@ -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<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) {
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody(required = false) CreateResourceBackupRequest request) {
return ApiResponse.success(resourceBackupService.createBackupTask(request));
}
@@ -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<ResourceRestoreTaskDetailResponse> taskDetail(
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "执行资源恢复任务")
@Operation(summary = "执行资源恢复任务", description = "用户确认停机恢复后,启动后台恢复脚本。")
public ApiResponse<CreateResourceRestoreResponse> 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<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "恢复任务号")
@PathVariable String taskId
) {
return ApiResponse.success(resourceTaskQueryService.getRestoreTaskSteps(taskId));
@Operation(summary = "查询最近资源恢复结果", description = "TMS 重启后用于恢复页面展示最近一次恢复时间、结果和失败原因。")
public ApiResponse<ResourceRestoreLatestResponse> 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<ResourceTaskStepLogResponse> taskStepLog(
@Parameter(description = "恢复任务号")
@PathVariable String taskId,
@Parameter(description = "步骤序号")
@PathVariable int stepNo
) {
return ApiResponse.success(resourceTaskQueryService.getRestoreTaskStepLog(taskId, stepNo));
}
}
@@ -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() {
}
}
@@ -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);
@@ -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<DiscoveredResourceItem> items = new ArrayList<>();
// TMS 自身配置目录是恢复后服务能否按原部署参数启动的基础资源,按目录递归打包。
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
// 收发器配置和 license 按产品形态切换路径:标准版走 cmep4i,直连版走 direct 目标路径。
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
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<DiscoveredResourceItem> items, String productType, String mqType) {
List<ConfiguredFileBackupResource> 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<DiscoveredResourceItem> items, String code) {
items.removeIf(item -> code.equals(item.getResourceCode()));
}
private static boolean matches(List<String> 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();
}
}
@@ -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;
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -16,6 +16,7 @@ public class ResourceRestorePrecheckResponse {
private Boolean keysetMatched;
private Boolean signatureValid;
private Boolean compatible;
private Boolean precheckPassed;
private Boolean payloadDecrypted;
private List<String> warnings;
private List<String> 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;
}
@@ -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<String> cleanupWarnings;
private List<String> warnings;
private List<String> blockingIssues;
private List<String> 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<String> getCleanupWarnings() {
return cleanupWarnings;
}
public void setCleanupWarnings(List<String> cleanupWarnings) {
this.cleanupWarnings = cleanupWarnings;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
public List<String> getBlockingIssues() {
return blockingIssues;
}
public void setBlockingIssues(List<String> blockingIssues) {
this.blockingIssues = blockingIssues;
}
public List<String> getRestorePlan() {
return restorePlan;
}
public void setRestorePlan(List<String> 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;
}
}
@@ -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<String> productTypes = List.of();
private List<String> mqTypes = List.of();
private boolean required = true;
public String getCode() {
@@ -49,6 +53,22 @@ public class ConfiguredFileBackupResource {
this.restorePath = restorePath;
}
public java.util.List<String> getProductTypes() {
return productTypes;
}
public void setProductTypes(java.util.List<String> productTypes) {
this.productTypes = productTypes;
}
public java.util.List<String> getMqTypes() {
return mqTypes;
}
public void setMqTypes(java.util.List<String> mqTypes) {
this.mqTypes = mqTypes;
}
public boolean isRequired() {
return required;
}
@@ -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<String> 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<String> getProductTypes() {
return productTypes;
}
public void setProductTypes(List<String> productTypes) {
this.productTypes = productTypes;
}
}
@@ -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;
}
}
@@ -1,11 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 恢复任务控制阶段。
*/
public enum ResourceControlPhase {
TMS_PRECHECK,
JAVA_EXTRACT,
SHELL_APPLY,
DONE
}
@@ -1,13 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源任务步骤状态。
*/
public enum ResourceStepStatus {
PENDING,
RUNNING,
SUCCESS,
SUCCESS_WITH_WARNINGS,
FAILED,
SKIPPED
}
@@ -1,16 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源备份/恢复任务主状态。
*/
public enum ResourceTaskStatus {
PENDING,
PRECHECKING,
PRECHECK_FAILED,
READY,
APPLYING,
VERIFYING,
SUCCESS,
FAILED,
CANCELLED
}
@@ -1,9 +0,0 @@
package com.cisd.tms.modules.backup.enums;
/**
* 资源任务类型。
*/
public enum ResourceTaskType {
RESOURCE_BACKUP,
RESOURCE_RESTORE
}
@@ -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<ResourceTaskStepEntity> {
@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<ResourceTaskStepEntity> selectByTaskIdOrderByStepNo(@Param("taskId") String taskId);
}
@@ -6,6 +6,8 @@ public interface DatabaseBackupCollector {
CollectedDatabaseDump collectCmepDatabaseDump();
CollectedDatabaseDump collectDatabaseDump(String databaseName, String fileName);
class CollectedDatabaseDump {
private String databaseName;
@@ -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<String> excludedTables
) {
List<String> 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.<String>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) {
}
}
@@ -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<Map<String, Object>> 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<String, Object> 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<Path> matchedFiles;
try (Stream<Path> 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<Map<String, Object>> archivedFiles = new ArrayList<>();
for (Path matchedFile : matchedFiles) {
Map<String, Object> 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> T writeUnchecked(CheckedSupplier<T> supplier) {
try {
return supplier.get();
@@ -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<ResourceTaskStepEntity> findByTaskIdOrderByStepNo(String taskId);
}
@@ -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<ResourceTaskStepEntity> findByTaskIdOrderByStepNo(String taskId) {
List<ResourceTaskStepEntity> steps = resourceTaskStepMapper.selectByTaskIdOrderByStepNo(taskId);
return steps == null ? List.of() : steps;
}
}
@@ -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<ActiveRestoreSnapshot> 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;
}
}
}
@@ -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<ConfiguredRestoreCommand> commands, String targetProductType) {
try {
StringBuilder builder = new StringBuilder();
for (ConfiguredRestoreCommand command : commands == null ? List.<ConfiguredRestoreCommand>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<String> 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<String, String> 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();
}
@@ -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);
}
}
}
@@ -6,4 +6,6 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
public interface ResourceRestoreService {
CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request);
CreateResourceRestoreResponse applyRestoreTask(String taskId);
}
@@ -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<ResourceTaskStepItemResponse> getRestoreTaskSteps(String taskId);
ResourceTaskStepLogResponse getRestoreTaskStepLog(String taskId, int stepNo);
ResourceRestoreLatestResponse getLatestRestore();
}
@@ -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);
@@ -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<String, Object> 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());
}
}
@@ -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<String, Object> context = Files.isRegularFile(contextPath)
? objectMapper.readValue(contextPath.toFile(), new TypeReference<Map<String, Object>>() {
})
: 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<String, Object> targetNodes(CreateResourceRestoreRequest.Nodes nodes) {
Map<String, Object> 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<String, Object> 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(), "资源恢复预检不存在");
@@ -18,7 +18,7 @@ import org.springframework.stereotype.Component;
public class ResourceTaskAdmissionGuard {
private static final Set<String> ACTIVE_BACKUP_STATUSES = Set.of("RUNNING");
private static final Set<String> ACTIVE_RESTORE_STATUSES = Set.of("APPLYING", "RUNNING", "HANDOFF", "PENDING");
private static final Set<String> ACTIVE_RESTORE_STATUSES = Set.of("READY_TO_APPLY", "APPLYING", "RUNNING", "HANDOFF", "PENDING");
private final ResourceBackupTaskRepository resourceBackupTaskRepository;
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
@@ -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<ResourceTaskStepItemResponse> getRestoreTaskSteps(String taskId) {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复任务不存在:" + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
Path taskDir = resolveTaskDir(entity);
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(taskDir);
List<ResourceTaskStepItemResponse> 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<ActiveRestoreStateStore.ActiveRestoreSnapshot> 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<String> 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<String> readStringList(String json) {
if (trim(json).isEmpty()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {
});
} 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<String> 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;
}
}
}
@@ -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);
}
@@ -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);
}
}
}
+99 -2
View File
@@ -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。
@@ -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)
);
-- -----------------------------------------------------------------------------
-- 认证与设备基础表
-- -----------------------------------------------------------------------------
@@ -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)
);
@@ -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\""));
}
}
@@ -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 接口进入停机恢复"));
}
}
@@ -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;
}
}
@@ -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\"")));
}
}
@@ -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());
}
}
@@ -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<byte[]> capturedDek = new AtomicReference<>();
AtomicReference<byte[]> 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<EccInternalEncryptRequest> encryptCaptor = ArgumentCaptor.forClass(EccInternalEncryptRequest.class);
Mockito.verify(pcieCryptoService).sdfeInternalEncryptEcc(encryptCaptor.capture());
Assertions.assertEquals(7, encryptCaptor.getValue().getKeyIndex());
Assertions.assertEquals(1, encryptCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalSignRequest> signCaptor = ArgumentCaptor.forClass(EccInternalSignRequest.class);
Mockito.verify(pcieCryptoService).eccInternalSign(signCaptor.capture());
Assertions.assertEquals(8, signCaptor.getValue().getKeyIndex());
ArgumentCaptor<UserKeySignRequest> signCaptor = ArgumentCaptor.forClass(UserKeySignRequest.class);
Mockito.verify(pcieCryptoService).userKeySignWithSm3(signCaptor.capture());
Assertions.assertEquals(1, signCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalVerifyRequest> verifyCaptor = ArgumentCaptor.forClass(EccInternalVerifyRequest.class);
Mockito.verify(pcieCryptoService).eccInternalVerify(verifyCaptor.capture());
Assertions.assertEquals(8, verifyCaptor.getValue().getKeyIndex());
Assertions.assertEquals(1, verifyCaptor.getValue().getKeyIndex());
ArgumentCaptor<EccInternalDecryptRequest> 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");
}
@@ -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<DiscoveredResourceItem> 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<DiscoveredResourceItem> 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<DiscoveredResourceItem> 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<DiscoveredResourceItem> 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<DiscoveredResourceItem> 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<DiscoveredResourceItem> 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<DiscoveredResourceItem> items, String code, String path) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && path.equals(item.getRestorePath()));
}
private static boolean hasMode(List<DiscoveredResourceItem> items, String code, String mode) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && mode.equals(item.getMode()));
}
private static boolean hasDatabaseName(List<DiscoveredResourceItem> items, String code, String databaseName) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && databaseName.equals(item.getDatabaseName()));
}
private static boolean hasEntryPath(List<DiscoveredResourceItem> items, String code, String entryPath) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && entryPath.equals(item.getEntryPath()));
}
private static boolean hasRequired(List<DiscoveredResourceItem> items, String code) {
return items.stream().anyMatch(item -> code.equals(item.getResourceCode()) && item.isRequired());
}
@@ -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;
}
}
@@ -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<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) 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<String, String> 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.<EccInternalEncryptRequest>getArgument(0).getData()));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalDecryptRequest>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<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> files = (java.util.List<Map<String, Object>>) 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.<EccInternalEncryptRequest>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.<EccInternalDecryptRequest>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;
}
@@ -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(
@@ -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<ResourceTaskStepEntity> 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) {
@@ -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<? extends Enum> typedEnumClass = (Class<? extends Enum>) enumClass.asSubclass(Enum.class);
for (String expectedName : expectedNames) {
Enum.valueOf(typedEnumClass, expectedName);
}
}
}
@@ -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<String, String> environment) throws Exception {
ProcessBuilder builder = new ProcessBuilder(
"bash",
@@ -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 {
@@ -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);
}
}
}
@@ -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<DiscoveredResourceItem> 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<String> runningStatus = new AtomicReference<>();
AtomicReference<String> runningStorageFingerprint = new AtomicReference<>();
AtomicReference<String> 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;
}
@@ -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;
@@ -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;
}
}
@@ -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<ResourceTaskStepItemResponse> 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;
}
}