fix:资源备份和恢复

This commit is contained in:
waner 2026-05-06 13:52:24 +08:00
parent 325d7ae42d
commit 8287289167
17 changed files with 387 additions and 11 deletions

View File

@ -276,6 +276,7 @@ Open:
- Java 会生成 shell 友好的 `restore-files.tsv`、`restore-databases.tsv`、`restore-mq.tsv`
- 创建恢复任务后启动 `apply-resource-backup.sh --work-dir <workDir>`,由 shell 执行停服务、文件覆盖、SQL 导入、MQ replay、起服务和健康检查
- shell 侧状态写入 `status.json`,日志写入 `restore.log`
- 第一版资源恢复是停机恢复。创建恢复任务后当前TMS服务可能中断前端应提示用户恢复期间页面/API可能不可用恢复结果以后续健康检查、重新登录和 `status.json` / `restore.log` 为准。
- `FILE` 回写 manifest 明确声明的配置/License 文件
- `DATABASE` 恢复:
- `db/TMS.sql`

View File

@ -4,6 +4,7 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.util.HttpResponseUtil;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.log.service.OperationAuditService;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -36,7 +37,7 @@ public class InternalAuthorizationInterceptor implements HandlerInterceptor {
RequireInternalAuth requireInternalAuth = findAnnotation(handlerMethod, RequireInternalAuth.class);
if (requireInternalAuth != null) {
String currentRole = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
if (!requireInternalAuth.role().getCode().equals(currentRole)) {
if (!roleAllowed(currentRole, requireInternalAuth)) {
writeForbidden(response, "角色无权限访问");
operationAuditService.recordDeniedLog(request, handlerMethod, "角色无权限访问");
return false;
@ -52,6 +53,22 @@ public class InternalAuthorizationInterceptor implements HandlerInterceptor {
return true;
}
private boolean roleAllowed(String currentRole, RequireInternalAuth requireInternalAuth) {
if (currentRole == null || currentRole.isBlank()) {
return false;
}
RoleCode[] anyRole = requireInternalAuth.anyRole();
if (anyRole.length == 0) {
return requireInternalAuth.role().getCode().equals(currentRole);
}
for (RoleCode roleCode : anyRole) {
if (roleCode.getCode().equals(currentRole)) {
return true;
}
}
return false;
}
private boolean authLevelAllowed(String currentAuthLevel, AuthLevel requiredAuthLevel) {
if (currentAuthLevel == null) {
return false;

View File

@ -11,7 +11,9 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireInternalAuth {
RoleCode role();
RoleCode role() default RoleCode.SUPER_ADMIN;
RoleCode[] anyRole() default {};
AuthLevel authLevel();
}

View File

@ -1,6 +1,9 @@
package com.cisd.tms.modules.backup.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.security.RequireInternalAuth;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse;
@ -8,6 +11,9 @@ 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.ResourceBackupService;
import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -40,12 +46,15 @@ public class ResourceBackupController {
@PostMapping
@ReplayProtected
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.BACKUP, summary = "创建资源备份任务")
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) {
return ApiResponse.success(resourceBackupService.createBackupTask(request));
}
@GetMapping("/{taskId}")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份任务详情", description = "返回资源备份任务详情。")
public ApiResponse<ResourceBackupTaskDetailResponse> taskDetail(
@Parameter(description = "备份任务号")
@ -55,6 +64,8 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/download")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.EXPORT, summary = "下载资源备份包")
@Operation(summary = "下载资源备份包", description = "以浏览器附件方式下载资源备份包。")
public ResponseEntity<Resource> downloadPackage(
@Parameter(description = "备份任务号")
@ -70,6 +81,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/steps")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份任务步骤", description = "返回资源备份任务步骤列表。")
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "备份任务号")
@ -79,6 +91,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/steps/{stepNo}/log")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份步骤日志", description = "返回资源备份任务步骤日志。")
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
@Parameter(description = "备份任务号")

View File

@ -1,6 +1,9 @@
package com.cisd.tms.modules.backup.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
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;
@ -11,6 +14,9 @@ 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 com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -37,6 +43,8 @@ public class ResourceRestoreController {
private final ResourceTaskQueryService resourceTaskQueryService;
@PostMapping("/precheck")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.ACCESS, summary = "预检资源恢复包")
@Operation(summary = "预检资源恢复请求", description = "根据 fileId 校验备份包头、签名和恢复范围摘要。")
public ApiResponse<ResourceRestorePrecheckResponse> precheck(@Valid @RequestBody ResourceRestorePrecheckRequest request) {
return ApiResponse.success(resourcePrecheckService.precheck(request));
@ -44,12 +52,15 @@ public class ResourceRestoreController {
@PostMapping
@ReplayProtected
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "创建资源恢复任务")
@Operation(summary = "创建资源恢复任务", description = "根据预检号创建资源恢复任务。")
public ApiResponse<CreateResourceRestoreResponse> createRestoreTask(@Valid @RequestBody CreateResourceRestoreRequest request) {
return ApiResponse.success(resourceRestoreService.createRestoreTask(request));
}
@GetMapping("/{taskId}")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。")
public ApiResponse<ResourceRestoreTaskDetailResponse> taskDetail(
@Parameter(description = "恢复任务号")
@ -59,6 +70,7 @@ public class ResourceRestoreController {
}
@GetMapping("/{taskId}/steps")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。")
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "恢复任务号")
@ -68,6 +80,7 @@ public class ResourceRestoreController {
}
@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 = "恢复任务号")

View File

@ -1,11 +1,16 @@
package com.cisd.tms.modules.backup.repository;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import java.util.List;
import java.util.Optional;
public interface ResourceBackupTaskRepository {
void save(ResourceBackupTaskEntity entity);
void update(ResourceBackupTaskEntity entity);
Optional<ResourceBackupTaskEntity> findByTaskId(String taskId);
List<ResourceBackupTaskEntity> findAll();
}

View File

@ -3,6 +3,7 @@ package com.cisd.tms.modules.backup.repository.impl;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@ -20,8 +21,19 @@ public class ResourceBackupTaskRepositoryImpl implements ResourceBackupTaskRepos
resourceBackupTaskMapper.insert(entity);
}
@Override
public void update(ResourceBackupTaskEntity entity) {
resourceBackupTaskMapper.updateById(entity);
}
@Override
public Optional<ResourceBackupTaskEntity> findByTaskId(String taskId) {
return Optional.ofNullable(resourceBackupTaskMapper.selectByTaskId(taskId));
}
@Override
public List<ResourceBackupTaskEntity> findAll() {
List<ResourceBackupTaskEntity> tasks = resourceBackupTaskMapper.selectList(null);
return tasks == null ? List.of() : tasks;
}
}

View File

@ -40,6 +40,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
private final ResourceBackupTaskRepository resourceBackupTaskRepository;
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties;
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
@Override
public ResourceKeysetStatusResponse getKeysetStatus() {
@ -57,6 +58,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
@Override
public CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request) {
resourceTaskAdmissionGuard.assertCanStartBackup();
// 资源备份必须基于最近一次成功初始化快照收集上下文避免前端重新传一套可能失真的参数
InitTaskEntity initTask = initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "成功ful init task is missing"));
@ -77,6 +79,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
resourceBackupTaskRepository.save(entity);
try {
// 先根据初始化快照收敛资源范围再交给打包服务生成本地可见的 .tmsbak 文件
@ -106,7 +109,8 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
entity.setErrorMessage(ex.getMessage());
}
entity.setFinishTime(LocalDateTime.now());
resourceBackupTaskRepository.save(entity);
entity.setUpdateTime(entity.getFinishTime());
resourceBackupTaskRepository.update(entity);
CreateResourceBackupResponse response = new CreateResourceBackupResponse();
response.setTaskId(taskId);

View File

@ -36,10 +36,12 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourcePayloadExtractor resourcePayloadExtractor;
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
@Override
public CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request) {
resourceTaskAdmissionGuard.assertCanStartRestore();
if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险");
}

