线上现象:对练页录音转写后提交回应,报「录音不属于当前账号,请重新录音后提交」。 根因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>
285 lines
7.8 KiB
TypeScript
285 lines
7.8 KiB
TypeScript
import type {
|
|
PracticeFinishResponse,
|
|
PracticePrepCard,
|
|
PracticeRecord,
|
|
PracticeMistake,
|
|
PracticeTeamSnapshot,
|
|
PracticeStartResponse,
|
|
PracticeTurnResponse,
|
|
CompetencyProfile,
|
|
ReviewAdviceRequest,
|
|
ReviewDetail,
|
|
PracticeAssignment,
|
|
OnboardTask,
|
|
QualificationSummary,
|
|
PracticeAssignmentRequest,
|
|
PracticeAssignmentBatchRequest,
|
|
PracticeAssignmentBatchResponse,
|
|
DailyDrillAnswerResponse,
|
|
PracticeAlert,
|
|
PromotionEvidence,
|
|
PracticeScenarioOption,
|
|
PracticeDifficulty,
|
|
PracticeQuestionFeedback
|
|
} from '@/types/api';
|
|
import { apiRequest } from './api';
|
|
import { getAuth, getSelectedPosition } from './auth';
|
|
|
|
const defaultScenarioId = 'complaint-water';
|
|
|
|
const currentExtPartyId = () => {
|
|
const phone = getAuth().phone.trim();
|
|
if (!phone) {
|
|
throw new Error('请先登录后再查看训练数据');
|
|
}
|
|
return phone;
|
|
};
|
|
|
|
export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
|
|
apiRequest<PracticePrepCard>({
|
|
url: `/api/train/practice/scenarios/${encodeURIComponent(scenarioId)}/prep-card`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeScenarios = (position = getSelectedPosition()) =>
|
|
apiRequest<PracticeScenarioOption[]>({
|
|
url: `/api/train/practice/scenarios?position=${encodeURIComponent(position || '生活顾问')}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const startPractice = (scenarioId = defaultScenarioId, assignmentId?: number) =>
|
|
apiRequest<PracticeStartResponse>({
|
|
url: '/api/train/practice/start',
|
|
method: 'POST',
|
|
data: {
|
|
extPartyId: currentExtPartyId(),
|
|
scenarioId,
|
|
position: getSelectedPosition(),
|
|
mode: 'mobile',
|
|
assignmentId
|
|
},
|
|
timeout: 30000
|
|
});
|
|
|
|
export const submitPracticeTurn = (params: {
|
|
sessionId: string;
|
|
roundIndex: number;
|
|
traineeText: string;
|
|
traineeAudioUrl?: string;
|
|
traineeAudioOssId?: number | string;
|
|
regenerate?: boolean;
|
|
style?: string;
|
|
}) =>
|
|
apiRequest<PracticeTurnResponse>({
|
|
url: '/api/train/practice/turn',
|
|
method: 'POST',
|
|
data: params,
|
|
timeout: 45000
|
|
});
|
|
|
|
export const finishPractice = (sessionId: string, assignmentId?: number) =>
|
|
apiRequest<PracticeFinishResponse>({
|
|
url: '/api/train/practice/finish',
|
|
method: 'POST',
|
|
data: { sessionId, assignmentId },
|
|
timeout: 45000
|
|
});
|
|
|
|
export const submitPracticeSatisfaction = (sessionId: string, score: number, comment = '') =>
|
|
apiRequest<{ sessionId: string; score: number; comment: string; submittedAt: string }>({
|
|
url: '/api/aihr/mobile/practice/satisfaction',
|
|
method: 'POST',
|
|
data: { sessionId, score, comment },
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeHistory = () => {
|
|
const extPartyId = encodeURIComponent(currentExtPartyId());
|
|
return apiRequest<PracticeRecord[]>({
|
|
url: `/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
};
|
|
|
|
export const getPracticeHistoryDetail = (id: number) =>
|
|
apiRequest<ReviewDetail>({
|
|
url: `/api/aihr/mobile/practice/history/${id}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeMistakes = () => {
|
|
const extPartyId = encodeURIComponent(currentExtPartyId());
|
|
return apiRequest<PracticeMistake[]>({
|
|
url: `/api/aihr/mobile/practice/mistakes?extPartyId=${extPartyId}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
};
|
|
|
|
export const getCompetencyProfile = () => {
|
|
const extPartyId = encodeURIComponent(currentExtPartyId());
|
|
return apiRequest<CompetencyProfile>({
|
|
url: `/api/aihr/mobile/profile?extPartyId=${extPartyId}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
};
|
|
|
|
export const getPromotionEvidence = () => {
|
|
const extPartyId = encodeURIComponent(currentExtPartyId());
|
|
return apiRequest<PromotionEvidence>({
|
|
url: `/api/aihr/mobile/profile/promotion-evidence?extPartyId=${extPartyId}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
};
|
|
|
|
export const getPracticeReviews = () =>
|
|
apiRequest<PracticeRecord[]>({
|
|
url: '/api/aihr/mobile/practice/reviews',
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeTeamSnapshot = () =>
|
|
apiRequest<PracticeTeamSnapshot>({
|
|
url: '/api/aihr/mobile/practice/team',
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeReviewDetail = (id: number) =>
|
|
apiRequest<ReviewDetail>({
|
|
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const markPracticeReviewed = (id: number, data: ReviewAdviceRequest) =>
|
|
apiRequest<void>({
|
|
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
|
|
method: 'POST',
|
|
data,
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeAssignments = (ownOnly = false) => {
|
|
const extPartyId = ownOnly ? encodeURIComponent(currentExtPartyId()) : '';
|
|
return apiRequest<PracticeAssignment[]>({
|
|
url: `/api/aihr/mobile/practice/assignments${ownOnly ? `?extPartyId=${extPartyId}` : ''}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
};
|
|
|
|
export const getOnboardTasks = () =>
|
|
apiRequest<OnboardTask[]>({
|
|
url: '/api/aihr/mobile/onboard/tasks',
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const completeOnboardTask = (id: number, evidenceRef?: string) =>
|
|
apiRequest<void>({
|
|
url: `/api/aihr/mobile/onboard/tasks/${id}/complete`,
|
|
method: 'POST',
|
|
data: evidenceRef ? { evidenceRef } : {},
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getQualification = () =>
|
|
apiRequest<QualificationSummary>({
|
|
url: '/api/aihr/mobile/qualification',
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
|
apiRequest<PracticeAssignment>({
|
|
url: '/api/aihr/mobile/practice/assignments',
|
|
method: 'POST',
|
|
data: {
|
|
source: 'manual',
|
|
scenarioId: 'fee-parking',
|
|
...data
|
|
},
|
|
timeout: 15000
|
|
});
|
|
|
|
export const createPracticeAssignmentsBatch = (data: PracticeAssignmentBatchRequest) =>
|
|
apiRequest<PracticeAssignmentBatchResponse>({
|
|
url: '/api/aihr/mobile/practice/assignments/batch',
|
|
method: 'POST',
|
|
data,
|
|
timeout: 30000
|
|
});
|
|
|
|
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number | string) =>
|
|
apiRequest<DailyDrillAnswerResponse>({
|
|
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
|
method: 'POST',
|
|
data: { answer, audioUrl, audioOssId },
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeAlerts = () =>
|
|
apiRequest<PracticeAlert[]>({
|
|
url: '/api/aihr/mobile/practice/alerts',
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
|
|
export const submitPracticeQuestionFeedback = (
|
|
assignmentId: number,
|
|
verdict: PracticeQuestionFeedback['verdict'],
|
|
comment = ''
|
|
) =>
|
|
apiRequest<PracticeQuestionFeedback>({
|
|
url: `/api/aihr/mobile/practice/assignments/${assignmentId}/feedback`,
|
|
method: 'POST',
|
|
data: { verdict, comment },
|
|
timeout: 15000
|
|
});
|
|
|
|
export const getPracticeDifficulties = async (windowDays = 30, position = ''): Promise<PracticeDifficulty[]> => {
|
|
const rows = await apiRequest<Array<{
|
|
scenarioId: string;
|
|
scenarioName: string;
|
|
position?: string;
|
|
attempts: number;
|
|
mistakeCount: number;
|
|
affectedPeople: number;
|
|
averageScore: number;
|
|
difficultyScore: number;
|
|
latestTime: string;
|
|
}>>({
|
|
url: `/api/aihr/mobile/practice/difficulties?windowDays=${windowDays}${position ? `&position=${encodeURIComponent(position)}` : ''}`,
|
|
method: 'GET',
|
|
timeout: 15000
|
|
});
|
|
return rows.map((item) => ({
|
|
...item,
|
|
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
|
|
count: item.mistakeCount
|
|
}));
|
|
};
|
|
|
|
export const assignPracticeDifficulty = (data: {
|
|
scenarioId: string;
|
|
cause: string;
|
|
dueDate: string;
|
|
requestId: string;
|
|
windowDays: number;
|
|
position?: string;
|
|
}) =>
|
|
apiRequest<PracticeAssignmentBatchResponse>({
|
|
url: '/api/aihr/mobile/practice/difficulties/assign',
|
|
method: 'POST',
|
|
data,
|
|
timeout: 30000
|
|
});
|