From a47a3edfd2eb07ed90569ef91087676ac20227e2 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 14:37:58 +0800 Subject: [PATCH] fix(aihr): reject unknown interview questions --- .../aihr/service/AihrInterviewService.java | 9 +++++ .../service/AihrInterviewServiceTest.java | 38 +++++++++++++++++++ docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + scripts/demo-check.sh | 1 + 4 files changed, 49 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 8db0bee5..06fc9acc 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 @@ -74,6 +74,7 @@ public class AihrInterviewService { requireSessionOwner(session, ownerIdentity); } if (session != null && request != null && !isBlank(request.questionId())) { + requireKnownQuestion(session, request.questionId()); session.answers().put(request.questionId(), answerText); } int baseScore = answerText.isBlank() ? 0 : Math.min(92, 62 + Math.min(answerText.length() / 4, 30)); @@ -106,6 +107,7 @@ public class AihrInterviewService { if (request != null && request.answers() != null) { request.answers().forEach((questionId, answer) -> { if (!isBlank(questionId)) { + requireKnownQuestion(session, questionId); session.answers().put(questionId, clean(answer)); } }); @@ -623,6 +625,13 @@ public class AihrInterviewService { } } + private static void requireKnownQuestion(InterviewSession session, String questionId) { + boolean known = session.questions().stream().anyMatch(question -> question.questionId().equals(questionId)); + if (!known) { + throw new IllegalArgumentException("面试题不存在"); + } + } + private CandidateProfile dynamicCandidate(String candidateId, String candidateName, String positionCode) { CandidateProfile template = candidates.get("zhang-ming"); String cleanId = clean(candidateId); 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 630fed95..9c32633b 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 @@ -220,6 +220,44 @@ class AihrInterviewServiceTest { verify(jdbcTemplate, never()).update(anyString(), org.mockito.ArgumentMatchers.any()); } + @Test + @Tag("dev") + void interviewAnswerRejectsUnknownQuestion() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty()); + AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), modelService, jdbcTemplate); + var started = service.start(new StartRequest("candidate-unknown-question", "候选人", "customer-service", "mobile")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.answer( + new AnswerRequest(started.sessionId(), "q999", "伪造题目回答", null) + )); + + assertEquals("面试题不存在", error.getMessage()); + } + + @Test + @Tag("dev") + void interviewFinishRejectsUnknownQuestion() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty()); + AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), modelService, jdbcTemplate); + var started = service.start(new StartRequest("candidate-finish-unknown-question", "候选人", "customer-service", "mobile")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.finish( + new FinishRequest(started.sessionId(), Map.of( + "q1", "确认诉求并安抚情绪", + "q2", "说明费用和整改安排", + "q3", "同步责任人和反馈时间", + "q999", "伪造题目回答" + )) + )); + + assertEquals("面试题不存在", error.getMessage()); + verify(jdbcTemplate, never()).update(anyString(), org.mockito.ArgumentMatchers.any()); + } + @Test @Tag("dev") void reviewResultRejectsOutOfRangeScore() { diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 065dd8ab..80672e0a 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -288,3 +288,4 @@ - 2026-07-14 BRD 4.1 面试作答完整性修复:面试 `finish` 此前允许没有任何对应题目有效回答仍生成本地 55 分结果并进入人工复核;现要求至少提交一条有效面试回答,空面试不落结果表。新增服务回归测试和 `demo-check` marker,未修改业务数据。 - 2026-07-14 BRD 4.1 面试完成门禁收紧:前端要求完成全部面试题,但后端此前只要求至少一题有效回答,部分回答仍可生成面试结果;现 `finish` 要求所有服务端题目均有非空回答,空/部分回答均不落结果表。新增部分作答回归测试和 `demo-check` marker,未修改业务数据。 - 2026-07-14 BRD 发布状态只读复核:生产根站、`/h5/`、租户列表和员工首页 API 均返回 `200`,但线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`;当前分支 `c4f98b9a` 的面试完成门禁修复尚未发布。本轮未执行生产静态同步、后端重启或业务数据写入。 +- 2026-07-14 BRD 4.1 面试证据输入边界修复:`answer/finish` 此前接受不存在于服务端题目列表的 `questionId`,可能把伪造字段写入回答快照;现统一拒绝未知题目 ID,评分和持久化只接受本次服务端生成的题目集合。新增 answer/finish 回归测试和 `demo-check` marker,未修改业务数据。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 75ac3517..8c522b35 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -461,6 +461,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/AihrInterviewService.java "finished_time IS NOT NULL AND total_score IS NOT NULL" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "reviewed_score = NULL" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "请先完成全部面试回答" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "面试题不存在" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "noneMatch(AihrPracticeSeedService::canSeeProject)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "@SaCheckLogin" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "sessions/{sessionId}/annotations"