From 2f21895597398c75e4ffcc20f2b9e1bd34a55966 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 02:45:00 +0800 Subject: [PATCH] feat(aihr): add candidate voice interview answers --- .../dromara/aihr/domain/AihrInterviewDto.java | 10 +- .../aihr/service/AihrInterviewService.java | 38 +++- .../service/AihrInterviewServiceTest.java | 20 ++ docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 + .../src/pages/candidate/interview/index.vue | 171 +++++++++++++++++- mobile-uni/src/services/candidate.ts | 9 +- mobile-uni/src/types/api.ts | 1 + mobile-uni/tests/candidate-view.test.mjs | 2 + scripts/demo-check.sh | 2 + 10 files changed, 245 insertions(+), 12 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java index b83a9b95..ea446214 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java @@ -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 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( diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index 365e535a..fc2529ce 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -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 questions, - Map answers, String source) { + Map answers, Map answerAudioOssIds, String source) { } private record GeneratedQuestions(List questions, String source) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java index 5f990741..4ff83c83 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java @@ -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() { diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 1698f2b6..6058a117 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -20,7 +20,7 @@ | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 | -| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | +| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | ## 后端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 5a98e0f3..d37b644a 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,7 @@ # BRD 功能审查与未完成项记录 +- 2026-07-15 BRD 4.1 候选人语音/文本作答补齐:候选人面试页新增“录音转文字”和“选择音频”,复用现有 `/api/ai/asr`、MediaRecorder 与文件选择兜底;`/answer` 接受可选 `answerAudioOssId`,服务端校验音频处于当前 APP 身份的 `staged/bound` 状态后,才把 `ossId` 写入问题快照,既保留旧文字客户端兼容,也不向客户端暴露原始 OSS URL。补充归属拒绝单测、候选人页面单测和 demo marker,待本轮构建验证后再提交。 + - 2026-07-15 BRD 4.1 候选人进度状态修正:候选人进度页此前用最近一份补充资料的审核状态作为“面试人工复核”状态,可能在面试已复核但资料未提交时误显示“人工复核中”;现改为面试状态读取 `InterviewRecord.reviewStatus`,并将补充资料审核单独展示为未提交/待审核/已通过/已驳回。仅修正移动端展示与类型契约,不改 API、数据库或审核结论;`mobile-uni` 类型检查、单元测试 `56/56` 和 H5 构建通过。 - 2026-07-15 BRD 4.2/P1 正式任务与资格只读桥接:复核确认 `aihr_onboard_task`、`aihr_qualification_gate` 已有结构但员工端没有读取入口;现新增登录 APP 用户范围内的 `GET /api/aihr/mobile/onboard/tasks` 与 `GET /api/aihr/mobile/qualification`,通过组织快照把手机号解析到正式 `ext_party_id`,不接受前端身份参数。`mobile-uni` 学习页新增正式岗前/入职任务和资格状态展示;空表明确显示“未配置”,不生成任务、不把 AI 训练分数当资格结论。该批次未写入生产数据,正式任务/资格规则仍需 HR 与组织系统提供。 diff --git a/mobile-uni/src/pages/candidate/interview/index.vue b/mobile-uni/src/pages/candidate/interview/index.vue index 66d88efa..5e84b322 100644 --- a/mobile-uni/src/pages/candidate/interview/index.vue +++ b/mobile-uni/src/pages/candidate/interview/index.vue @@ -48,8 +48,29 @@ :disabled="interviewStatus !== 'active'" placeholder="输入你的真实回答" /> + + + + 已附带语音留痕 + - @@ -100,7 +121,7 @@