Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/ItAccountAuditController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

268 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 来定位账号");
}
}