View File

@ -0,0 +1,106 @@
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.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader;
import com.cisd.tms.modules.init.repository.InitTaskRepository;
import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Component;
@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 final ResourceBackupTaskRepository resourceBackupTaskRepository;
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final InitTaskRepository initTaskRepository;
private final UpgradeTaskRepository upgradeTaskRepository;
private final RestoreStatusFileReader restoreStatusFileReader;
public ResourceTaskAdmissionGuard(
ResourceBackupTaskRepository resourceBackupTaskRepository,
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
InitTaskRepository initTaskRepository,
UpgradeTaskRepository upgradeTaskRepository,
RestoreStatusFileReader restoreStatusFileReader
) {
this.resourceBackupTaskRepository = resourceBackupTaskRepository;
this.resourceRestoreTaskRepository = resourceRestoreTaskRepository;
this.initTaskRepository = initTaskRepository;
this.upgradeTaskRepository = upgradeTaskRepository;
this.restoreStatusFileReader = restoreStatusFileReader;
}
public synchronized void assertCanStartBackup() {
assertNoActiveBackup();
assertNoActiveRestore();
assertNoRunningInitOrReset();
assertNoRunningUpgrade();
}
public synchronized void assertCanStartRestore() {
assertNoActiveBackup();
assertNoActiveRestore();
assertNoRunningInitOrReset();
assertNoRunningUpgrade();
}
private void assertNoActiveBackup() {
for (ResourceBackupTaskEntity task : safeList(resourceBackupTaskRepository.findAll())) {
if (ACTIVE_BACKUP_STATUSES.contains(trim(task.getStatus()))) {
throw new BizException(ErrorCode.CONFLICT.getCode(), "资源备份任务正在执行:" + trim(task.getTaskId()));
}
}
}
private void assertNoActiveRestore() {
for (ResourceRestoreTaskEntity task : safeList(resourceRestoreTaskRepository.findAll())) {
if (!ACTIVE_RESTORE_STATUSES.contains(trim(task.getStatus()))) {
continue;
}
RestoreStatusFileReader.RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(task));
if ("SUCCESS".equalsIgnoreCase(trim(snapshot.status())) || "FAILED".equalsIgnoreCase(trim(snapshot.status()))) {
continue;
}
throw new BizException(ErrorCode.CONFLICT.getCode(), "资源恢复任务正在执行:" + trim(task.getTaskId()));
}
}
private void assertNoRunningInitOrReset() {
initTaskRepository.findLatestByStatus("RUNNING").ifPresent(task -> {
throw new BizException(ErrorCode.CONFLICT.getCode(), "初始化或重置任务正在执行:" + trim(task.getTaskId()));
});
}
private void assertNoRunningUpgrade() {
upgradeTaskRepository.findRunningTask().ifPresent(task -> {
throw new BizException(ErrorCode.CONFLICT.getCode(), "升级任务正在执行:" + trim(task.getTaskId()));
});
}
private static Path resolveTaskDir(ResourceRestoreTaskEntity task) {
String runnerStatePath = trim(task.getRunnerStatePath());
if (runnerStatePath.isEmpty()) {
return null;
}
Path statePath = Path.of(runnerStatePath).normalize();
return statePath.getParent();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static <T> List<T> safeList(List<T> items) {
return items == null ? List.of() : items;
}
}

