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.AuditArchive; import com.kaidi.oa.domain.AuditProject; import com.kaidi.oa.repository.AuditArchiveRepository; import com.kaidi.oa.repository.AuditFindingRepository; import com.kaidi.oa.repository.AuditProjectRepository; 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.PutMapping; 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.Year; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 审计档案库(内控部·审计监察部·第9模块)。 * * 解决审计缺口:「无统一审计档案库实体(底稿/报告/证据/整改记录无统一归档入口)/ * 无审计发现库单独聚合视图」。 * * 核心功能: * - POST / 新建归档记录(状态=归档中); * - PATCH /{id}/archive 确认归档(归档中→已归档); * - GET /?year=&type= 按年度/类型过滤查询; * - GET /search?q= 简易关键词检索(title/keywords/tags/description); * - GET /findings-library 审计发现库聚合视图(跨审计项目汇总历史问题); * - GET /summary 档案按年度/类型统计数量; * - PUT /{id} 更新档案信息(已归档阶段只允许更新标签/关键词); * - DELETE /{id} 删除(仅归档中阶段)。 */ @RestController @RequestMapping("/api/oa/audit-archives") public class AuditArchiveController { private final AuditArchiveRepository archiveRepo; private final AuditProjectRepository projectRepo; private final AuditFindingRepository findingRepo; public AuditArchiveController(AuditArchiveRepository archiveRepo, AuditProjectRepository projectRepo, AuditFindingRepository findingRepo) { this.archiveRepo = archiveRepo; this.projectRepo = projectRepo; this.findingRepo = findingRepo; } @GetMapping public ApiResp> list( @RequestParam(required = false) String year, @RequestParam(required = false) String archiveType, @RequestParam(required = false) Long projectId) { if (projectId != null) { return ApiResp.ok(archiveRepo.findByProjectId(projectId)); } if (year != null && !year.isBlank()) { return ApiResp.ok(archiveRepo.findByYear(year)); } if (archiveType != null && !archiveType.isBlank()) { return ApiResp.ok(archiveRepo.findByArchiveType(archiveType)); } return ApiResp.ok(archiveRepo.findByOrderByYearDescIdDesc()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(load(id)); } /** * 简易关键词检索(title/keywords/tags 含匹配,不依赖 ES)。 */ @GetMapping("/search") public ApiResp> search(@RequestParam String q) { if (q == null || q.isBlank()) { return ApiResp.ok(List.of()); } return ApiResp.ok( archiveRepo.findByKeywordsContainingOrTitleContainingOrTagsContaining( q, q, q)); } /** * 审计发现库聚合视图:按问题类别/风险等级聚合历史全部审计发现(含整改状态), * 供新项目检索类似问题参考经验。返回发现列表(含 rootCause/category/severity/status)。 */ @GetMapping("/findings-library") public ApiResp findingsLibrary( @RequestParam(required = false) String category, @RequestParam(required = false) String severity) { var all = findingRepo.findAll(); var filtered = all.stream() .filter(f -> category == null || category.isBlank() || category.equals(f.getCategory())) .filter(f -> severity == null || severity.isBlank() || severity.equals(f.getSeverity())) .map(f -> Map.of( "id", f.getId(), "findingNo", f.getFindingNo() == null ? "" : f.getFindingNo(), "title", f.getTitle() == null ? "" : f.getTitle(), "category", f.getCategory() == null ? "" : f.getCategory(), "severity", f.getSeverity() == null ? "" : f.getSeverity(), "status", f.getStatus() == null ? "" : f.getStatus(), "rootCause", f.getRootCause() == null ? "" : f.getRootCause(), "projectName", f.getProjectName() == null ? "" : f.getProjectName(), "auditee", f.getAuditee() == null ? "" : f.getAuditee() )) .toList(); return ApiResp.ok(filtered); } /** * 档案统计摘要:按年度/类型统计数量(辅助知识库全览)。 */ @GetMapping("/summary") public ApiResp> summary() { List all = archiveRepo.findAll(); Map byYear = all.stream() .collect(Collectors.groupingBy( a -> a.getYear() == null ? "未知" : a.getYear(), Collectors.counting())); Map byType = all.stream() .collect(Collectors.groupingBy( a -> a.getArchiveType() == null ? "其他" : a.getArchiveType(), Collectors.counting())); return ApiResp.ok(Map.of("total", (long) all.size(), "byYear", byYear, "byType", byType)); } public record CreateRequest( Long projectId, String year, String archiveType, String title, String description, String fileRef, String fileHash, String tags, String keywords, int retentionYear, String archiver) { } @PostMapping public ApiResp create(@RequestBody CreateRequest req) { if (req.title() == null || req.title().isBlank()) { throw new ApiException(400, "档案标题不能为空"); } if (req.archiveType() == null || req.archiveType().isBlank()) { throw new ApiException(400, "档案类型不能为空"); } AuditArchive a = new AuditArchive(); a.setArchiveNo(nextArchiveNo()); a.setYear(req.year() != null && !req.year().isBlank() ? req.year() : String.valueOf(Year.now().getValue())); a.setArchiveType(req.archiveType()); a.setTitle(req.title()); a.setDescription(req.description()); a.setFileRef(req.fileRef()); a.setFileHash(req.fileHash()); a.setTags(req.tags()); a.setKeywords(req.keywords()); a.setRetentionYear(req.retentionYear() <= 0 ? 10 : req.retentionYear()); a.setArchiver(req.archiver()); a.setStatus("归档中"); a.setCreatedAt(Instant.now()); if (req.projectId() != null) { projectRepo.findById(req.projectId()).ifPresent(p -> { a.setProjectId(p.getId()); a.setProjectName(p.getName()); a.setAuditee(p.getAuditee()); a.setAuditType(p.getAuditType()); }); } return ApiResp.ok(archiveRepo.save(a)); } public record UpdateRequest( String title, String description, String tags, String keywords, String fileRef, int retentionYear) { } @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody UpdateRequest req) { AuditArchive a = load(id); if ("已销毁".equals(a.getStatus())) { throw new ApiException(409, "已销毁档案不允许修改"); } // 已归档阶段只允许改标签/关键词/描述(核心字段锁定)。 boolean archived = "已归档".equals(a.getStatus()); if (req.tags() != null) a.setTags(req.tags()); if (req.keywords() != null) a.setKeywords(req.keywords()); if (req.description() != null) a.setDescription(req.description()); if (!archived) { if (req.title() != null && !req.title().isBlank()) a.setTitle(req.title()); if (req.fileRef() != null) a.setFileRef(req.fileRef()); if (req.retentionYear() > 0) a.setRetentionYear(req.retentionYear()); } return ApiResp.ok(archiveRepo.save(a)); } public record ArchiveRequest(String archiver) { } /** 确认归档(归档中 → 已归档)。 */ @PatchMapping("/{id}/archive") public ApiResp archive(@PathVariable Long id, @RequestBody ArchiveRequest req) { AuditArchive a = load(id); if (!"归档中".equals(a.getStatus())) { throw new ApiException(409, "只有归档中状态可确认归档(当前:" + a.getStatus() + ")"); } a.setStatus("已归档"); a.setArchivedAt(Instant.now()); if (req.archiver() != null && !req.archiver().isBlank()) a.setArchiver(req.archiver()); return ApiResp.ok(archiveRepo.save(a)); } /** 删除(仅归档中阶段)。 */ @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { AuditArchive a = load(id); if (!"归档中".equals(a.getStatus())) { throw new ApiException(409, "只有归档中阶段的档案可以删除(当前:" + a.getStatus() + ")"); } archiveRepo.deleteById(id); return ApiResp.ok(null); } private AuditArchive load(Long id) { return archiveRepo.findById(id) .orElseThrow(() -> new NotFoundException("审计档案不存在: " + id)); } private String nextArchiveNo() { String year = String.valueOf(Year.now().getValue()); long seq = archiveRepo.count() + 1; return String.format("AC-%s-%03d", year, seq); } }