feat(aihr): link candidates to employee principals

This commit is contained in:
2026-07-14 06:22:36 +08:00
parent 0287698214
commit ef188d3283
10 changed files with 330 additions and 1 deletions
@@ -1,15 +1,20 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
@@ -32,6 +37,8 @@ import java.util.List;
@SaCheckLogin
public class AihrInterviewController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrInterviewService interviewService;
@PostMapping("/start")
@@ -56,6 +63,20 @@ public class AihrInterviewController {
return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/candidate-links")
public R<CandidateLinkResponse> candidateLink(@RequestParam String candidateId) {
return R.ok(interviewService.candidateLink(candidateId));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/candidate-links")
public R<CandidateLinkResponse> linkCandidate(@RequestBody CandidateLinkRequest request) {
LoginUser loginUser = LoginHelper.getLoginUser();
String operator = loginUser == null ? "unknown" : loginUser.getUsername();
return R.ok(interviewService.linkCandidate(request, operator));
}
private static StartRequest bindAppCandidate(StartRequest request) {
String appUsername = currentAppUsername();
if (appUsername.isBlank()) {
@@ -26,6 +26,12 @@ public final class AihrInterviewDto {
public record FinishRequest(String sessionId, Map<String, String> answers) {
}
public record CandidateLinkRequest(String candidateId, String extPartyId) {
}
public record CandidateLinkResponse(String candidateId, String extPartyId, String status, String linkedTime) {
}
public record FinishResponse(
Integer totalScore,
String suggestion,
@@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
@@ -44,6 +46,7 @@ public class AihrInterviewService {
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
private final Map<String, CandidateProfile> candidates = buildCandidates();
private volatile boolean tableReady;
private volatile boolean candidateLinkTableReady;
public StartResponse start(StartRequest request) {
CandidateProfile candidate = resolveCandidate(
@@ -129,6 +132,59 @@ public class AihrInterviewService {
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
}
public CandidateLinkResponse candidateLink(String candidateId) {
ensureCandidateLinkTable();
String normalizedCandidateId = clean(candidateId);
if (normalizedCandidateId.isBlank()) {
return null;
}
return jdbcTemplate.query("""
SELECT candidate_id, ext_party_id, status, linked_time
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);
}
public CandidateLinkResponse linkCandidate(CandidateLinkRequest request, String operator) {
ensureCandidateLinkTable();
String candidateId = clean(request == null ? null : request.candidateId());
String extPartyId = clean(request == null ? null : request.extPartyId());
if (candidateId.isBlank() || candidateId.length() > 80) {
throw new IllegalArgumentException("候选人ID不能为空且不能超过80个字符");
}
if (extPartyId.isBlank() || extPartyId.length() > 100) {
throw new IllegalArgumentException("员工主体ID不能为空且不能超过100个字符");
}
Integer activeEmployee = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM aihr_org_snapshot
WHERE tenant_id = '000000'
AND employment_status = 'active'
AND ext_party_id = ?
""", Integer.class, extPartyId);
if (activeEmployee == null || activeEmployee == 0) {
throw new IllegalArgumentException("员工主体不存在或已离职,请先同步在职组织快照");
}
CandidateLinkResponse current = candidateLink(candidateId);
if (current != null && !extPartyId.equals(current.extPartyId())) {
throw new IllegalArgumentException("候选人已关联其他员工主体,请先由HR核对后处理");
}
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())
ON DUPLICATE KEY UPDATE
status = '已关联', linked_by = VALUES(linked_by), linked_time = now(), update_time = now()
""", candidateId, extPartyId, firstNonBlank(operator, "unknown"));
return candidateLink(candidateId);
}
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
var finishedTime = rs.getTimestamp("finished_time");
return new RecordResponse(
@@ -226,6 +282,34 @@ public class AihrInterviewService {
}
}
private void ensureCandidateLinkTable() {
if (candidateLinkTableReady) {
return;
}
synchronized (this) {
if (candidateLinkTableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';
""");
candidateLinkTableReady = true;
}
}
private List<QuestionRecordResponse> questionRecords(InterviewSession session) {
return session.questions().stream().map(question -> {
String answer = clean(session.answers().get(question.questionId()));
@@ -0,0 +1,72 @@
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.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AihrInterviewServiceTest {
@Test
@Tag("dev")
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);
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"));
}
@Test
@Tag("dev")
void linkCandidateRejectsChangingAnExistingEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
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);
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"));
}
@Test
@Tag("dev")
void linkCandidateIsIdempotentForTheSameEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
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);
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"));
}
}
@@ -25,3 +25,18 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`),
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';