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联动)。 * *

两大能力: *

    *
  1. 手动审计:IT 人员定期对系统账号逐条核查,填写审计结论(通过/异常), * 形成账号权限定期审计报告({@code GET /report})。
  2. *
  3. HR 联动触发:员工离职/入职/岗位变动由 HR 系统或 IT 人员调用 * {@code POST /hr-trigger} ,自动 enable/disable SysUser 账号,并留下审计记录 * (triggerSource=HR离职/HR入职/HR岗位变动)。这就是"离职→系统账号自动disable" * 的跨模块联动落地,而非前端只有一个静态字段。
  4. *
*

* * * * 写口含账号信息,默认受 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 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 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> 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 disabledAccounts, Map actionDistribution, List recentLogs) { } /** * 账号权限审计报告(IT 治理合规核心): * 全量系统账号状态快照(已禁用账号清单)、HR联动次数、手动审计次数、 * 异常数、动作分布、近期审计日志。 */ @GetMapping("/report") public ApiResp report() { List allUsers = userRepo.findAll(); int enabled = 0, disabled = 0; List 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 allLogs = auditRepo.findAll(); int hrCount = 0, manualCount = 0, anomalyCount = 0; Map 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 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 来定位账号"); } }