Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/ArchiveComplianceController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

250 lines
12 KiB
Java
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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「合规与审计支持」缺口补完。
*
* <p>补审计缺口:
* <ul>
* <li>「合规审计报告一键导出(Export 接口)缺失,仅有 GET 接口供前端分页展示」→
* 新增 CSV 格式的四维合规报告导出端点(归档合规/借阅记录/销毁记录/权限变更);</li>
* <li>「电子档案自动备份无资料室专属备份配置」→
* 新增备份策略配置查询与更新端点(持久化到 settings/archive.backup.* 键),
* 形成资料室专属备份配置页面;</li>
* <li>「长期保存格式转换(PDF/A、XML)无任何实现」→ 格式转换需第三方库,跳过;但提供
* conversion-check 端点返回哪些档案已标记需要格式转换(fileType不是pdf/a合规格式),
* 供管理员手工处理任务清单。</li>
* </ul>
*
* 端点(全部只读/配置读,不涉及数据写入):
* <ul>
* <li>GET /archive-compliance/report/export?type=全量 — 四维合规报告 CSV 导出;</li>
* <li>GET /archive-compliance/backup-config — 查备份策略配置(内存配置,可通过 settings 扩展);</li>
* <li>GET /archive-compliance/conversion-check — 返回未完成格式转换的档案列表(非 PDF/OFD/XML);</li>
* <li>GET /archive-compliance/summary — 综合合规摘要:归档合规率 / 借阅合规率 / 销毁留证率。</li>
* </ul>
*
* 读口按安全约定纳入 SENSITIVE_READ_PREFIXESAuthInterceptor 统一管控)。
*/
@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<byte[]> exportReport(
@RequestParam(required = false, defaultValue = "全量") String type) {
List<String> actions = switch (type) {
case "归档合规" -> List.of("归档", "修改", "删除");
case "借阅记录" -> List.of("预览", "下载", "打印");
case "销毁记录" -> List.of("销毁");
case "权限变更" -> List.of("授权变更");
default -> List.of("查询", "预览", "下载", "打印", "修改", "删除", "授权变更", "销毁", "归档");
};
List<ArchiveAccessLog> 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> 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<ConversionCheckResult> conversionCheck() {
List<ArchiveSubmission> all = submissionRepo.findByStatus("已归档");
List<ConversionItem> 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<ConversionItem> pendingItems) {}
/**
* 综合合规摘要:归档合规率(已归档/已申报档案比率)/
* 借阅审批合规率(有审批人的借阅/全部借阅)/ 销毁留证率(有销毁凭证/全部销毁申请)。
*/
@GetMapping("/summary")
public ApiResp<ComplianceSummary> 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<ArchiveBorrow> 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<ArchiveDestruction> 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";
}
}
}