From 31703af4f2cd232508f138e5daf4c77877a8bfed Mon Sep 17 00:00:00 2001 From: yuzhiran Date: Sun, 5 Jul 2026 01:57:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20mp-weixin=20=E5=BD=95=E9=9F=B3=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=20-=20=E6=9D=83=E9=99=90=E5=A3=B0=E6=98=8E+authorize+?= =?UTF-8?q?=E7=A7=BB=E9=99=A4TS=E6=B3=A8=E8=A7=A3+=E7=A9=BA=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interview.vue: 移除 `let recorder: any` TS 类型注解(小程序构建器不解析 TS,上次修改直接破坏构建从未部署) - interview.vue: 合并两个 onMounted 调用,删除凭空引入的 refreshState() 未定义函数引用 - interview.vue: startRecord 增加 uni.authorize scope.record 权限预请求,拒绝时引导 openSetting - interview.vue: recorder.start 改 wav/16kHz/128kbps(旧 aac/22050/16kbps 产生全 0 损坏文件) - manifest.json: mp-weixin.permission 增加 scope.record 录音权限声明 - tts.controller.ts: ASR 拒绝 <500B 空音频文件,避免 whisper 调 ffmpeg 解码失败刷错日志 --- backend/src/modules/tts/tts.controller.ts | 8 ++ zhiyin-app/src/manifest.json | 7 +- zhiyin-app/src/pages/interview/interview.vue | 110 +++++++++++++------ 3 files changed, 91 insertions(+), 34 deletions(-) diff --git a/backend/src/modules/tts/tts.controller.ts b/backend/src/modules/tts/tts.controller.ts index 5834de2..2a5ea62 100644 --- a/backend/src/modules/tts/tts.controller.ts +++ b/backend/src/modules/tts/tts.controller.ts @@ -47,6 +47,14 @@ export class TtsController { const dest = path.join(uploadDir, file.filename + ext) fs.renameSync(file.path, dest) + // 拒绝损坏/空音频文件,避免 whisper 调用 ffmpeg 解码失败刷错误日志 + const stat = fs.statSync(dest) + if (!stat.size || stat.size < 500) { + this.logger.warn(`ASR: empty audio upload (size=${stat.size}), ext=${ext}`) + try { fs.unlinkSync(dest) } catch {} + return { text: '' } + } + let wavPath = dest if (ext.toLowerCase() !== '.wav') { const potentialWav = dest.replace(/\.[^.]+$/, '.wav') diff --git a/zhiyin-app/src/manifest.json b/zhiyin-app/src/manifest.json index 629c1ba..d30c6db 100644 --- a/zhiyin-app/src/manifest.json +++ b/zhiyin-app/src/manifest.json @@ -16,6 +16,11 @@ "urlCheck": false, "__usePrivacyCheck__": true }, - "usingComponents": true + "usingComponents": true, + "permission": { + "scope.record": { + "desc": "用于语音输入回答面试问题" + } + } } } diff --git a/zhiyin-app/src/pages/interview/interview.vue b/zhiyin-app/src/pages/interview/interview.vue index facac75..d836cce 100644 --- a/zhiyin-app/src/pages/interview/interview.vue +++ b/zhiyin-app/src/pages/interview/interview.vue @@ -146,10 +146,52 @@ const aiAmplitudeData = ref([]) const isSpeaking = ref(false) const dhRef = ref(null) const isRecording = ref(false) -let recorder = null + let recorder = null -let timerSeconds = 0 -let timerInterval = null +// 录音初始化(一次性创建 recorder 并设置事件) +function initRecorder() { + // #ifdef MP-WEIXIN + if (recorder) return // 已初始化 + recorder = uni.getRecorderManager() + recorder.onStart(() => { + console.log('[Recorder] start recording') + }) + recorder.onError((err) => { + console.error('[Recorder] error:', err) + isRecording.value = false + uni.showToast({ title: '录音失败,请重试', icon: 'none' }) + }) + recorder.onStop(async (res) => { + isRecording.value = false + console.log('[Recorder] stop, tempFilePath:', res.tempFilePath) + if (!res.tempFilePath) { + uni.showToast({ title: '录音文件为空', icon: 'none' }) + return + } + try { + const uploadRes = await uni.uploadFile({ + url: api(API_ENDPOINTS.TTS.ASR), + filePath: res.tempFilePath, + name: 'audio', + header: { 'Authorization': `Bearer ${token()}` }, + }) + if (uploadRes.statusCode === 200 && uploadRes.data) { + const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data + if (data.text) { + inputText.value = data.text + uni.vibrateShort({ type: 'light' }) + return + } + } else if (checkAuth(uploadRes)) { + return + } + } catch (e) { + console.error('[ASR] upload error:', e?.message || e) + } + uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' }) + }) + // #endif +} let MAX_QUESTIONS = 10 const progressPercent = computed(() => Math.min((answeredCount.value / MAX_QUESTIONS) * 100, 100)) @@ -190,9 +232,9 @@ const selectPosition = (pos) => { } onMounted(() => { + initRecorder() timerInterval = setInterval(() => timerSeconds++, 1000) if (!position.value) { - // 未传入岗位,展示选择弹窗(无论是否登录) loadPositions() showPositionPicker.value = true } else if (token()) { @@ -356,43 +398,45 @@ const confirmExit = () => { function startRecord() { if (aiLoading.value || isComplete.value) return // #ifdef MP-WEIXIN - isRecording.value = true - recorder = uni.getRecorderManager() - recorder.onStart(() => {}) - recorder.onError(() => { isRecording.value = false; uni.showToast({ title: '录音失败', icon: 'none' }) }) - recorder.onStop(async (res) => { - if (!res.tempFilePath) return - const audioPath = res.tempFilePath - try { - const uploadRes = await uni.uploadFile({ - url: api(API_ENDPOINTS.TTS.ASR), - filePath: audioPath, - name: 'audio', - header: { 'Authorization': `Bearer ${token()}` }, + if (isRecording.value) return + // 先确保已获得 scope.record 授权,避免没权限时产生全 0 的 corrupted 音频 + uni.authorize({ + scope: 'scope.record', + success: () => doStartRecorder(), + fail: () => { + uni.showModal({ + title: '需要录音权限', + content: '请在「设置」中开启「麦克风」权限后再使用语音输入', + confirmText: '去设置', + success: (r) => { if (r.confirm) uni.openSetting({}) }, }) - if (uploadRes.statusCode === 200 && uploadRes.data) { - const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data - if (data.text) { - inputText.value = data.text - uni.vibrateShort({ type: 'light' }) - return - } - } else if (checkAuth(uploadRes)) { - return - } - } catch (e) { - console.error('[ASR] upload error:', e?.message || e) - } - uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' }) + }, }) - recorder.start({ format: 'aac', sampleRate: 22050, numberOfChannels: 1, encodeBitRate: 16000 }) - uni.vibrateShort({ type: 'medium' }) // #endif // #ifndef MP-WEIXIN uni.showToast({ title: '语音输入仅支持小程序', icon: 'none' }) // #endif } +// #ifdef MP-WEIXIN +function doStartRecorder() { + isRecording.value = true + if (!recorder) initRecorder() + if (!recorder) { + isRecording.value = false + uni.showToast({ title: '录音初始化失败', icon: 'none' }) + return + } + recorder.start({ + format: 'wav', + sampleRate: 16000, + numberOfChannels: 1, + encodeBitRate: 128000, + }) + uni.vibrateShort({ type: 'medium' }) +} +// #endif + function stopRecord() { if (!recorder || !isRecording.value) return isRecording.value = false