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 9cf9438f..79bdb2ea 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 @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest; import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse; import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest; @@ -141,9 +142,9 @@ public class AihrInterviewService { return jdbcTemplate.query(""" SELECT candidate_id, ext_party_id, status, linked_time FROM aihr_candidate_employee_link - WHERE tenant_id = '000000' AND candidate_id = ? + WHERE tenant_id = ? AND candidate_id = ? LIMIT 1 - """, this::mapCandidateLink, normalizedCandidateId).stream().findFirst().orElse(null); + """, this::mapCandidateLink, tenantId(), normalizedCandidateId).stream().findFirst().orElse(null); } public CandidateLinkResponse linkCandidate(CandidateLinkRequest request, String operator) { @@ -159,19 +160,19 @@ public class AihrInterviewService { Integer activeEmployee = jdbcTemplate.queryForObject(""" SELECT COUNT(*) FROM aihr_org_snapshot - WHERE tenant_id = '000000' + WHERE tenant_id = ? AND employment_status = 'active' AND ext_party_id = ? - """, Integer.class, extPartyId); + """, Integer.class, tenantId(), extPartyId); 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 = ? + WHERE tenant_id = ? AND ext_party_id = ? LIMIT 1 - """, this::mapCandidateLink, extPartyId).stream().findFirst().orElse(null); + """, this::mapCandidateLink, tenantId(), extPartyId).stream().findFirst().orElse(null); if (existingPartyLink != null && !candidateId.equals(existingPartyLink.candidateId())) { throw new IllegalArgumentException("员工主体已关联其他候选人,请先由HR核对后处理"); } @@ -182,10 +183,10 @@ public class AihrInterviewService { jdbcTemplate.update(""" INSERT INTO aihr_candidate_employee_link (tenant_id, candidate_id, ext_party_id, status, linked_by, linked_time, create_time, update_time) - VALUES ('000000', ?, ?, '已关联', ?, now(), now(), now()) + VALUES (?, ?, ?, '已关联', ?, now(), now(), now()) ON DUPLICATE KEY UPDATE status = '已关联', linked_by = VALUES(linked_by), linked_time = now(), update_time = now() - """, candidateId, extPartyId, firstNonBlank(operator, "unknown")); + """, tenantId(), candidateId, extPartyId, firstNonBlank(operator, "unknown")); return candidateLink(candidateId); } @@ -211,6 +212,10 @@ public class AihrInterviewService { ); } + private String tenantId() { + return firstNonBlank(LoginHelper.getTenantId(), "000000"); + } + private void saveResult(String sessionId, InterviewSession session, ScoreResult score) { ensureResultTable(); jdbcTemplate.update(""" 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 66bf5590..14177974 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 @@ -27,14 +27,14 @@ class AihrInterviewServiceTest { void linkCandidateRejectsMissingOrInactiveEmployee() { JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate); - when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-inactive"))).thenReturn(0); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("employee-inactive"))).thenReturn(0); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate( new CandidateLinkRequest("candidate-1", "employee-inactive"), "hr" )); assertEquals("员工主体不存在或已离职,请先同步在职组织快照", error.getMessage()); - verify(jdbcTemplate, never()).update(anyString(), eq("candidate-1"), eq("employee-inactive"), eq("hr")); + verify(jdbcTemplate, never()).update(anyString(), eq("000000"), eq("candidate-1"), eq("employee-inactive"), eq("hr")); } @Test @@ -44,14 +44,14 @@ class AihrInterviewServiceTest { AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate)); CandidateLinkResponse current = new CandidateLinkResponse("candidate-2", "employee-old", "已关联", "07-14 06:00"); doReturn(current).when(service).candidateLink("candidate-2"); - when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-new"))).thenReturn(1); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("employee-new"))).thenReturn(1); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate( new CandidateLinkRequest("candidate-2", "employee-new"), "hr" )); assertEquals("候选人已关联其他员工主体,请先由HR核对后处理", error.getMessage()); - verify(jdbcTemplate, never()).update(anyString(), eq("candidate-2"), eq("employee-new"), eq("hr")); + verify(jdbcTemplate, never()).update(anyString(), eq("000000"), eq("candidate-2"), eq("employee-new"), eq("hr")); } @Test @@ -61,8 +61,8 @@ class AihrInterviewServiceTest { 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"))) + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("employee-4"))).thenReturn(1); + when(jdbcTemplate.query(anyString(), org.mockito.ArgumentMatchers.>any(), eq("000000"), eq("employee-4"))) .thenReturn(java.util.List.of(existing)); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate( @@ -70,7 +70,7 @@ class AihrInterviewServiceTest { )); assertEquals("员工主体已关联其他候选人,请先由HR核对后处理", error.getMessage()); - verify(jdbcTemplate, never()).update(anyString(), eq("candidate-4"), eq("employee-4"), eq("hr")); + verify(jdbcTemplate, never()).update(anyString(), eq("000000"), eq("candidate-4"), eq("employee-4"), eq("hr")); } @Test @@ -80,13 +80,13 @@ class AihrInterviewServiceTest { AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate)); CandidateLinkResponse linked = new CandidateLinkResponse("candidate-3", "employee-3", "已关联", "07-14 06:10"); doReturn(linked).when(service).candidateLink("candidate-3"); - when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-3"))).thenReturn(1); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq("employee-3"))).thenReturn(1); CandidateLinkResponse result = service.linkCandidate( new CandidateLinkRequest("candidate-3", "employee-3"), "hr" ); assertSame(linked, result); - verify(jdbcTemplate).update(anyString(), eq("candidate-3"), eq("employee-3"), eq("hr")); + verify(jdbcTemplate).update(anyString(), eq("000000"), eq("candidate-3"), eq("employee-3"), eq("hr")); } } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 5aa824da..5c350156 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 f0d434af..88985c5b 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 标题和员工首页真实渲染。