From a4bbfc22224d3b4e7d6b7213af53107875260459 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 18 Jul 2026 03:57:47 +0800 Subject: [PATCH] fix: stop snowflake ossId precision loss and daily-drill audio identity mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上现象:对练页录音转写后提交回应,报「录音不属于当前账号,请重新录音后提交」。 根因A(前端,截图直接原因):OSS 编号是雪花 ID(如 2078204068457525249), 超出 JS Number 安全整数范围,后端按 RuoYi 规范序列化为字符串,但前端 4 处 用 Number() 强转(对练 ASR、每日题 ASR、每日题恢复列表、候选人面试 ASR), 精度丢失成 …5200,后端按错误 oss_id 查归属 COUNT=0 → 误报。 生产铁证:同一暂存行,正确 id 查得 1 行,JS 强转后的 id 查得 0 行; 用损坏 id 提交 turn 复现原报错,用字符串正确 id 提交成功。 修复A:speech.ts 新增 normalizeOssId(纯字符串校验透传,禁止 Number 强转), 4 处调用点改为字符串保存,practice/candidate 服务签名与状态类型放宽为 number|string(types/api.ts 本就是 number|string,Jackson 字符串转 Long 无损)。 根因B(后端,潜伏):answerDailyDrill 用组织 extPartyId(如 1003)做录音 归属身份校验,而暂存写入的是登录手机号——每日题语音提交必失败。 修复B:新增 validateAudioOwnershipByUser,按服务端登录 owner_user_id (与暂存同源、不可伪造)校验,对练路径(身份一致)保持原校验不变。 发布与验证: - H5 已发布(备份 /opt/wygj/backups/h5-before-20260718035416) - 后端 jar 已发布重启(备份 /opt/wygj/app/ruoyi-admin.jar.bak-20260718035552), 15s 恢复,重启后对练 turn+字符串 ossId 生产复核通过 - 每日题语音路径为逻辑级+数据级验证(该账号今日无待训练每日题, 业务级复验待下一次每日题窗口) Co-Authored-By: Claude Fable 5 --- .../aihr/service/AihrPracticeSeedService.java | 31 ++++++++++++++++++- .../src/pages/candidate/interview/index.vue | 7 +++-- mobile-uni/src/pages/user/practice/index.vue | 15 +++++---- mobile-uni/src/services/candidate.ts | 2 +- mobile-uni/src/services/practice.ts | 4 +-- mobile-uni/src/services/speech.ts | 6 ++++ 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 823919a3..c4458598 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -1650,6 +1650,35 @@ public class AihrPracticeSeedService { throw new ServiceException("录音不属于当前账号,请重新录音后提交"); } + /** + * Ownership check keyed by the login user id only. Staging always records the + * app login (owner_user_id + phone), while daily-drill submission carries the + * org extPartyId — comparing that against the staged phone can never match, + * so daily answers validate by the unforgeable server-side user id instead. + */ + private void validateAudioOwnershipByUser(Long ossId, Long ownerUserId) { + if (ossId == null) { + return; + } + if (ownerUserId == null || ownerUserId <= 0) { + throw new ServiceException("录音归属校验失败,请重新录音后提交"); + } + try { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM aihr_practice_audio_upload + WHERE tenant_id = ? AND oss_id = ? AND owner_user_id = ? + AND status IN ('staged', 'bound') + """, Integer.class, tenantId(), ossId, ownerUserId); + if (count != null && count > 0) { + return; + } + } catch (DataAccessException ignored) { + // Keep the ownership failure generic; do not leak database details. + } + throw new ServiceException("录音不属于当前账号,请重新录音后提交"); + } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id) { return markReviewed(id, null); @@ -2800,7 +2829,7 @@ public class AihrPracticeSeedService { String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl()); Long audioOssId = request == null ? null : request.audioOssId(); - validateAudioOwnership(audioOssId, extPartyId, ownerUserId); + validateAudioOwnershipByUser(audioOssId, ownerUserId); DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer()); LocalDateTime now = LocalDateTime.now(); int updated = jdbcTemplate.update(""" diff --git a/mobile-uni/src/pages/candidate/interview/index.vue b/mobile-uni/src/pages/candidate/interview/index.vue index c07df3f1..5e4da634 100644 --- a/mobile-uni/src/pages/candidate/interview/index.vue +++ b/mobile-uni/src/pages/candidate/interview/index.vue @@ -156,6 +156,7 @@ import { resetPageScroll } from '@/services/navigation'; import { chooseSpeechAudio, createSpeechPlaybackController, + normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; @@ -164,7 +165,7 @@ const currentPath = '/pages/candidate/interview/index'; const records = ref([]); const interviewQuestions = ref([]); const interviewAnswers = ref>({}); -const interviewAudioOssIds = ref>({}); +const interviewAudioOssIds = ref>({}); const interviewResult = ref(null); const interviewSessionId = ref(''); const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle'); @@ -325,10 +326,10 @@ const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => { const text = (result.text || '').trim(); if (!text) throw new Error('语音没有识别到文字'); interviewAnswers.value = { ...interviewAnswers.value, [questionId]: text }; - const ossId = Number(result.ossId); + const ossId = normalizeOssId(result.ossId); interviewAudioOssIds.value = { ...interviewAudioOssIds.value, - [questionId]: Number.isFinite(ossId) ? ossId : undefined + [questionId]: ossId }; message.value = '语音已转成文字,请确认后提交'; }; diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 9773f8d1..3b9ba52c 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -323,7 +323,7 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic import { searchKnowledge } from '@/services/knowledge'; import { resetPageScroll } from '@/services/navigation'; import { ensureEmployeePosition } from '@/services/position'; -import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; +import { chooseSpeechAudio, createSpeechPlaybackController, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope'; type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished'; @@ -370,7 +370,7 @@ const assignments = ref([]); const practiceHistory = ref([]); const drillAnswers = ref>({}); const dailyAudioUrls = ref>({}); -const dailyAudioOssIds = ref>({}); +const dailyAudioOssIds = ref>({}); const assignmentLoading = ref(false); const answeringId = ref(null); const activeAssignmentId = ref(); @@ -384,7 +384,7 @@ const recordingStarting = ref(false); const recordingAssignmentId = ref(null); const transcribing = ref(false); const draftAudioUrl = ref(''); -const draftAudioOssId = ref(); +const draftAudioOssId = ref(); const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' }); const mediaRecorder = ref(null); const mediaStream = ref(null); @@ -599,8 +599,7 @@ const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: numb const current = draft.value.trim(); draft.value = current ? `${current}\n${text}` : text; draftAudioUrl.value = result.audioUrl || ''; - const ossId = Number(result.ossId); - draftAudioOssId.value = Number.isFinite(ossId) ? ossId : undefined; + draftAudioOssId.value = normalizeOssId(result.ossId); message.value = '语音已转成文字,请确认后提交'; }; @@ -616,10 +615,10 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi ...dailyAudioUrls.value, [assignmentId]: result.audioUrl || '' }; - const ossId = Number(result.ossId); + const ossId = normalizeOssId(result.ossId); dailyAudioOssIds.value = { ...dailyAudioOssIds.value, - [assignmentId]: Number.isFinite(ossId) ? ossId : undefined + [assignmentId]: ossId }; message.value = '每日一练语音已转成文字,请确认后提交'; }; @@ -784,7 +783,7 @@ const loadAssignments = async (request: RequestScopeTicket = requests.begin('ass dailyAudioOssIds.value = Object.fromEntries( nextAssignments .filter((item) => item.audioOssId !== undefined && item.audioOssId !== null) - .map((item) => [item.id, Number(item.audioOssId)]) + .map((item) => [item.id, normalizeOssId(item.audioOssId)]) ); consumePendingAssignment(); } catch (error) { diff --git a/mobile-uni/src/services/candidate.ts b/mobile-uni/src/services/candidate.ts index 68505716..f942b358 100644 --- a/mobile-uni/src/services/candidate.ts +++ b/mobile-uni/src/services/candidate.ts @@ -101,7 +101,7 @@ export const saveInterviewAnswer = ( sessionId: string, questionId: string, answerText: string, - answerAudioOssId?: number + answerAudioOssId?: number | string ) => apiRequest({ url: '/api/recruit/interview/answer', diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index 0a7879f0..c0e14f1f 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -68,7 +68,7 @@ export const submitPracticeTurn = (params: { roundIndex: number; traineeText: string; traineeAudioUrl?: string; - traineeAudioOssId?: number; + traineeAudioOssId?: number | string; regenerate?: boolean; style?: string; }) => @@ -218,7 +218,7 @@ export const createPracticeAssignmentsBatch = (data: PracticeAssignmentBatchRequ timeout: 30000 }); -export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) => +export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number | string) => apiRequest({ url: `/api/aihr/mobile/practice/assignments/${id}/answer`, method: 'POST', diff --git a/mobile-uni/src/services/speech.ts b/mobile-uni/src/services/speech.ts index c98a4e96..9059cc4a 100644 --- a/mobile-uni/src/services/speech.ts +++ b/mobile-uni/src/services/speech.ts @@ -3,6 +3,12 @@ import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg']; +// OSS 编号是雪花 ID,超出 JS 安全整数范围,必须按字符串透传,禁止 Number() 强转。 +export const normalizeOssId = (value: unknown): string | undefined => { + const text = String(value ?? '').trim(); + return /^\d+$/.test(text) ? text : undefined; +}; + export const chooseSpeechAudio = () => new Promise((resolve, reject) => { uni.chooseFile({