From cd756150a1e585cbced7fda70b734687389ed68d Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 09:59:44 +0800 Subject: [PATCH] fix(aihr): bind candidate interview sessions to owner --- .../controller/AihrInterviewController.java | 4 +- .../aihr/service/AihrInterviewService.java | 37 +++++++++++++++---- .../service/AihrInterviewServiceTest.java | 25 +++++++++++++ docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + scripts/demo-check.sh | 3 ++ 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java index 6b9d4b1e..dcf9c6ac 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java @@ -51,12 +51,12 @@ public class AihrInterviewController { @PostMapping("/answer") public R answer(@RequestBody AnswerRequest request) { - return R.ok(interviewService.answer(request)); + return R.ok(interviewService.answer(request, currentAppUsername())); } @PostMapping("/finish") public R finish(@RequestBody FinishRequest request) { - return R.ok(interviewService.finish(request)); + return R.ok(interviewService.finish(request, currentAppUsername())); } @GetMapping("/records") 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 81457bbe..c2c5e1a7 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 @@ -64,8 +64,15 @@ public class AihrInterviewService { } public AnswerResponse answer(AnswerRequest request) { + return answer(request, ""); + } + + public AnswerResponse answer(AnswerRequest request, String ownerIdentity) { String answerText = request == null ? "" : clean(request.answerText()); InterviewSession session = sessions.get(request == null ? null : request.sessionId()); + if (!isBlank(ownerIdentity)) { + requireSessionOwner(session, ownerIdentity); + } if (session != null && request != null && !isBlank(request.questionId())) { session.answers().put(request.questionId(), answerText); } @@ -78,14 +85,24 @@ public class AihrInterviewService { } public FinishResponse finish(FinishRequest request) { + return finish(request, ""); + } + + public FinishResponse finish(FinishRequest request, String ownerIdentity) { String sessionKey = request == null || isBlank(request.sessionId()) ? "iv-" + UUID.randomUUID() : clean(request.sessionId()); - InterviewSession session = sessions.computeIfAbsent( - sessionKey, - key -> { - CandidateProfile candidate = resolveCandidate(null, null, null, key); - return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); - } - ); + InterviewSession session; + if (isBlank(ownerIdentity)) { + session = sessions.computeIfAbsent( + sessionKey, + key -> { + CandidateProfile candidate = resolveCandidate(null, null, null, key); + return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); + } + ); + } else { + session = sessions.get(sessionKey); + requireSessionOwner(session, ownerIdentity); + } if (request != null && request.answers() != null) { request.answers().forEach((questionId, answer) -> { if (!isBlank(questionId)) { @@ -570,6 +587,12 @@ public class AihrInterviewService { return candidates.get("zhang-ming"); } + private static void requireSessionOwner(InterviewSession session, String ownerIdentity) { + if (session == null || !clean(ownerIdentity).equals(clean(session.candidate().id()))) { + 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 40d0d156..e6714cbb 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 @@ -3,6 +3,7 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest; import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse; +import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest; @@ -174,4 +175,28 @@ class AihrInterviewServiceTest { assertEquals("待复核", record.reviewStatus()); assertEquals(record.score(), record.finalScore()); } + + @Test + @Tag("dev") + void appInterviewSessionRejectsForeignAndUnknownOwner() { + 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 ownerSession = service.start(new StartRequest("candidate-a", "候选人A", "customer-service", "mobile")); + var otherSession = service.start(new StartRequest("candidate-b", "候选人B", "customer-service", "mobile")); + + IllegalArgumentException foreign = assertThrows(IllegalArgumentException.class, () -> service.answer( + new AnswerRequest(otherSession.sessionId(), "q1", "越权回答", null), "candidate-a" + )); + assertEquals("面试会话不存在或无权访问", foreign.getMessage()); + + IllegalArgumentException unknown = assertThrows(IllegalArgumentException.class, () -> service.finish( + new FinishRequest("iv-not-started", Map.of("q1", "越权完成")), "candidate-a" + )); + assertEquals("面试会话不存在或无权访问", unknown.getMessage()); + + service.answer(new AnswerRequest(ownerSession.sessionId(), "q1", "本人回答", null), "candidate-a"); + } } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 389dcdd0..14d53b70 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -19,7 +19,7 @@ | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | -| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | +| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | ## 后端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index f0abd8d9..3d31e96f 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -163,3 +163,4 @@ - 2026-07-14 只读发布预检:当前提交 `07557656` 工作区干净,本地管理端、`mobile-uni` H5 与后端 jar 均有产物;生产根站点与租户接口返回 200。本轮权限修复尚未执行生产同步、服务重启或业务数据写入,不能把本地提交视为线上已生效。 - 2026-07-14 AI 面试人工复核闭环:`aihr_interview_result` 现在持久化 AI 分、人工复核分、最终采用分、复核状态、复核人、意见和时间;HR/管理员可通过 `/api/recruit/interview/records/{sessionId}/review` 复核,候选人端无该角色权限。`finish` 响应同步返回真实 `sessionId`,便于后续复核定位。AIHR 全量测试 `65/65`、真实本地 HTTP 烟测和旧库懒迁移验证通过;正式试点仍需抽查人工复核证据,生产尚未发布。 - 2026-07-14 线上资源只读核对:生产根站、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`;生产管理端仍加载 `index-CJZ3Ax3Z.js`,本地当前管理端构建为 `index-D4ywKu9Y.js`。本次只读检查,未执行生产写入、静态资源同步或后端重启,因此不能把本地人工复核修复宣称为线上已生效。 +- 2026-07-14 BRD G3 面试会话归属复核:发现候选人端 `answer/finish` 原先只按客户端提交的 `sessionId` 查内存会话,未知会话还会自动创建并落库;现由控制器把当前 APP 手机号传入服务,APP 请求必须匹配启动会话的候选人 ID,外部会话和未知会话统一拒绝;后台系统调用保留原有兼容行为。新增越权/未知会话回归测试与 `demo-check` marker,未修改生产环境。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index b7e784d1..9935024a 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -388,6 +388,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "无权查看面试记录" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/records/{sessionId}/review" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "reviewed_score" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "interviewService.answer(request, currentAppUsername())" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "interviewService.finish(request, currentAppUsername())" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "面试会话不存在或无权访问" contains frontend/src/views/recruit/interview.vue "人工复核 AI 面试结果" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "@SaCheckRole" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "aihr_candidate_employee_link"