package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.domain.ArchiveAccessLog; import com.kaidi.oa.domain.ArchiveBorrow; import com.kaidi.oa.domain.ArchiveDestruction; import com.kaidi.oa.domain.ArchiveSubmission; import com.kaidi.oa.repository.ArchiveAccessLogRepository; import com.kaidi.oa.repository.ArchiveBorrowRepository; import com.kaidi.oa.repository.ArchiveDestructionRepository; import com.kaidi.oa.repository.ArchiveSubmissionRepository; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.List; /** * 资料室·Module 7「合规与审计支持」缺口补完。 * *

补审计缺口: *

* * 端点(全部只读/配置读,不涉及数据写入): * * * 读口按安全约定纳入 SENSITIVE_READ_PREFIXES(AuthInterceptor 统一管控)。 */ @RestController @RequestMapping("/api/oa/archive-compliance") public class ArchiveComplianceController { private final ArchiveAccessLogRepository accessLogRepo; private final ArchiveSubmissionRepository submissionRepo; private final ArchiveBorrowRepository borrowRepo; private final ArchiveDestructionRepository destructionRepo; public ArchiveComplianceController(ArchiveAccessLogRepository accessLogRepo, ArchiveSubmissionRepository submissionRepo, ArchiveBorrowRepository borrowRepo, ArchiveDestructionRepository destructionRepo) { this.accessLogRepo = accessLogRepo; this.submissionRepo = submissionRepo; this.borrowRepo = borrowRepo; this.destructionRepo = destructionRepo; } /** * 四维合规报告 CSV 导出。type 参数:归档合规 / 借阅记录 / 销毁记录 / 权限变更 / 全量。 * 返回 text/csv;charset=UTF-8,文件名为「档案合规报告_type_日期.csv」。 * 满足「合规审计报告一键导出(Export 接口)缺失」缺口。 */ @GetMapping("/report/export") public ResponseEntity exportReport( @RequestParam(required = false, defaultValue = "全量") String type) { List actions = switch (type) { case "归档合规" -> List.of("归档", "修改", "删除"); case "借阅记录" -> List.of("预览", "下载", "打印"); case "销毁记录" -> List.of("销毁"); case "权限变更" -> List.of("授权变更"); default -> List.of("查询", "预览", "下载", "打印", "修改", "删除", "授权变更", "销毁", "归档"); }; List logs = accessLogRepo.findTop500ByOrderByIdDesc() .stream() .filter(l -> actions.contains(l.getAction())) .toList(); StringBuilder sb = new StringBuilder(); sb.append(""); // BOM for Excel sb.append("操作时间,档案ID,档案名称,操作类型,操作人,所属部门,来源IP,详情\n"); for (ArchiveAccessLog log : logs) { sb.append(csvField(log.getOccurredAt())).append(","); sb.append(csvField(log.getArchiveId() == null ? "" : String.valueOf(log.getArchiveId()))).append(","); sb.append(csvField(log.getArchiveTitle())).append(","); sb.append(csvField(log.getAction())).append(","); sb.append(csvField(log.getOperator())).append(","); sb.append(csvField(log.getOperatorDept())).append(","); sb.append(csvField(log.getIp())).append(","); sb.append(csvField(log.getDetail())).append("\n"); } byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8); String filename = "档案合规报告_" + type + "_" + LocalDate.now() + ".csv"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodeFilename(filename)) .contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) .body(body); } /** 备份策略配置查询(固定合理默认值,管理员可参考用于实际备份工具配置)。 */ @GetMapping("/backup-config") public ApiResp backupConfig() { // 资料室专属备份配置(本系统为嵌入式 SQLite,备份策略以配置形式返回供运维参考) return ApiResp.ok(new BackupConfig( "local+remote", // strategy: local+remote / local-only / remote-only "daily", // incrementalFreq: daily / weekly "weekly", // fullBackupFreq: weekly / monthly "30", // retentionDays: 本地备份保留天数 "365", // remoteRetentionDays: 异地保留天数 "/var/backup/oa-archive", // localPath: 本地备份路径 "s3://kaidi-oa-archive", // remotePath: 异地/云备份路径(参考) true, // enabled: 是否启用 "02:00", // dailyTime: 每日增量时间 "Sun 03:00", // weeklyTime: 每周全量时间(周日凌晨) "基于 SQLite WAL + cp 快照 + rsync 远端同步;关键档案另行 7z 加密归档" )); } public record BackupConfig( String strategy, String incrementalFreq, String fullBackupFreq, String retentionDays, String remoteRetentionDays, String localPath, String remotePath, boolean enabled, String dailyTime, String weeklyTime, String note ) {} /** * 格式转换待办清单:返回 fileType 非「长期保存合规格式(PDF/OFD/XML/PDF/A)」的档案提交记录 * 中需要转换的条目,供管理员安排手工转换任务(长期保存格式转换 PDF/A、XML)。 */ @GetMapping("/conversion-check") public ApiResp conversionCheck() { List all = submissionRepo.findByStatus("已归档"); List needConvert = all.stream() .filter(s -> needsConversion(s.getFileType())) .map(s -> new ConversionItem( s.getId(), s.getArchiveCode(), s.getTitle(), s.getFileType(), s.getCategoryName(), s.getSecLevel(), s.getArchiveCode(), "建议转换为 PDF/A")) .toList(); long total = all.size(); long compliant = all.stream().filter(s -> !needsConversion(s.getFileType())).count(); double rate = total == 0 ? 100.0 : Math.round(compliant * 1000.0 / total) / 10.0; return ApiResp.ok(new ConversionCheckResult(total, compliant, needConvert.size(), rate, needConvert)); } public record ConversionItem( Long submissionId, String archiveCode, String title, String fileType, String category, String secLevel, String code, String suggestion) {} public record ConversionCheckResult( long total, long compliant, long pending, double complianceRate, List pendingItems) {} /** * 综合合规摘要:归档合规率(已归档/已申报档案比率)/ * 借阅审批合规率(有审批人的借阅/全部借阅)/ 销毁留证率(有销毁凭证/全部销毁申请)。 */ @GetMapping("/summary") public ApiResp summary() { // 归档合规率 long allSubs = submissionRepo.count(); long archived = submissionRepo.findByStatus("已归档").size(); double archiveRate = allSubs == 0 ? 100.0 : Math.round(archived * 1000.0 / allSubs) / 10.0; // 借阅审批合规率(有审批人记录) List borrows = borrowRepo.findAll(); long totalBorrows = borrows.size(); long approvedBorrows = borrows.stream() .filter(b -> b.getApprover() != null && !b.getApprover().isBlank()) .count(); double borrowRate = totalBorrows == 0 ? 100.0 : Math.round(approvedBorrows * 1000.0 / totalBorrows) / 10.0; // 销毁留证率(有 destroyEvidence / remark 的销毁申请) List destructions = destructionRepo.findAll(); long totalDest = destructions.size(); // 以「有执行人」作为销毁留证的代理指标(executor 非空 = 执行完成有留证) long evidenced = destructions.stream() .filter(d -> d.getExecutor() != null && !d.getExecutor().isBlank()) .count(); double destRate = totalDest == 0 ? 100.0 : Math.round(evidenced * 1000.0 / totalDest) / 10.0; return ApiResp.ok(new ComplianceSummary( allSubs, archived, archiveRate, totalBorrows, approvedBorrows, borrowRate, totalDest, evidenced, destRate, LocalDate.now().toString() )); } public record ComplianceSummary( long totalSubmissions, long archivedCount, double archiveComplianceRate, long totalBorrows, long approvedBorrows, double borrowApprovalRate, long totalDestructions, long evidencedDestructions, double destructionEvidenceRate, String reportDate ) {} // ---- helpers ---- private static boolean needsConversion(String fileType) { if (fileType == null || fileType.isBlank()) { return false; } String ft = fileType.trim().toLowerCase(); // 长期保存合规格式:pdf、ofd、xml、pdf/a 标准(近似判断) return !ft.equals("pdf") && !ft.equals("ofd") && !ft.equals("xml") && !ft.contains("pdf/a") && !ft.contains("pdfa"); } private static String csvField(String v) { if (v == null) { return ""; } if (v.contains(",") || v.contains("\"") || v.contains("\n")) { return "\"" + v.replace("\"", "\"\"") + "\""; } return v; } private static String encodeFilename(String name) { try { return java.net.URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"); } catch (Exception e) { return "archive_report.csv"; } } }