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,267 @@
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.ItAccountAuditRecord;
import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.repository.ItAccountAuditRecordRepository;
import com.kaidi.oa.repository.SysUserRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 信息部·账号权限审计 + HR 联动(需求 §4 权限与账号审计 / §6 HR联动)。
*
* <p>两大能力:
* <ol>
* <li><b>手动审计</b>:IT 人员定期对系统账号逐条核查,填写审计结论(通过/异常),
* 形成账号权限定期审计报告({@code GET /report})。</li>
* <li><b>HR 联动触发</b>:员工离职/入职/岗位变动由 HR 系统或 IT 人员调用
* {@code POST /hr-trigger} ,自动 enable/disable SysUser 账号,并留下审计记录
* triggerSource=HR离职/HR入职/HR岗位变动)。这就是"离职→系统账号自动disable"
* 的跨模块联动落地,而非前端只有一个静态字段。</li>
* </ol>
* </p>
*
* <ul>
* <li>{@code POST /hr-trigger} 触发 HR 联动(enable/disable/adjust 账号);</li>
* <li>{@code POST /manual-audit} 手动审计一条账号记录;</li>
* <li>{@code GET /report} 账号权限审计报告(按日期/来源汇总,附全账号状态快照);</li>
* <li>{@code GET /logs} 全部审计/联动日志;</li>
* </ul>
*
* 写口含账号信息,默认受 default-deny(ADMIN/APPROVER) 保护;读侧含账号状态,登记 SENSITIVE_READ_PREFIXES。
*/
@RestController
@RequestMapping("/api/oa/it-account-audit")
public class ItAccountAuditController {
private final ItAccountAuditRecordRepository auditRepo;
private final SysUserRepository userRepo;
public ItAccountAuditController(ItAccountAuditRecordRepository auditRepo,
SysUserRepository userRepo) {
this.auditRepo = auditRepo;
this.userRepo = userRepo;
}
// ---------- HR 联动触发 ----------
public record HrTriggerRequest(
Long userId, String loginName,
String hrEventType, String hrEventDesc,
String operator, String note) {
}
/**
* HR 联动触发:入职/离职/岗位变动自动操作系统账号,并写审计日志。
* hrEventTypeHR入职(→ 启用账号)/ HR离职(→ 禁用账号)/ HR岗位变动(审计记录岗位调整)。
* userId 或 loginName 至少提供一个来定位账号。
*/
@Transactional
@PostMapping("/hr-trigger")
public ApiResp<ItAccountAuditRecord> hrTrigger(@RequestBody HrTriggerRequest req) {
if (req.hrEventType() == null || req.hrEventType().isBlank()) {
throw new ApiException(400, "hrEventType 不能为空(HR入职/HR离职/HR岗位变动)");
}
SysUser user = resolveUser(req.userId(), req.loginName());
boolean beforeEnabled = user.isEnabled();
String beforeState = beforeEnabled ? "已启用" : "已禁用";
String action;
String afterState;
switch (req.hrEventType()) {
case "HR离职" -> {
user.setEnabled(false);
action = "禁用账号";
afterState = "已禁用";
}
case "HR入职" -> {
user.setEnabled(true);
action = "启用账号";
afterState = "已启用";
}
case "HR岗位变动" -> {
// 岗位变动不直接改 enabled,仅留审计记录供 IT 人员人工核查权限是否合理。
action = "调整权限";
afterState = beforeState;
}
default -> throw new ApiException(400, "不支持的 hrEventType" + req.hrEventType()
+ "HR入职/HR离职/HR岗位变动)");
}
userRepo.save(user);
ItAccountAuditRecord rec = new ItAccountAuditRecord();
rec.setTargetUserId(user.getId());
rec.setTargetUserName(user.getDisplayName());
rec.setTargetLoginName(user.getLoginName());
rec.setTriggerSource(req.hrEventType());
rec.setHrEventDesc(req.hrEventDesc());
rec.setAction(action);
rec.setBeforeState(beforeState);
rec.setAfterState(afterState);
rec.setOperator(req.operator() == null ? "系统自动" : req.operator());
rec.setAuditDate(LocalDate.now().toString());
rec.setNote(req.note());
rec.setCreatedAt(Instant.now());
return ApiResp.ok(auditRepo.save(rec));
}
// ---------- 手动审计 ----------
public record ManualAuditRequest(
Long userId, String loginName,
String action, String auditConclusion,
String beforeRoles, String afterRoles,
String operator, String note) {
}
/**
* 手动账号权限审计:IT 人员对指定账号做人工核查,记录审计结论。
* action:审查通过 / 审查异常 / 注销账号 / 禁用账号 / 启用账号 / 调整权限。
*/
@Transactional
@PostMapping("/manual-audit")
public ApiResp<ItAccountAuditRecord> manualAudit(@RequestBody ManualAuditRequest req) {
if (req.action() == null || req.action().isBlank()) {
throw new ApiException(400, "审计动作(action) 不能为空");
}
SysUser user = resolveUser(req.userId(), req.loginName());
String beforeState = user.isEnabled() ? "已启用" : "已禁用";
String afterState = beforeState;
// 部分动作直接操作账号
if ("注销账号".equals(req.action()) || "禁用账号".equals(req.action())) {
user.setEnabled(false);
afterState = "已禁用";
userRepo.save(user);
} else if ("启用账号".equals(req.action())) {
user.setEnabled(true);
afterState = "已启用";
userRepo.save(user);
}
ItAccountAuditRecord rec = new ItAccountAuditRecord();
rec.setTargetUserId(user.getId());
rec.setTargetUserName(user.getDisplayName());
rec.setTargetLoginName(user.getLoginName());
rec.setTriggerSource("手动审计");
rec.setAction(req.action());
rec.setBeforeState(beforeState);
rec.setAfterState(afterState);
rec.setBeforeRoles(req.beforeRoles());
rec.setAfterRoles(req.afterRoles());
rec.setOperator(req.operator());
rec.setAuditConclusion(req.auditConclusion());
rec.setAuditDate(LocalDate.now().toString());
rec.setNote(req.note());
rec.setCreatedAt(Instant.now());
return ApiResp.ok(auditRepo.save(rec));
}
// ---------- 审计报告 + 日志 ----------
@GetMapping("/logs")
public ApiResp<List<ItAccountAuditRecord>> logs(
@RequestParam(required = false) String triggerSource,
@RequestParam(required = false) String action) {
if (triggerSource != null && !triggerSource.isBlank()) {
return ApiResp.ok(auditRepo.findByTriggerSource(triggerSource));
}
if (action != null && !action.isBlank()) {
return ApiResp.ok(auditRepo.findByAction(action));
}
return ApiResp.ok(auditRepo.findAll());
}
public record AccountSnapshot(Long userId, String displayName, String loginName,
boolean enabled, String title) {
}
public record AuditReport(String generatedAt, int totalUsers, int enabledCount, int disabledCount,
int hrTriggerCount, int manualAuditCount, int anomalyCount,
List<AccountSnapshot> disabledAccounts,
Map<String, Integer> actionDistribution,
List<ItAccountAuditRecord> recentLogs) {
}
/**
* 账号权限审计报告(IT 治理合规核心):
* 全量系统账号状态快照(已禁用账号清单)、HR联动次数、手动审计次数、
* 异常数、动作分布、近期审计日志。
*/
@GetMapping("/report")
public ApiResp<AuditReport> report() {
List<SysUser> allUsers = userRepo.findAll();
int enabled = 0, disabled = 0;
List<AccountSnapshot> disabledList = new ArrayList<>();
for (SysUser u : allUsers) {
if (u.isEnabled()) {
enabled++;
} else {
disabled++;
disabledList.add(new AccountSnapshot(u.getId(), u.getDisplayName(),
u.getLoginName(), false, u.getTitle()));
}
}
List<ItAccountAuditRecord> allLogs = auditRepo.findAll();
int hrCount = 0, manualCount = 0, anomalyCount = 0;
Map<String, Integer> actionDist = new LinkedHashMap<>();
for (ItAccountAuditRecord r : allLogs) {
if ("手动审计".equals(r.getTriggerSource())) {
manualCount++;
} else {
hrCount++;
}
if ("审查异常".equals(r.getAction())) {
anomalyCount++;
}
if (r.getAction() != null) {
actionDist.merge(r.getAction(), 1, Integer::sum);
}
}
// 取最近 20 条日志(按 createdAt 倒排)
List<ItAccountAuditRecord> sorted = allLogs.stream()
.sorted((a, b) -> b.getCreatedAt() != null && a.getCreatedAt() != null
? b.getCreatedAt().compareTo(a.getCreatedAt()) : 0)
.limit(20).toList();
return ApiResp.ok(new AuditReport(
Instant.now().toString(),
allUsers.size(), enabled, disabled,
hrCount, manualCount, anomalyCount,
disabledList, actionDist, sorted));
}
// ---------- helpers ----------
private SysUser resolveUser(Long userId, String loginName) {
if (userId != null) {
return userRepo.findById(userId)
.orElseThrow(() -> new NotFoundException("系统账号不存在,userId=" + userId));
}
if (loginName != null && !loginName.isBlank()) {
return userRepo.findByLoginName(loginName)
.orElseThrow(() -> new NotFoundException("系统账号不存在,loginName=" + loginName));
}
throw new ApiException(400, "必须提供 userId 或 loginName 来定位账号");
}
}