feat(aihr): persist interview human review
This commit is contained in:
+11
@@ -12,6 +12,7 @@ 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.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.aihr.service.AihrInterviewService;
|
||||
@@ -22,6 +23,7 @@ import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -70,6 +72,15 @@ 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)
|
||||
@PostMapping("/records/{sessionId}/review")
|
||||
public R<Void> review(@PathVariable String sessionId, @RequestBody ReviewRequest request) {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
String operator = loginUser == null ? "unknown" : loginUser.getUsername();
|
||||
interviewService.reviewResult(sessionId, request, operator);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/candidate-links")
|
||||
public R<CandidateLinkResponse> candidateLink(@RequestParam String candidateId) {
|
||||
|
||||
+22
-2
@@ -26,6 +26,9 @@ public final class AihrInterviewDto {
|
||||
public record FinishRequest(String sessionId, Map<String, String> answers) {
|
||||
}
|
||||
|
||||
public record ReviewRequest(Integer reviewedScore, String reviewNote) {
|
||||
}
|
||||
|
||||
public record CandidateLinkRequest(String candidateId, String extPartyId) {
|
||||
}
|
||||
|
||||
@@ -50,7 +53,24 @@ public final class AihrInterviewDto {
|
||||
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
|
||||
}
|
||||
|
||||
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary,
|
||||
List<QuestionRecordResponse> questions) {
|
||||
public record RecordResponse(
|
||||
String sessionId,
|
||||
String time,
|
||||
String candidate,
|
||||
String position,
|
||||
Integer score,
|
||||
Integer reviewedScore,
|
||||
Integer finalScore,
|
||||
String reviewStatus,
|
||||
String reviewer,
|
||||
String reviewNote,
|
||||
String result,
|
||||
String summary,
|
||||
List<QuestionRecordResponse> questions
|
||||
) {
|
||||
public RecordResponse(String time, String candidate, String position, Integer score, String result, String summary,
|
||||
List<QuestionRecordResponse> questions) {
|
||||
this("", time, candidate, position, score, null, score, "待复核", "", "", result, summary, questions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-9
@@ -16,6 +16,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.QuestionRecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -117,7 +118,8 @@ public class AihrInterviewService {
|
||||
ensureResultTable();
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary, questions_json
|
||||
SELECT session_id, finished_time, candidate_name, position_code, total_score,
|
||||
reviewed_score, review_status, reviewer, review_note, result, ai_summary, questions_json
|
||||
FROM aihr_interview_result
|
||||
WHERE tenant_id = ?
|
||||
""");
|
||||
@@ -134,6 +136,30 @@ public class AihrInterviewService {
|
||||
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
|
||||
}
|
||||
|
||||
public void reviewResult(String sessionId, ReviewRequest request, String operator) {
|
||||
ensureResultTable();
|
||||
String normalizedSessionId = clean(sessionId);
|
||||
if (normalizedSessionId.isBlank() || normalizedSessionId.length() > 100) {
|
||||
throw new IllegalArgumentException("面试会话ID不能为空且不能超过100个字符");
|
||||
}
|
||||
Integer reviewedScore = request == null ? null : request.reviewedScore();
|
||||
if (reviewedScore == null || reviewedScore < 0 || reviewedScore > 100) {
|
||||
throw new IllegalArgumentException("人工复核分必须是0到100之间的整数");
|
||||
}
|
||||
String reviewNote = clean(request.reviewNote());
|
||||
if (reviewNote.length() > 1000) {
|
||||
throw new IllegalArgumentException("人工复核意见不能超过1000个字符");
|
||||
}
|
||||
int updated = jdbcTemplate.update("""
|
||||
UPDATE aihr_interview_result
|
||||
SET reviewed_score = ?, review_status = '已复核', reviewer = ?, review_note = ?, reviewed_time = now(), update_time = now()
|
||||
WHERE tenant_id = ? AND session_id = ?
|
||||
""", reviewedScore, firstNonBlank(operator, "unknown"), reviewNote, tenantId(), normalizedSessionId);
|
||||
if (updated == 0) {
|
||||
throw new IllegalArgumentException("面试记录不存在或不属于当前租户");
|
||||
}
|
||||
}
|
||||
|
||||
public CandidateLinkResponse candidateLink(String candidateId) {
|
||||
ensureCandidateLinkTable();
|
||||
String normalizedCandidateId = clean(candidateId);
|
||||
@@ -193,11 +219,23 @@ public class AihrInterviewService {
|
||||
|
||||
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
var finishedTime = rs.getTimestamp("finished_time");
|
||||
Integer aiScore = rs.getInt("total_score");
|
||||
if (rs.wasNull()) {
|
||||
aiScore = null;
|
||||
}
|
||||
Object reviewedScoreValue = rs.getObject("reviewed_score");
|
||||
Integer reviewedScore = reviewedScoreValue instanceof Number number ? number.intValue() : null;
|
||||
return new RecordResponse(
|
||||
rs.getString("session_id"),
|
||||
finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMAT),
|
||||
rs.getString("candidate_name"),
|
||||
rs.getString("position_code"),
|
||||
rs.getInt("total_score"),
|
||||
aiScore,
|
||||
reviewedScore,
|
||||
reviewedScore == null ? aiScore : reviewedScore,
|
||||
firstNonBlank(rs.getString("review_status"), "待复核"),
|
||||
firstNonBlank(rs.getString("reviewer"), ""),
|
||||
firstNonBlank(rs.getString("review_note"), ""),
|
||||
rs.getString("result"),
|
||||
rs.getString("ai_summary"),
|
||||
parseQuestionRecords(rs.getString("questions_json"))
|
||||
@@ -274,6 +312,11 @@ public class AihrInterviewService {
|
||||
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||
`position_code` varchar(120) DEFAULT NULL COMMENT '岗位',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`reviewed_score` int DEFAULT NULL COMMENT '人工复核分',
|
||||
`review_status` varchar(20) DEFAULT '待复核' COMMENT '人工复核状态',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '复核人',
|
||||
`review_note` varchar(1000) DEFAULT NULL COMMENT '人工复核意见',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '复核时间',
|
||||
`result` varchar(40) DEFAULT NULL COMMENT '结论',
|
||||
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议',
|
||||
`ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要',
|
||||
@@ -291,17 +334,37 @@ public class AihrInterviewService {
|
||||
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||
""");
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json");
|
||||
} catch (DataAccessException error) {
|
||||
if (!String.valueOf(error.getMessage()).contains("Duplicate column")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json");
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewed_score int DEFAULT NULL COMMENT '人工复核分' AFTER total_score");
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN review_status varchar(20) DEFAULT '待复核' COMMENT '人工复核状态' AFTER reviewed_score");
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewer varchar(100) DEFAULT NULL COMMENT '复核人' AFTER review_status");
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN review_note varchar(1000) DEFAULT NULL COMMENT '人工复核意见' AFTER reviewer");
|
||||
addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '复核时间' AFTER review_note");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(String ddl) {
|
||||
try {
|
||||
jdbcTemplate.execute(ddl);
|
||||
} catch (DataAccessException error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDuplicateColumnError(DataAccessException error) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
if (String.valueOf(current.getMessage()).contains("Duplicate column")) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ensureCandidateLinkTable() {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
|
||||
+28
@@ -4,6 +4,7 @@ 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.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -120,4 +121,31 @@ class AihrInterviewServiceTest {
|
||||
assertTrue(controllerSource.contains("StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE)"));
|
||||
assertTrue(controllerSource.contains("return R.fail(\"无权查看面试记录\")"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void reviewResultPersistsHumanScoreWithoutChangingAiScore() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate);
|
||||
when(jdbcTemplate.update(anyString(), eq(88), eq("hr-operator"), eq("人工确认现场协调能力不足"), eq("000000"), eq("iv-1")))
|
||||
.thenReturn(1);
|
||||
|
||||
service.reviewResult("iv-1", new ReviewRequest(88, "人工确认现场协调能力不足"), "hr-operator");
|
||||
|
||||
verify(jdbcTemplate).update(anyString(), eq(88), eq("hr-operator"), eq("人工确认现场协调能力不足"), eq("000000"), eq("iv-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void reviewResultRejectsOutOfRangeScore() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.reviewResult(
|
||||
"iv-1", new ReviewRequest(101, "超出范围"), "hr-operator"
|
||||
));
|
||||
|
||||
assertEquals("人工复核分必须是0到100之间的整数", error.getMessage());
|
||||
verify(jdbcTemplate, never()).update(anyString(), eq(101), eq("hr-operator"), eq("超出范围"), eq("000000"), eq("iv-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||
`position_code` varchar(120) DEFAULT NULL COMMENT '岗位',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`reviewed_score` int DEFAULT NULL COMMENT '人工复核分',
|
||||
`review_status` varchar(20) DEFAULT '待复核' COMMENT '人工复核状态',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '复核人',
|
||||
`review_note` varchar(1000) DEFAULT NULL COMMENT '人工复核意见',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '复核时间',
|
||||
`result` varchar(40) DEFAULT NULL COMMENT '结论',
|
||||
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议',
|
||||
`ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要',
|
||||
|
||||
Reference in New Issue
Block a user