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:
@@ -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 联动触发:入职/离职/岗位变动自动操作系统账号,并写审计日志。
|
||||
* hrEventType:HR入职(→ 启用账号)/ 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 来定位账号");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user