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 cf4f91f7..9cf9438f 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 @@ -143,12 +143,7 @@ public class AihrInterviewService { FROM aihr_candidate_employee_link WHERE tenant_id = '000000' AND candidate_id = ? LIMIT 1 - """, (rs, rowNum) -> new CandidateLinkResponse( - rs.getString("candidate_id"), - rs.getString("ext_party_id"), - rs.getString("status"), - rs.getTimestamp("linked_time") == null ? "" : rs.getTimestamp("linked_time").toLocalDateTime().format(TIME_FORMAT) - ), normalizedCandidateId).stream().findFirst().orElse(null); + """, this::mapCandidateLink, normalizedCandidateId).stream().findFirst().orElse(null); } public CandidateLinkResponse linkCandidate(CandidateLinkRequest request, String operator) { @@ -171,6 +166,15 @@ public class AihrInterviewService { if (activeEmployee == null || activeEmployee == 0) { throw new IllegalArgumentException("员工主体不存在或已离职,请先同步在职组织快照"); } + CandidateLinkResponse existingPartyLink = jdbcTemplate.query(""" + SELECT candidate_id, ext_party_id, status, linked_time + FROM aihr_candidate_employee_link + WHERE tenant_id = '000000' AND ext_party_id = ? + LIMIT 1 + """, this::mapCandidateLink, extPartyId).stream().findFirst().orElse(null); + if (existingPartyLink != null && !candidateId.equals(existingPartyLink.candidateId())) { + throw new IllegalArgumentException("员工主体已关联其他候选人,请先由HR核对后处理"); + } CandidateLinkResponse current = candidateLink(candidateId); if (current != null && !extPartyId.equals(current.extPartyId())) { throw new IllegalArgumentException("候选人已关联其他员工主体,请先由HR核对后处理"); @@ -198,6 +202,15 @@ public class AihrInterviewService { ); } + private CandidateLinkResponse mapCandidateLink(ResultSet rs, int rowNum) throws SQLException { + return new CandidateLinkResponse( + rs.getString("candidate_id"), + rs.getString("ext_party_id"), + rs.getString("status"), + rs.getTimestamp("linked_time") == null ? "" : rs.getTimestamp("linked_time").toLocalDateTime().format(TIME_FORMAT) + ); + } + private void saveResult(String sessionId, InterviewSession session, ScoreResult score) { ensureResultTable(); jdbcTemplate.update(""" @@ -303,7 +316,7 @@ public class AihrInterviewService { `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`), - KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`) + UNIQUE KEY `uk_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联'; """); candidateLinkTableReady = true; 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 c1192ee7..66bf5590 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 @@ -6,6 +6,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; @@ -53,6 +54,25 @@ class AihrInterviewServiceTest { verify(jdbcTemplate, never()).update(anyString(), eq("candidate-2"), eq("employee-new"), eq("hr")); } + @Test + @Tag("dev") + void linkCandidateRejectsAnEmployeeAlreadyLinkedToAnotherCandidate() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate)); + CandidateLinkResponse existing = new CandidateLinkResponse("candidate-old", "employee-4", "已关联", "07-14 06:00"); + doReturn(null).when(service).candidateLink("candidate-4"); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-4"))).thenReturn(1); + when(jdbcTemplate.query(anyString(), org.mockito.ArgumentMatchers.>any(), eq("employee-4"))) + .thenReturn(java.util.List.of(existing)); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate( + new CandidateLinkRequest("candidate-4", "employee-4"), "hr" + )); + + assertEquals("员工主体已关联其他候选人,请先由HR核对后处理", error.getMessage()); + verify(jdbcTemplate, never()).update(anyString(), eq("candidate-4"), eq("employee-4"), eq("hr")); + } + @Test @Tag("dev") void linkCandidateIsIdempotentForTheSameEmployee() { diff --git a/backend/script/sql/aihr_interview_result_mysql8.sql b/backend/script/sql/aihr_interview_result_mysql8.sql index 6d905c06..e9d09122 100644 --- a/backend/script/sql/aihr_interview_result_mysql8.sql +++ b/backend/script/sql/aihr_interview_result_mysql8.sql @@ -38,5 +38,5 @@ CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` ( `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`), - KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`) + UNIQUE KEY `uk_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联'; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 8b4331f2..5aa824da 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -7,7 +7,7 @@ | 页面 | 后端接口 | 处理 | |---|---|---| | AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 | -| 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在候选人面试记录页把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,关联其他主体会拒绝 | +| 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在候选人面试记录页把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index c634c841..f0d434af 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -84,7 +84,7 @@ - 2026-07-14 权限审查:候选人面试、案例处理、ASR/TTS、SOP 搜索/反馈/总结卡、候选人资料接口均补控制器级登录门禁;匿名首页与 `position-sop` 导航摘要仍保持公开只读,写入、模型调用和候选人私有记录不再依赖业务层“失败返回”兜底。 - 2026-07-14 案例详情审查:管理端案例详情已同步展示 `mediaUrl` 原始音频播放器,与员工端音频回放保持一致;当前仍没有正式预渲染视频样片、学习点、主管点评和相关训练场景字段,因此不把音频回放计为完整视频学习闭环。 - 2026-07-14 BRD 深度复核:重新按“业务条款→源码路径→数据证据”核对岗位-SOP、训练/错题本、案例详情、RAG、候选人面试、组织权限和成长证据,补发现首批场景尚未覆盖 BRD 明列的服务推介/增值与日常服务内容;`mobile-uni` H5 构建、类型检查与 49 个用户侧单测通过,AIHR 案例/对练定向测试 31 个通过。剩余 P0/P1 仍是正式组织映射、住宅项目试点数据、补齐场景内容、方言供应商与样本、预渲染视频资产、HR 规则契约等输入,不能用继续扩页面或未经确认的 seed 话术替代。 -- 2026-07-14 候选人入职链路补齐:管理端候选人面试页新增 HR/管理员将本地候选人 ID 关联至已同步在职 `ext_party_id` 的入口,后端以独立关系表保存租户、主体 ID、状态和操作审计,不复制组织姓名/部门;关联目标必须存在于在职组织快照,重复同主体幂等,关联其他主体拒绝。正式组织同步和真实入职数据仍需试点验证。 +- 2026-07-14 候选人入职链路补齐:管理端候选人面试页新增 HR/管理员将本地候选人 ID 关联至已同步在职 `ext_party_id` 的入口,后端以独立关系表保存租户、主体 ID、状态和操作审计,不复制组织姓名/部门;关联目标必须存在于在职组织快照,重复同主体幂等,候选人更换主体或同一主体被其他候选人占用均拒绝,并由唯一约束兜底。正式组织同步和真实入职数据仍需试点验证。 - 2026-07-14 线上资源只读核对:生产管理端当前加载 `/assets/index-CVcvQ1KE.js`,可检出提示词模板标记但检不出本分支新增的 `case-audio` 管理端播放器标记;因此 `6292e741` 的管理端案例音频回放尚未发布,不能把本地源码状态当作线上状态。发布前需按现有备份、资源哈希和浏览器回归流程执行。 - 2026-07-14 发布差异取证:当前分支生产构建管理端主 JS `frontend/dist/assets/index-CJZ3Ax3Z.js` SHA-256 为 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`,后端 `backend/ruoyi-admin/target/ruoyi-admin.jar` 为 `2817e0b30a4a0bc52cd5d88a8dd68c52981698185fb5320c24fa2743ab17ccf7`;生产仍加载 `index-CVcvQ1KE.js`,远端 jar SHA-256 为 `18f4643f401aafeab3ee896d7d6341736d5d7ee2343fee0fa86992c5797adcb9`。本轮只读核对,未执行远端写入。 - 2026-07-14 06:07 发布完成:备份目录为 `/opt/wygj/backups/release-20260714060705`;管理端、`mobile-uni` H5 和后端 jar 均已同步,线上管理端加载 `index-CJZ3Ax3Z.js`,H5 加载 `index-D4-NrEpb.js`,H5 主 JS SHA-256 为 `ab15bfd17cabe58e2f34b0ac61ee198a837128ed85dc6bb0ddf77bc3e22dcd9c`,后端 jar SHA-256 为 `2817e0b30a4a0bc52cd5d88a8dd68c52981698185fb5320c24fa2743ab17ccf7`;`wygj-aihr.service` active,生产根站点、`/h5/`、`/prod-api/auth/tenant/list` 和员工首页 API 均返回 200,线上管理端资源可检出 `case-audio`。重启瞬间的短暂 502 在服务启动完成后恢复,未形成持续故障;Chrome headless 已确认管理端标题、H5 标题和员工首页真实渲染。