View File

@ -1,5 +1,5 @@
package com.cisd.tms.modules.log.enums;
public enum ModuleCode {
AUTH, INIT, UPGRADE, DEVICE, NETWORK, KEY, SYSTEM, SECURITY, LOG
AUTH, INIT, UPGRADE, DEVICE, NETWORK, KEY, SYSTEM, SECURITY, LOG, BACKUP
}

View File

@ -79,6 +79,21 @@ class InternalAuthorizationInterceptorTest {
Mockito.verifyNoInteractions(operationAuditService);
}
@Test
void shouldAllowAnyConfiguredRoleWhenEndpointDeclaresRoleSet() throws Exception {
OperationAuditService operationAuditService = Mockito.mock(OperationAuditService.class);
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper(), operationAuditService);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/resource-backups");
request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SUPER_ADMIN");
request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, handler("opsOrSuperAdminFullEndpoint"));
Assertions.assertTrue(allowed);
Mockito.verifyNoInteractions(operationAuditService);
}
private static HandlerMethod handler(String methodName) throws NoSuchMethodException {
DemoController controller = new DemoController();
return new HandlerMethod(controller, DemoController.class.getDeclaredMethod(methodName));
@ -94,5 +109,15 @@ class InternalAuthorizationInterceptorTest {
@RequireInternalAuth(role = com.cisd.tms.modules.auth.enums.RoleCode.KEY_ADMIN, authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.LIMITED)
public void keyAdminLimitedEndpoint() {
}
@RequireInternalAuth(
anyRole = {
com.cisd.tms.modules.auth.enums.RoleCode.OPS_ADMIN,
com.cisd.tms.modules.auth.enums.RoleCode.SUPER_ADMIN
},
authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.FULL
)
public void opsOrSuperAdminFullEndpoint() {
}
}
}

View File

