package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.ItDisasterDrillRecord; import com.kaidi.oa.repository.ItDisasterDrillRecordRepository; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 信息部·高可用与容灾管理(需求 §2 高可用与容灾)。 * 记录集群/双机热备/异地容灾配置台账与定期容灾切换演练。 * *

三类记录(recordType): *

    *
  1. 配置台账:记录高可用架构、主备节点、RTO/RPO 指标,无状态机(直接已完成);
  2. *
  3. 演练记录:计划中→进行中→已完成/已失败,记录实际 RTO 与发现问题;
  4. *
  5. 故障切换记录:直接写入已完成,记录真实故障切换过程与恢复时间。
  6. *
*

* * * * 写口默认受 default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/it-disaster-drills") public class ItDisasterDrillController { private final ItDisasterDrillRecordRepository repo; public ItDisasterDrillController(ItDisasterDrillRecordRepository repo) { this.repo = repo; } // ---------- CRUD ---------- @GetMapping public ApiResp> list( @RequestParam(required = false) String recordType, @RequestParam(required = false) String status) { if (recordType != null && !recordType.isBlank()) { return ApiResp.ok(repo.findByRecordType(recordType)); } if (status != null && !status.isBlank()) { return ApiResp.ok(repo.findByStatus(status)); } return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(require(id)); } public record DrillRequest( String recordType, String title, String targetSystem, String haScheme, String primaryNode, String standbyNode, String schemeDetail, String plannedDate, String executor, Integer rtoMinutes, Integer rpoMinutes, String note) { } /** 新建配置台账或演练计划。配置台账直接置「已完成」,演练记录置「计划中」,故障切换置「已完成」。 */ @PostMapping public ApiResp create(@RequestBody DrillRequest req) { if (req.title() == null || req.title().isBlank()) { throw new ApiException(400, "标题(title) 不能为空"); } String type = req.recordType() == null || req.recordType().isBlank() ? "演练记录" : req.recordType(); ItDisasterDrillRecord r = new ItDisasterDrillRecord(); r.setRecordNo("DR-" + LocalDate.now().toString().replace("-", "") + "-" + String.format("%03d", (repo.count() + 1) % 1000)); r.setRecordType(type); r.setTitle(req.title()); r.setTargetSystem(req.targetSystem()); r.setHaScheme(req.haScheme()); r.setPrimaryNode(req.primaryNode()); r.setStandbyNode(req.standbyNode()); r.setSchemeDetail(req.schemeDetail()); r.setPlannedDate(req.plannedDate()); r.setExecutor(req.executor()); r.setRtoMinutes(req.rtoMinutes()); r.setRpoMinutes(req.rpoMinutes()); r.setNote(req.note()); r.setStatus(("配置台账".equals(type) || "故障切换记录".equals(type)) ? "已完成" : "计划中"); if ("故障切换记录".equals(type) && r.getActualDate() == null) { r.setActualDate(LocalDate.now().toString()); } r.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(r)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody DrillRequest req) { ItDisasterDrillRecord r = require(id); if (req.title() != null && !req.title().isBlank()) r.setTitle(req.title()); if (req.targetSystem() != null) r.setTargetSystem(req.targetSystem()); if (req.haScheme() != null) r.setHaScheme(req.haScheme()); if (req.primaryNode() != null) r.setPrimaryNode(req.primaryNode()); if (req.standbyNode() != null) r.setStandbyNode(req.standbyNode()); if (req.schemeDetail() != null) r.setSchemeDetail(req.schemeDetail()); if (req.plannedDate() != null) r.setPlannedDate(req.plannedDate()); if (req.executor() != null) r.setExecutor(req.executor()); if (req.rtoMinutes() != null) r.setRtoMinutes(req.rtoMinutes()); if (req.rpoMinutes() != null) r.setRpoMinutes(req.rpoMinutes()); if (req.note() != null) r.setNote(req.note()); return ApiResp.ok(repo.save(r)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) throw new NotFoundException("disaster drill record not found: " + id); repo.deleteById(id); return ApiResp.ok(null); } // ---------- 状态机(演练记录专用)---------- /** 演练开始:计划中 → 进行中。 */ @PostMapping("/{id}/start") public ApiResp start(@PathVariable Long id) { ItDisasterDrillRecord r = require(id); if (!"计划中".equals(r.getStatus())) { throw new ApiException(409, "仅「计划中」演练可开始,当前:" + r.getStatus()); } r.setStatus("进行中"); r.setActualDate(LocalDate.now().toString()); return ApiResp.ok(repo.save(r)); } public record CompleteRequest(Integer actualRtoMinutes, String drillResult, String issues, String remediation, String nextDrillDate, String note) { } /** 演练完成:进行中 → 已完成,填实际 RTO 与问题整改。 */ @PostMapping("/{id}/complete") public ApiResp complete(@PathVariable Long id, @RequestBody CompleteRequest req) { ItDisasterDrillRecord r = require(id); if (!"进行中".equals(r.getStatus())) { throw new ApiException(409, "仅「进行中」演练可完成,当前:" + r.getStatus()); } if (req.drillResult() == null || req.drillResult().isBlank()) { throw new ApiException(400, "演练结果(drillResult) 不能为空"); } if (req.actualRtoMinutes() != null) r.setActualRtoMinutes(req.actualRtoMinutes()); r.setDrillResult(req.drillResult()); if (req.issues() != null) r.setIssues(req.issues()); if (req.remediation() != null) r.setRemediation(req.remediation()); if (req.nextDrillDate() != null) r.setNextDrillDate(req.nextDrillDate()); if (req.note() != null) r.setNote(req.note()); r.setStatus("已完成"); return ApiResp.ok(repo.save(r)); } public record FailRequest(String issues, String note) { } /** 演练失败:进行中 → 已失败,记录失败原因并要求整改。 */ @PostMapping("/{id}/fail") public ApiResp fail(@PathVariable Long id, @RequestBody FailRequest req) { ItDisasterDrillRecord r = require(id); if (!"进行中".equals(r.getStatus())) { throw new ApiException(409, "仅「进行中」演练可标记失败,当前:" + r.getStatus()); } if (req.issues() != null) r.setIssues(req.issues()); r.setDrillResult("失败"); if (req.note() != null) r.setNote(req.note()); r.setStatus("已失败"); return ApiResp.ok(repo.save(r)); } // ---------- 总览看板 ---------- public record DisasterOverview(int totalConfigs, int totalDrills, int drillCompleted, int drillFailed, int drillPlanned, int failoverRecords, double drillSuccessRate, double rtoMeetRate, Map byScheme) { } /** * 容灾管理总览:配置台账数/演练完成率/演练失败数/RTO 达标率/HA 方案分布。 * RTO 达标:演练后实际 RTO ≤ 目标 RTO。 */ @GetMapping("/overview") public ApiResp overview() { List all = repo.findAll(); int configs = 0, drills = 0, drillDone = 0, drillFailed = 0, drillPlanned = 0, failovers = 0; int rtoMet = 0, rtoTotal = 0; Map byScheme = new LinkedHashMap<>(); for (ItDisasterDrillRecord r : all) { switch (r.getRecordType() == null ? "演练记录" : r.getRecordType()) { case "配置台账" -> configs++; case "演练记录" -> { drills++; if ("已完成".equals(r.getStatus())) drillDone++; else if ("已失败".equals(r.getStatus())) drillFailed++; else if ("计划中".equals(r.getStatus())) drillPlanned++; // RTO 达标判断 if (r.getRtoMinutes() != null && r.getActualRtoMinutes() != null) { rtoTotal++; if (r.getActualRtoMinutes() <= r.getRtoMinutes()) rtoMet++; } } case "故障切换记录" -> failovers++; } if (r.getHaScheme() != null) { byScheme.merge(r.getHaScheme(), 1, Integer::sum); } } double successRate = (drillDone + drillFailed) == 0 ? 0 : Math.round(drillDone * 1000.0 / (drillDone + drillFailed)) / 10.0; double rtoRate = rtoTotal == 0 ? 0 : Math.round(rtoMet * 1000.0 / rtoTotal) / 10.0; return ApiResp.ok(new DisasterOverview(configs, drills, drillDone, drillFailed, drillPlanned, failovers, successRate, rtoRate, byScheme)); } // ---------- helpers ---------- private ItDisasterDrillRecord require(Long id) { return repo.findById(id) .orElseThrow(() -> new NotFoundException("disaster drill record not found: " + id)); } }