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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,214 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.domain.Archive;
import com.kaidi.oa.domain.ArchiveAccessLog;
import com.kaidi.oa.repository.ArchiveAccessLogRepository;
import com.kaidi.oa.repository.ArchiveRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.transaction.annotation.Transactional;
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.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 资料室·Module 3「电子文件管理」批量操作补完。
*
* <p>补缺口:「批量下载/转移/修改元数据无专门端点」。
* 本控制器提供三个批量端点(均 @Transactional,幂等写入):
* <ul>
* <li>POST /archive-batch/transfer — 批量转移档案分类(targetCategory);</li>
* <li>POST /archive-batch/meta-update — 批量修改元数据(tags / summary / accessLevel / archiveDate);</li>
* <li>POST /archive-batch/download-list — 批量下载清单(返回文件 URL 列表,前端逐一下载)。</li>
* </ul>
*
* 注意:PDF/A 格式转换、OCR 正文识别、区块链哈希存证属外部系统,本轮跳过。
*/
@RestController
@RequestMapping("/api/oa/archive-batch")
public class ArchiveBatchController {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ArchiveRepository archiveRepo;
private final ArchiveAccessLogRepository accessLogRepo;
private final CurrentUserResolver currentUser;
public ArchiveBatchController(ArchiveRepository archiveRepo,
ArchiveAccessLogRepository accessLogRepo,
CurrentUserResolver currentUser) {
this.archiveRepo = archiveRepo;
this.accessLogRepo = accessLogRepo;
this.currentUser = currentUser;
}
// ─────────────────────────────────────────────────────────────────────────
// 1. 批量转移分类
// ─────────────────────────────────────────────────────────────────────────
public record TransferRequest(List<Long> ids, String targetCategory) {}
public record BatchResult(int total, int affected, String message) {}
/**
* 批量将选定档案转移到目标分类。
* ids 最多 200 条,targetCategory 不可为空。
*/
@PostMapping("/transfer")
@Transactional
public ApiResp<BatchResult> transfer(@RequestBody TransferRequest req,
HttpServletRequest http) {
validateIds(req.ids());
if (req.targetCategory() == null || req.targetCategory().isBlank()) {
throw new ApiException(400, "目标分类不可为空");
}
List<Archive> archives = archiveRepo.findAllById(req.ids());
String operator = resolveOperator(http);
String now = LocalDateTime.now().format(FMT);
for (Archive a : archives) {
String oldCat = a.getCategory();
a.setCategory(req.targetCategory());
archiveRepo.save(a);
writeLog(a.getId(), a.getTitle(), "批量转移",
operator, "分类 " + oldCat + "" + req.targetCategory(), http, now);
}
return ApiResp.ok(new BatchResult(req.ids().size(), archives.size(),
"已将 " + archives.size() + " 条档案转移至【" + req.targetCategory() + ""));
}
// ─────────────────────────────────────────────────────────────────────────
// 2. 批量修改元数据
// ─────────────────────────────────────────────────────────────────────────
/**
* 批量修改元数据字段。
* 仅修改非空字段(null 表示不改)。
* 可修改:tags / summary / accessLevel / archiveDate。
*/
public record MetaUpdateRequest(
List<Long> ids,
String tags,
String summary,
String accessLevel,
String archiveDate) {}
@PostMapping("/meta-update")
@Transactional
public ApiResp<BatchResult> metaUpdate(@RequestBody MetaUpdateRequest req,
HttpServletRequest http) {
validateIds(req.ids());
boolean anyField = req.tags() != null || req.summary() != null
|| req.accessLevel() != null || req.archiveDate() != null;
if (!anyField) {
throw new ApiException(400, "至少需要指定一个要修改的元数据字段");
}
List<Archive> archives = archiveRepo.findAllById(req.ids());
String operator = resolveOperator(http);
String now = LocalDateTime.now().format(FMT);
for (Archive a : archives) {
if (req.tags() != null) {
a.setTags(req.tags());
}
if (req.summary() != null) {
a.setSummary(req.summary());
}
if (req.accessLevel() != null) {
a.setAccessLevel(req.accessLevel());
}
if (req.archiveDate() != null) {
a.setArchiveDate(req.archiveDate());
}
archiveRepo.save(a);
writeLog(a.getId(), a.getTitle(), "批量修改元数据",
operator, buildMetaDetail(req), http, now);
}
return ApiResp.ok(new BatchResult(req.ids().size(), archives.size(),
"已批量更新 " + archives.size() + " 条档案的元数据"));
}
// ─────────────────────────────────────────────────────────────────────────
// 3. 批量下载清单(返回 fileUrl 列表,前端逐一触发下载)
// ─────────────────────────────────────────────────────────────────────────
public record DownloadListRequest(List<Long> ids) {}
public record DownloadItem(Long id, String title, String fileType, String fileUrl) {}
public record DownloadListResult(int total, List<DownloadItem> items, String note) {}
/**
* 批量下载清单:返回可下载档案的 fileUrl 列表(已有 fileUrl 的条目)。
* 前端逐条调用浏览器 &lt;a download&gt; 完成下载,避免后端 ZIP 依赖。
*/
@PostMapping("/download-list")
@Transactional
public ApiResp<DownloadListResult> downloadList(@RequestBody DownloadListRequest req,
HttpServletRequest http) {
validateIds(req.ids());
List<Archive> archives = archiveRepo.findAllById(req.ids());
String operator = resolveOperator(http);
String now = LocalDateTime.now().format(FMT);
List<DownloadItem> items = archives.stream()
.filter(a -> a.getFileUrl() != null && !a.getFileUrl().isBlank())
.map(a -> {
writeLog(a.getId(), a.getTitle(), "批量下载",
operator, "批量下载请求", http, now);
return new DownloadItem(a.getId(), a.getTitle(), a.getFileType(), a.getFileUrl());
})
.toList();
int noUrl = archives.size() - items.size();
String note = noUrl > 0
? noUrl + " 条档案无文件链接(实体档案或未上传电子版),已跳过"
: "全部档案均有文件链接";
return ApiResp.ok(new DownloadListResult(items.size(), items, note));
}
// ─────────────────────────────────────────────────────────────────────────
// helpers
// ─────────────────────────────────────────────────────────────────────────
private void validateIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
throw new ApiException(400, "ids 不可为空");
}
if (ids.size() > 200) {
throw new ApiException(400, "单次批量操作最多 200 条");
}
}
private String resolveOperator(HttpServletRequest http) {
var u = currentUser.resolve(http);
return u != null ? u.getDisplayName() : "未知用户";
}
private void writeLog(Long archiveId, String title, String action,
String operator, String detail,
HttpServletRequest http, String now) {
ArchiveAccessLog log = new ArchiveAccessLog();
log.setArchiveId(archiveId);
log.setArchiveTitle(title);
log.setAction(action);
log.setOperator(operator);
log.setIp(http.getRemoteAddr());
log.setDetail(detail);
log.setOccurredAt(now);
accessLogRepo.save(log);
}
private String buildMetaDetail(MetaUpdateRequest req) {
java.util.LinkedHashMap<String, String> m = new java.util.LinkedHashMap<>();
if (req.tags() != null) m.put("tags", req.tags());
if (req.summary() != null) m.put("summary", req.summary().length() > 20 ? req.summary().substring(0, 20) + "" : req.summary());
if (req.accessLevel() != null) m.put("accessLevel", req.accessLevel());
if (req.archiveDate() != null) m.put("archiveDate", req.archiveDate());
StringBuilder sb = new StringBuilder("批量修改:");
m.forEach((k, v) -> sb.append(" ").append(k).append("=").append(v).append(";"));
return sb.toString();
}
}