@ -17,4 +17,12 @@ class ResourceBackupEndToEndContractTest {
Assertions.assertTrue(restoreController.contains("/precheck"));
Assertions.assertTrue(restoreController.contains("/steps/{stepNo}/log"));
}
@Test
void readmeShouldDeclareRestoreIsAColdStopOperation() throws Exception {
String readme = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/README.md"));
Assertions.assertTrue(readme.contains("停机恢复"));
Assertions.assertTrue(readme.contains("创建恢复任务后当前TMS服务可能中断"));
}
}

View File

@ -0,0 +1,62 @@
package com.cisd.tms.modules.backup.controller;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.security.RequireInternalAuth;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class ResourceBackupAuthorizationAuditContractTest {
@Test
void shouldRequireOpsOrSuperAdminFullForBackupAndRestoreEndpoints() throws Exception {
assertFullOpsOrSuperAdmin(ResourceBackupController.class.getMethod(
"createBackupTask",
com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest.class
));
assertFullOpsOrSuperAdmin(ResourceBackupController.class.getMethod("downloadPackage", String.class));
assertFullOpsOrSuperAdmin(ResourceRestoreController.class.getMethod(
"precheck",
com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest.class
));
assertFullOpsOrSuperAdmin(ResourceRestoreController.class.getMethod(
"createRestoreTask",
com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class
));
}
@Test
void shouldAuditBackupDownloadPrecheckAndRestoreCreation() throws Exception {
assertAudit(ResourceBackupController.class.getMethod(
"createBackupTask",
com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest.class
), ActionType.BACKUP);
assertAudit(ResourceBackupController.class.getMethod("downloadPackage", String.class), ActionType.EXPORT);
assertAudit(ResourceRestoreController.class.getMethod(
"precheck",
com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest.class
), ActionType.ACCESS);
assertAudit(ResourceRestoreController.class.getMethod(
"createRestoreTask",
com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class
), ActionType.RECOVER);
}
private static void assertFullOpsOrSuperAdmin(Method method) {
RequireInternalAuth auth = method.getAnnotation(RequireInternalAuth.class);
Assertions.assertNotNull(auth, method.getName() + " should require internal auth");
Assertions.assertEquals(AuthLevel.FULL, auth.authLevel());
Assertions.assertArrayEquals(new RoleCode[]{RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, auth.anyRole());
}
private static void assertAudit(Method method, ActionType actionType) {
AuditedOperation audit = method.getAnnotation(AuditedOperation.class);
Assertions.assertNotNull(audit, method.getName() + " should be audited");
Assertions.assertEquals(ModuleCode.BACKUP, audit.module());
Assertions.assertEquals(actionType, audit.action());
}
}

View File

@ -11,6 +11,7 @@ import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl;
import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.cisd.tms.modules.init.repository.InitTaskRepository;
@ -29,6 +30,7 @@ class ResourceBackupServiceTest {
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));
@ -57,7 +59,8 @@ class ResourceBackupServiceTest {
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties()
new ResourceBackupProperties(),
admissionGuard
);
CreateResourceBackupResponse response = service.createBackupTask(sampleRequest());
@ -68,7 +71,7 @@ class ResourceBackupServiceTest {
Assertions.assertNotNull(response.getCreatedAt());
ArgumentCaptor<ResourceBackupTaskEntity> captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class);
Mockito.verify(resourceBackupTaskRepository).save(captor.capture());
Mockito.verify(resourceBackupTaskRepository).update(captor.capture());
ResourceBackupTaskEntity saved = captor.getValue();
Assertions.assertEquals("SUCCESS", saved.getStatus());
Assertions.assertEquals(packageResult.getPackagePath(), saved.getPackagePath());
@ -83,6 +86,7 @@ class ResourceBackupServiceTest {
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));
@ -103,7 +107,8 @@ class ResourceBackupServiceTest {
resourcePackageService,
resourceBackupTaskRepository,
new ResourceTaskIdGenerator(),
new ResourceBackupProperties()
new ResourceBackupProperties(),
admissionGuard
);
CreateResourceBackupResponse response = service.createBackupTask(sampleRequest());
@ -111,7 +116,7 @@ class ResourceBackupServiceTest {
Assertions.assertEquals("FAILED", response.getStatus());
ArgumentCaptor<ResourceBackupTaskEntity> captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class);
Mockito.verify(resourceBackupTaskRepository).save(captor.capture());
Mockito.verify(resourceBackupTaskRepository).update(captor.capture());
ResourceBackupTaskEntity saved = captor.getValue();
Assertions.assertEquals("FAILED", saved.getStatus());
Assertions.assertEquals("REQUIRED_RESOURCE_MISSING", saved.getErrorCode());

