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,180 @@
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.ArchiveAcl;
import com.kaidi.oa.repository.ArchiveAclRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.LocalDate;
import java.util.List;
/**
* 资料室·细粒度权限管理(Module 6「细粒度权限」缺口:LOGIC_GAP → 补深)。
* 每条 ArchiveAcl 为「某授权主体(部门/岗位/项目组/个人)」对「某档案/某分类」的操作位授权
* (可见/预览/下载/打印/改/删/授权),支持按部门/岗位/项目组动态授权,expireDate 到期失效。
* 补审计「细粒度权限无、ArchiveController 仅密级粗粒度、脱敏无」缺口。
*
* <p>授权查询:
* <ul>
* <li>GET /by-target?targetType=档案&targetId=1 — 某档案/分类的全部授权条目(供权限配置页展示);</li>
* <li>GET /by-grantee?granteeType=部门&granteeKey=工程部 — 某主体可访问的全部条目;</li>
* <li>GET /check?targetType=档案&targetId=1&granteeType=个人&granteeKey=张三 — 查某主体对某档案的合并权限位。</li>
* </ul>
*
* <p>撤销授权:PUT /{id}/revokeenabled=false),保留记录留痕而不物理删除。
* 写口受 default-deny(ADMIN/APPROVER) 保护。
*/
@RestController
@RequestMapping("/api/oa/archive-acls")
public class ArchiveAclController {
private final ArchiveAclRepository aclRepo;
public ArchiveAclController(ArchiveAclRepository aclRepo) {
this.aclRepo = aclRepo;
}
/** 某档案或分类下的全部(含已撤销)授权条目。 */
@GetMapping("/by-target")
public ApiResp<List<ArchiveAcl>> byTarget(@RequestParam String targetType,
@RequestParam Long targetId) {
return ApiResp.ok(aclRepo.findByTargetTypeAndTargetId(targetType, targetId));
}
/** 某授权主体的全部有效授权。 */
@GetMapping("/by-grantee")
public ApiResp<List<ArchiveAcl>> byGrantee(@RequestParam String granteeType,
@RequestParam String granteeKey) {
return ApiResp.ok(aclRepo.findByGranteeTypeAndGranteeKey(granteeType, granteeKey)
.stream().filter(a -> Boolean.TRUE.equals(a.getEnabled())).toList());
}
/**
* 合并权限检查:查某主体对某档案的合并权限位(取所有 enabled 条目按 OR 合并 7 个操作位)。
* 用于前端在档案预览页决定是否显示下载/打印按钮。
*/
@GetMapping("/check")
public ApiResp<AclCheckResult> check(@RequestParam String targetType,
@RequestParam Long targetId,
@RequestParam String granteeType,
@RequestParam String granteeKey) {
List<ArchiveAcl> acls = aclRepo.findByTargetTypeAndTargetId(targetType, targetId)
.stream()
.filter(a -> Boolean.TRUE.equals(a.getEnabled())
&& granteeType.equals(a.getGranteeType())
&& granteeKey.equals(a.getGranteeKey()))
.toList();
// 检查是否到期
String today = LocalDate.now().toString();
List<ArchiveAcl> effective = acls.stream()
.filter(a -> a.getExpireDate() == null || a.getExpireDate().compareTo(today) >= 0)
.toList();
boolean canView = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanView()));
boolean canPreview = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanPreview()));
boolean canDownload = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanDownload()));
boolean canPrint = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanPrint()));
boolean canEdit = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanEdit()));
boolean canDelete = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanDelete()));
boolean canGrant = effective.stream().anyMatch(a -> Boolean.TRUE.equals(a.getCanGrant()));
return ApiResp.ok(new AclCheckResult(granteeType, granteeKey, targetType, targetId,
canView, canPreview, canDownload, canPrint, canEdit, canDelete, canGrant));
}
public record AclCheckResult(
String granteeType, String granteeKey, String targetType, Long targetId,
boolean canView, boolean canPreview, boolean canDownload, boolean canPrint,
boolean canEdit, boolean canDelete, boolean canGrant) {
}
@GetMapping("/{id}")
public ApiResp<ArchiveAcl> get(@PathVariable Long id) {
return ApiResp.ok(require(id));
}
/** 全部有效授权列表(供权限管理总表展示)。 */
@GetMapping
public ApiResp<List<ArchiveAcl>> list() {
return ApiResp.ok(aclRepo.findByEnabledTrue());
}
public record AclRequest(
String granteeType, String granteeKey, String targetType, Long targetId, String targetName,
Boolean canView, Boolean canPreview, Boolean canDownload, Boolean canPrint,
Boolean canEdit, Boolean canDelete, Boolean canGrant,
String expireDate, String grantedBy, String remark) {
}
/** 新增授权条目。 */
@PostMapping
public ApiResp<ArchiveAcl> create(@RequestBody AclRequest req) {
if (req.granteeType() == null || req.granteeType().isBlank()) {
throw new ApiException(400, "授权主体类型(granteeType) 不能为空");
}
if (req.granteeKey() == null || req.granteeKey().isBlank()) {
throw new ApiException(400, "授权主体标识(granteeKey) 不能为空");
}
if (req.targetType() == null || req.targetId() == null) {
throw new ApiException(400, "授权目标(targetType/targetId) 不能为空");
}
ArchiveAcl a = new ArchiveAcl();
applyAcl(a, req);
a.setEnabled(Boolean.TRUE);
a.setGrantDate(LocalDate.now().toString());
a.setCreatedAt(Instant.now());
return ApiResp.ok(aclRepo.save(a));
}
/** 修改授权条目。 */
@PatchMapping("/{id}")
public ApiResp<ArchiveAcl> update(@PathVariable Long id, @RequestBody AclRequest req) {
ArchiveAcl a = require(id);
applyAcl(a, req);
return ApiResp.ok(aclRepo.save(a));
}
/** 撤销授权(enabled→false,留痕不删除)。 */
@PostMapping("/{id}/revoke")
public ApiResp<ArchiveAcl> revoke(@PathVariable Long id) {
ArchiveAcl a = require(id);
if (!Boolean.TRUE.equals(a.getEnabled())) {
throw new ApiException(409, "该授权条目已撤销");
}
a.setEnabled(Boolean.FALSE);
return ApiResp.ok(aclRepo.save(a));
}
private void applyAcl(ArchiveAcl a, AclRequest req) {
if (req.granteeType() != null) a.setGranteeType(req.granteeType().trim());
if (req.granteeKey() != null) a.setGranteeKey(req.granteeKey().trim());
if (req.targetType() != null) a.setTargetType(req.targetType().trim());
if (req.targetId() != null) a.setTargetId(req.targetId());
if (req.targetName() != null) a.setTargetName(req.targetName().trim());
if (req.canView() != null) a.setCanView(req.canView());
if (req.canPreview() != null) a.setCanPreview(req.canPreview());
if (req.canDownload() != null) a.setCanDownload(req.canDownload());
if (req.canPrint() != null) a.setCanPrint(req.canPrint());
if (req.canEdit() != null) a.setCanEdit(req.canEdit());
if (req.canDelete() != null) a.setCanDelete(req.canDelete());
if (req.canGrant() != null) a.setCanGrant(req.canGrant());
if (req.expireDate() != null) a.setExpireDate(req.expireDate().isBlank() ? null : req.expireDate().trim());
if (req.grantedBy() != null) a.setGrantedBy(req.grantedBy().trim());
if (req.remark() != null) a.setRemark(req.remark());
}
private ArchiveAcl require(Long id) {
return aclRepo.findById(id)
.orElseThrow(() -> new NotFoundException("archive acl not found: " + id));
}
}