线上现象:对练页录音转写后提交回应,报「录音不属于当前账号,请重新录音后提交」。 根因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>
120 lines
3.5 KiB
TypeScript
120 lines
3.5 KiB
TypeScript
import type {
|
|
CandidateMaterial,
|
|
CandidateSelectedFile,
|
|
InterviewFinishResponse,
|
|
InterviewRecord,
|
|
InterviewStartResponse
|
|
} from '@/types/api';
|
|
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
|
import { getAuth } from './auth';
|
|
|
|
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
|
|
|
|
export const getCandidateId = () => {
|
|
const phone = getAuth().phone.trim();
|
|
if (!phone) {
|
|
throw new Error('请先登录后再查看候选人进度');
|
|
}
|
|
return phone;
|
|
};
|
|
|
|
export const getCandidateName = () => {
|
|
const phone = getAuth().phone.trim();
|
|
return phone ? `候选人${phone.slice(-4)}` : '候选人';
|
|
};
|
|
|
|
export const getCandidateMaterials = () =>
|
|
apiRequest<CandidateMaterial[]>({
|
|
url: `/api/aihr/mobile/candidate/materials?candidateId=${encodeURIComponent(getCandidateId())}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getCandidateInterviewRecords = () =>
|
|
apiRequest<InterviewRecord[]>({
|
|
url: `/api/recruit/interview/records?limit=5&candidateId=${encodeURIComponent(getCandidateId())}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const chooseCandidateFile = () =>
|
|
new Promise<CandidateSelectedFile>((resolve, reject) => {
|
|
uni.chooseFile({
|
|
count: 1,
|
|
type: 'all',
|
|
extension: materialExtensions,
|
|
success: (result) => {
|
|
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
|
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
|
|
const path = file.path || result.tempFilePaths?.[0] || '';
|
|
if (!path) {
|
|
reject(new Error('未获取到文件路径'));
|
|
return;
|
|
}
|
|
resolve({
|
|
path,
|
|
name: file.name || path.split('/').pop() || 'candidate-material',
|
|
size: Number(file.size || 0)
|
|
});
|
|
},
|
|
fail: () => reject(new Error('未选择文件'))
|
|
});
|
|
});
|
|
|
|
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
|
|
new Promise<CandidateMaterial>((resolve, reject) => {
|
|
uni.uploadFile({
|
|
url: apiUrl('/api/aihr/mobile/candidate/materials'),
|
|
filePath: file.path,
|
|
name: 'file',
|
|
header: authHeaders(false),
|
|
formData: {
|
|
candidateId: getCandidateId(),
|
|
candidateName: getCandidateName(),
|
|
materialType
|
|
},
|
|
success: (response) => {
|
|
try {
|
|
resolve(readTextPayload<CandidateMaterial>(response.statusCode, response.data));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
},
|
|
fail: () => reject(new Error('资料上传失败'))
|
|
});
|
|
});
|
|
|
|
export const startCandidateInterview = () =>
|
|
apiRequest<InterviewStartResponse>({
|
|
url: '/api/recruit/interview/start',
|
|
method: 'POST',
|
|
data: {
|
|
candidateId: getCandidateId(),
|
|
candidateName: getCandidateName(),
|
|
positionCode: 'customer-service',
|
|
mode: 'mobile'
|
|
},
|
|
timeout: 30000
|
|
});
|
|
|
|
export const saveInterviewAnswer = (
|
|
sessionId: string,
|
|
questionId: string,
|
|
answerText: string,
|
|
answerAudioOssId?: number | string
|
|
) =>
|
|
apiRequest<void>({
|
|
url: '/api/recruit/interview/answer',
|
|
method: 'POST',
|
|
data: { sessionId, questionId, answerText, answerAudioOssId },
|
|
timeout: 30000
|
|
});
|
|
|
|
export const finishCandidateInterview = (sessionId: string, answers: Record<string, string>) =>
|
|
apiRequest<InterviewFinishResponse>({
|
|
url: '/api/recruit/interview/finish',
|
|
method: 'POST',
|
|
data: { sessionId, answers },
|
|
timeout: 45000
|
|
});
|