View File

@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard;
import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore;
@ -40,6 +41,7 @@ class ResourceRestoreHandoffServiceTest {
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
Files.createDirectories(packagePath.getParent());
Files.writeString(packagePath, "package-bytes");
@ -68,7 +70,8 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
applierLauncher
applierLauncher,
admissionGuard
);
CreateResourceRestoreRequest request = new CreateResourceRestoreRequest();
@ -103,6 +106,7 @@ class ResourceRestoreHandoffServiceTest {
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
precheckResponse.setCompatible(false);
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
@ -113,7 +117,8 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
applierLauncher
applierLauncher,
admissionGuard
);
BizException exception = Assertions.assertThrows(BizException.class,
@ -133,6 +138,7 @@ class ResourceRestoreHandoffServiceTest {
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
precheckResponse.setExpiresAt(LocalDateTime.now().minusMinutes(1).toString());
store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse));
@ -143,7 +149,8 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
applierLauncher
applierLauncher,
admissionGuard
);
BizException exception = Assertions.assertThrows(BizException.class,

View File

@ -0,0 +1,94 @@
package com.cisd.tms.modules.backup.service;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader;
import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.cisd.tms.modules.init.repository.InitTaskRepository;
import com.cisd.tms.modules.upgrade.entity.UpgradeTaskEntity;
import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.List;
import java.util.Optional;
class ResourceTaskAdmissionGuardTest {
@Test
void shouldRejectBackupWhenAnotherBackupIsRunning() {
ResourceBackupTaskRepository backupRepository = Mockito.mock(ResourceBackupTaskRepository.class);
Mockito.when(backupRepository.findAll()).thenReturn(List.of(activeBackup()));
ResourceTaskAdmissionGuard guard = guard(backupRepository);
BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartBackup);
Assertions.assertTrue(exception.getMessage().contains("资源备份任务正在执行"));
}
@Test
void shouldRejectRestoreWhenInitIsRunning() {
InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class);
InitTaskEntity running = new InitTaskEntity();
running.setTaskId("INIT-001");
running.setTaskType("INIT");
running.setStatus("RUNNING");
Mockito.when(initTaskRepository.findLatestByStatus("RUNNING")).thenReturn(Optional.of(running));
ResourceTaskAdmissionGuard guard = guard(Mockito.mock(ResourceBackupTaskRepository.class), initTaskRepository);
BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartRestore);
Assertions.assertTrue(exception.getMessage().contains("初始化或重置任务正在执行"));
}
@Test
void shouldRejectRestoreWhenUpgradeIsRunning() {
UpgradeTaskRepository upgradeTaskRepository = Mockito.mock(UpgradeTaskRepository.class);
UpgradeTaskEntity running = new UpgradeTaskEntity();
running.setTaskId("UPG-001");
running.setStatus("RUNNING");
Mockito.when(upgradeTaskRepository.findRunningTask()).thenReturn(Optional.of(running));
ResourceTaskAdmissionGuard guard = guard(
Mockito.mock(ResourceBackupTaskRepository.class),
Mockito.mock(InitTaskRepository.class),
upgradeTaskRepository
);
BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartRestore);
Assertions.assertTrue(exception.getMessage().contains("升级任务正在执行"));
}
private static ResourceBackupTaskEntity activeBackup() {
ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity();
entity.setTaskId("RBKP-TASK-001");
entity.setStatus("RUNNING");
return entity;
}
private static ResourceTaskAdmissionGuard guard(ResourceBackupTaskRepository backupRepository) {
return guard(backupRepository, Mockito.mock(InitTaskRepository.class));
}
private static ResourceTaskAdmissionGuard guard(ResourceBackupTaskRepository backupRepository, InitTaskRepository initTaskRepository) {
return guard(backupRepository, initTaskRepository, Mockito.mock(UpgradeTaskRepository.class));
}
private static ResourceTaskAdmissionGuard guard(
ResourceBackupTaskRepository backupRepository,
InitTaskRepository initTaskRepository,
UpgradeTaskRepository upgradeTaskRepository
) {
return new ResourceTaskAdmissionGuard(
backupRepository,
Mockito.mock(ResourceRestoreTaskRepository.class),
initTaskRepository,
upgradeTaskRepository,
Mockito.mock(RestoreStatusFileReader.class)
);
}
}