From 94a3d0fe5bc097cf7cc780ed9a8f2f2bee3e7d4a Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 10:46:39 +0800 Subject: [PATCH] fix(aihr): validate candidate links --- .../aihr/service/AihrInterviewService.java | 22 +++++++++++++++++++ .../service/AihrInterviewServiceTest.java | 17 ++++++++++++++ docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + scripts/demo-check.sh | 1 + 4 files changed, 41 insertions(+) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index c2c5e1a7..3e47120d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -238,6 +238,9 @@ public class AihrInterviewService { if (current != null && !extPartyId.equals(current.extPartyId())) { throw new IllegalArgumentException("候选人已关联其他员工主体,请先由HR核对后处理"); } + if (current == null && !candidateExists(candidateId)) { + throw new IllegalArgumentException("候选人不存在,请先创建或完成一次面试记录"); + } jdbcTemplate.update(""" INSERT INTO aihr_candidate_employee_link (tenant_id, candidate_id, ext_party_id, status, linked_by, linked_time, create_time, update_time) @@ -248,6 +251,25 @@ public class AihrInterviewService { return candidateLink(candidateId); } + private boolean candidateExists(String candidateId) { + if (candidates.containsKey(candidateId) + || sessions.values().stream().anyMatch(session -> candidateId.equals(session.candidate().id()))) { + return true; + } + try { + ensureResultTable(); + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM aihr_interview_result + WHERE tenant_id = ? AND candidate_id = ? + """, Integer.class, tenantId(), candidateId); + return count != null && count > 0; + } catch (DataAccessException e) { + log.warn("resolve interview candidate failed(处理错误已隐藏)"); + return false; + } + } + private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException { var finishedTime = rs.getTimestamp("finished_time"); Integer aiScore = rs.getInt("total_score"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java index e6714cbb..42ddf3c6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java @@ -103,6 +103,23 @@ class AihrInterviewServiceTest { verify(jdbcTemplate).update(anyString(), eq("000000"), eq("candidate-3"), eq("employee-3"), eq("hr")); } + @Test + @Tag("dev") + void linkCandidateRejectsUnknownCandidate() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate)); + doReturn(null).when(service).candidateLink("candidate-unknown"); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("employee-5"))).thenReturn(1); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("candidate-unknown"))).thenReturn(0); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate( + new CandidateLinkRequest("candidate-unknown", "employee-5"), "hr" + )); + + assertEquals("候选人不存在,请先创建或完成一次面试记录", error.getMessage()); + verify(jdbcTemplate, never()).update(anyString(), eq("000000"), eq("candidate-unknown"), eq("employee-5"), eq("hr")); + } + @Test @Tag("dev") void interviewRecordsAreScopedToCurrentTenant() { diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index ea21c10c..03752e85 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -177,3 +177,4 @@ - 2026-07-14 BRD 4.4 每日三题资格补强:每日派题现在在组织快照存在 `hire_date` 列时按“入职日期至今不超过 3 个月”判断,边界按自然月且包含当天;旧 Demo 快照没有该列时继续使用“移动端训练记录少于 3 次”的兼容回退。正式试点仍需在外部组织同步落地并校验 `hire_date`,不能把回退口径当成正式新员工资格。 - 2026-07-14 BRD G3 候选资料审核状态复核:候选人资料审核接口此前在空请求体时默认写回“待审核”,可能覆盖已有“已通过/已驳回”状态;现要求审核动作显式提交三态之一,空值和未知状态直接拒绝,避免后台误操作回退审核结论。 - 2026-07-14 BRD 案例署名复核:案例库已有负责人字段,但此前上传、整理和入库记录都硬编码为“培训组”,无法追溯案例贡献者;现复用既有 `owner` 字段保存首次上传身份,整理和入库阶段保留原署名,手机号身份仅显示员工末四位,避免把原始手机号写入案例展示。 +- 2026-07-14 BRD 候选人关联完整性复核:候选人—员工关联接口此前只校验员工主体在职,允许给不存在的任意 `candidateId` 建立入职关联;现要求候选人必须来自内置候选档案、活动面试会话或当前租户已持久化面试记录,保留已有幂等关联和历史数据兼容。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 20733788..db3de829 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -515,6 +515,7 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "storeSourceAudio" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "currentCaseOwner" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "候选人不存在,请先创建或完成一次面试记录" contains mobile-uni/src/pages/user/cases/index.vue "case-audio" contains frontend/src/views/knowledge/cases.vue "case-audio" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_case_record"