fix: stop snowflake ossId precision loss and daily-drill audio identity mismatch
线上现象:对练页录音转写后提交回应,报「录音不属于当前账号,请重新录音后提交」。 根因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 <noreply@anthropic.com>
This commit is contained in:
+30
-1
@@ -1650,6 +1650,35 @@ public class AihrPracticeSeedService {
|
|||||||
throw new ServiceException("录音不属于当前账号,请重新录音后提交");
|
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)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public boolean markReviewed(Long id) {
|
public boolean markReviewed(Long id) {
|
||||||
return markReviewed(id, null);
|
return markReviewed(id, null);
|
||||||
@@ -2800,7 +2829,7 @@ public class AihrPracticeSeedService {
|
|||||||
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
|
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
|
||||||
String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl());
|
String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl());
|
||||||
Long audioOssId = request == null ? null : request.audioOssId();
|
Long audioOssId = request == null ? null : request.audioOssId();
|
||||||
validateAudioOwnership(audioOssId, extPartyId, ownerUserId);
|
validateAudioOwnershipByUser(audioOssId, ownerUserId);
|
||||||
DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer());
|
DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer());
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
int updated = jdbcTemplate.update("""
|
int updated = jdbcTemplate.update("""
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ import { resetPageScroll } from '@/services/navigation';
|
|||||||
import {
|
import {
|
||||||
chooseSpeechAudio,
|
chooseSpeechAudio,
|
||||||
createSpeechPlaybackController,
|
createSpeechPlaybackController,
|
||||||
|
normalizeOssId,
|
||||||
transcribeSpeechBlob,
|
transcribeSpeechBlob,
|
||||||
transcribeSpeechFile
|
transcribeSpeechFile
|
||||||
} from '@/services/speech';
|
} from '@/services/speech';
|
||||||
@@ -164,7 +165,7 @@ const currentPath = '/pages/candidate/interview/index';
|
|||||||
const records = ref<InterviewRecord[]>([]);
|
const records = ref<InterviewRecord[]>([]);
|
||||||
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
||||||
const interviewAnswers = ref<Record<string, string>>({});
|
const interviewAnswers = ref<Record<string, string>>({});
|
||||||
const interviewAudioOssIds = ref<Record<string, number | undefined>>({});
|
const interviewAudioOssIds = ref<Record<string, string | undefined>>({});
|
||||||
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
||||||
const interviewSessionId = ref('');
|
const interviewSessionId = ref('');
|
||||||
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
|
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
|
||||||
@@ -325,10 +326,10 @@ const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => {
|
|||||||
const text = (result.text || '').trim();
|
const text = (result.text || '').trim();
|
||||||
if (!text) throw new Error('语音没有识别到文字');
|
if (!text) throw new Error('语音没有识别到文字');
|
||||||
interviewAnswers.value = { ...interviewAnswers.value, [questionId]: text };
|
interviewAnswers.value = { ...interviewAnswers.value, [questionId]: text };
|
||||||
const ossId = Number(result.ossId);
|
const ossId = normalizeOssId(result.ossId);
|
||||||
interviewAudioOssIds.value = {
|
interviewAudioOssIds.value = {
|
||||||
...interviewAudioOssIds.value,
|
...interviewAudioOssIds.value,
|
||||||
[questionId]: Number.isFinite(ossId) ? ossId : undefined
|
[questionId]: ossId
|
||||||
};
|
};
|
||||||
message.value = '语音已转成文字,请确认后提交';
|
message.value = '语音已转成文字,请确认后提交';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
|
|||||||
import { searchKnowledge } from '@/services/knowledge';
|
import { searchKnowledge } from '@/services/knowledge';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
import { ensureEmployeePosition } from '@/services/position';
|
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';
|
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||||
|
|
||||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||||
@@ -370,7 +370,7 @@ const assignments = ref<PracticeAssignment[]>([]);
|
|||||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||||
const drillAnswers = ref<Record<number, string>>({});
|
const drillAnswers = ref<Record<number, string>>({});
|
||||||
const dailyAudioUrls = ref<Record<number, string>>({});
|
const dailyAudioUrls = ref<Record<number, string>>({});
|
||||||
const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
const dailyAudioOssIds = ref<Record<number, string | undefined>>({});
|
||||||
const assignmentLoading = ref(false);
|
const assignmentLoading = ref(false);
|
||||||
const answeringId = ref<number | null>(null);
|
const answeringId = ref<number | null>(null);
|
||||||
const activeAssignmentId = ref<number | undefined>();
|
const activeAssignmentId = ref<number | undefined>();
|
||||||
@@ -384,7 +384,7 @@ const recordingStarting = ref(false);
|
|||||||
const recordingAssignmentId = ref<number | null>(null);
|
const recordingAssignmentId = ref<number | null>(null);
|
||||||
const transcribing = ref(false);
|
const transcribing = ref(false);
|
||||||
const draftAudioUrl = ref('');
|
const draftAudioUrl = ref('');
|
||||||
const draftAudioOssId = ref<number | undefined>();
|
const draftAudioOssId = ref<string | undefined>();
|
||||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||||
const mediaStream = ref<MediaStream | null>(null);
|
const mediaStream = ref<MediaStream | null>(null);
|
||||||
@@ -599,8 +599,7 @@ const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: numb
|
|||||||
const current = draft.value.trim();
|
const current = draft.value.trim();
|
||||||
draft.value = current ? `${current}\n${text}` : text;
|
draft.value = current ? `${current}\n${text}` : text;
|
||||||
draftAudioUrl.value = result.audioUrl || '';
|
draftAudioUrl.value = result.audioUrl || '';
|
||||||
const ossId = Number(result.ossId);
|
draftAudioOssId.value = normalizeOssId(result.ossId);
|
||||||
draftAudioOssId.value = Number.isFinite(ossId) ? ossId : undefined;
|
|
||||||
message.value = '语音已转成文字,请确认后提交';
|
message.value = '语音已转成文字,请确认后提交';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -616,10 +615,10 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
|||||||
...dailyAudioUrls.value,
|
...dailyAudioUrls.value,
|
||||||
[assignmentId]: result.audioUrl || ''
|
[assignmentId]: result.audioUrl || ''
|
||||||
};
|
};
|
||||||
const ossId = Number(result.ossId);
|
const ossId = normalizeOssId(result.ossId);
|
||||||
dailyAudioOssIds.value = {
|
dailyAudioOssIds.value = {
|
||||||
...dailyAudioOssIds.value,
|
...dailyAudioOssIds.value,
|
||||||
[assignmentId]: Number.isFinite(ossId) ? ossId : undefined
|
[assignmentId]: ossId
|
||||||
};
|
};
|
||||||
message.value = '每日一练语音已转成文字,请确认后提交';
|
message.value = '每日一练语音已转成文字,请确认后提交';
|
||||||
};
|
};
|
||||||
@@ -784,7 +783,7 @@ const loadAssignments = async (request: RequestScopeTicket = requests.begin('ass
|
|||||||
dailyAudioOssIds.value = Object.fromEntries(
|
dailyAudioOssIds.value = Object.fromEntries(
|
||||||
nextAssignments
|
nextAssignments
|
||||||
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||||
.map((item) => [item.id, Number(item.audioOssId)])
|
.map((item) => [item.id, normalizeOssId(item.audioOssId)])
|
||||||
);
|
);
|
||||||
consumePendingAssignment();
|
consumePendingAssignment();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const saveInterviewAnswer = (
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
questionId: string,
|
questionId: string,
|
||||||
answerText: string,
|
answerText: string,
|
||||||
answerAudioOssId?: number
|
answerAudioOssId?: number | string
|
||||||
) =>
|
) =>
|
||||||
apiRequest<void>({
|
apiRequest<void>({
|
||||||
url: '/api/recruit/interview/answer',
|
url: '/api/recruit/interview/answer',
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const submitPracticeTurn = (params: {
|
|||||||
roundIndex: number;
|
roundIndex: number;
|
||||||
traineeText: string;
|
traineeText: string;
|
||||||
traineeAudioUrl?: string;
|
traineeAudioUrl?: string;
|
||||||
traineeAudioOssId?: number;
|
traineeAudioOssId?: number | string;
|
||||||
regenerate?: boolean;
|
regenerate?: boolean;
|
||||||
style?: string;
|
style?: string;
|
||||||
}) =>
|
}) =>
|
||||||
@@ -218,7 +218,7 @@ export const createPracticeAssignmentsBatch = (data: PracticeAssignmentBatchRequ
|
|||||||
timeout: 30000
|
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<DailyDrillAnswerResponse>({
|
apiRequest<DailyDrillAnswerResponse>({
|
||||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
|||||||
|
|
||||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
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 = () =>
|
export const chooseSpeechAudio = () =>
|
||||||
new Promise<SpeechSelectedFile>((resolve, reject) => {
|
new Promise<SpeechSelectedFile>((resolve, reject) => {
|
||||||
uni.chooseFile({
|
uni.chooseFile({
|
||||||
|
|||||||
Reference in New Issue
Block a user