恢复点(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>
246 lines
10 KiB
Java
246 lines
10 KiB
Java
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<AuditArchive>> 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<AuditArchive> get(@PathVariable Long id) {
|
||
return ApiResp.ok(load(id));
|
||
}
|
||
|
||
/**
|
||
* 简易关键词检索(title/keywords/tags 含匹配,不依赖 ES)。
|
||
*/
|
||
@GetMapping("/search")
|
||
public ApiResp<List<AuditArchive>> 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<Object> 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<Map<String, Object>> summary() {
|
||
List<AuditArchive> all = archiveRepo.findAll();
|
||
Map<String, Long> byYear = all.stream()
|
||
.collect(Collectors.groupingBy(
|
||
a -> a.getYear() == null ? "未知" : a.getYear(),
|
||
Collectors.counting()));
|
||
Map<String, Long> 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<AuditArchive> 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<AuditArchive> 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<AuditArchive> 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<Void> 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);
|
||
}
|
||
}
|