feat(aihr): add candidate voice interview answers

This commit is contained in:
2026-07-15 02:45:00 +08:00
parent 40574c204d
commit 2f21895597
10 changed files with 245 additions and 12 deletions
@@ -17,7 +17,10 @@ public final class AihrInterviewDto {
public record QuestionResponse(String questionId, Integer seq, String questionText, String point, String scenario, String sampleAnswer) {
}
public record AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl) {
public record AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl, Long answerAudioOssId) {
public AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl) {
this(sessionId, questionId, answerText, answerAudioUrl, null);
}
}
public record AnswerResponse(Integer score, Map<String, Integer> dimensions, String aiComment) {
@@ -50,7 +53,10 @@ public final class AihrInterviewDto {
public record DimensionResponse(String label, Object value, String note) {
}
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating, Long audioOssId) {
public QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
this(seq, question, answer, rating, null);
}
}
public record RecordResponse(
@@ -19,6 +19,7 @@ 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.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
@@ -59,7 +60,7 @@ public class AihrInterviewService {
);
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
String sessionId = "iv-" + UUID.randomUUID();
sessions.put(sessionId, new InterviewSession(tenantId(), candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source()));
sessions.put(sessionId, new InterviewSession(tenantId(), candidate, generated.questions(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), generated.source()));
return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), generated.questions(), generated.source());
}
@@ -74,9 +75,15 @@ public class AihrInterviewService {
if (!isBlank(ownerIdentity)) {
requireSessionOwner(session, ownerIdentity);
}
validateAnswerAudioOwnership(request == null ? null : request.answerAudioOssId(), ownerIdentity);
if (session != null && request != null && !isBlank(request.questionId())) {
requireKnownQuestion(session, request.questionId());
session.answers().put(request.questionId(), answerText);
if (request.answerAudioOssId() == null) {
session.answerAudioOssIds().remove(request.questionId());
} else {
session.answerAudioOssIds().put(request.questionId(), request.answerAudioOssId());
}
}
int baseScore = answerText.isBlank() ? 0 : Math.min(92, 62 + Math.min(answerText.length() / 4, 30));
return new AnswerResponse(
@@ -98,7 +105,7 @@ public class AihrInterviewService {
sessionKey,
key -> {
CandidateProfile candidate = resolveCandidate(null, null, null, key);
return new InterviewSession(tenantId(), candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
return new InterviewSession(tenantId(), candidate, candidate.questions(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), "local-rubric");
}
);
} else {
@@ -463,7 +470,8 @@ public class AihrInterviewService {
return session.questions().stream().map(question -> {
String answer = clean(session.answers().get(question.questionId()));
String rating = answer.length() >= 32 ? "优秀" : answer.length() >= 12 ? "良好" : "待提升";
return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating);
return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating,
session.answerAudioOssIds().get(question.questionId()));
}).toList();
}
@@ -641,6 +649,28 @@ public class AihrInterviewService {
}
}
private void validateAnswerAudioOwnership(Long audioOssId, String ownerIdentity) {
if (audioOssId == null) {
return;
}
if (isBlank(ownerIdentity)) {
throw new ServiceException("录音归属校验失败,请重新录音后提交");
}
try {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM aihr_practice_audio_upload
WHERE tenant_id = ? AND oss_id = ? AND owner_identity = ?
AND status IN ('staged', 'bound')
""", Integer.class, tenantId(), audioOssId, ownerIdentity.trim());
if (count == null || count == 0) {
throw new ServiceException("录音归属校验失败,请重新录音后提交");
}
} catch (DataAccessException error) {
throw new ServiceException("录音归属校验失败,请重新录音后提交");
}
}
private CandidateProfile dynamicCandidate(String candidateId, String candidateName, String positionCode) {
CandidateProfile template = candidates.get("zhang-ming");
String cleanId = clean(candidateId);
@@ -758,7 +788,7 @@ public class AihrInterviewService {
}
private record InterviewSession(String tenantId, CandidateProfile candidate, List<QuestionResponse> questions,
Map<String, String> answers, String source) {
Map<String, String> answers, Map<String, Long> answerAudioOssIds, String source) {
}
private record GeneratedQuestions(List<QuestionResponse> questions, String source) {
@@ -8,6 +8,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -334,6 +335,25 @@ class AihrInterviewServiceTest {
service.answer(new AnswerRequest(ownerSession.sessionId(), "q1", "本人回答", null), "candidate-a");
}
@Test
@Tag("dev")
void interviewAnswerRejectsAudioOwnedByAnotherIdentity() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), modelService, jdbcTemplate);
var started = service.start(new StartRequest("candidate-audio", "候选人", "customer-service", "mobile"));
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("000000"), eq(9002L), eq("candidate-audio")))
.thenReturn(0);
ServiceException error = assertThrows(ServiceException.class, () -> service.answer(
new AnswerRequest(started.sessionId(), "q1", "语音回答", null, 9002L), "candidate-audio"
));
assertEquals("录音归属校验失败,请重新录音后提交", error.getMessage());
verify(jdbcTemplate).queryForObject(anyString(), eq(Integer.class), eq("000000"), eq(9002L), eq("candidate-audio"));
}
@Test
@Tag("dev")
void interviewAnswerRejectsUnknownSessionForSystemFlow() {