fix(aihr): prevent duplicate employee principal links

This commit is contained in:
2026-07-14 06:29:03 +08:00
parent 3540b072f3
commit e332efb731
5 changed files with 43 additions and 10 deletions
@@ -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;
@@ -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.<RowMapper<CandidateLinkResponse>>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() {
@@ -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 候选人与员工主体关联';
+1 -1
View File
@@ -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,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
+1 -1
View File
@@ -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 标题和员工首页真实渲染。