feat(aihr): persist interview human review

This commit is contained in:
2026-07-14 09:44:03 +08:00
parent ccb164a45a
commit e55abec630
10 changed files with 271 additions and 14 deletions
@@ -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) {
@@ -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);
}
}
}
@@ -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;
@@ -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摘要',