From 720cb97877821d5b3ecb54a9bbf1f578dee938a8 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Tue, 20 Oct 2026 18:25:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E5=A4=A7=E7=A5=9E=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E4=BB=8B=E7=BB=8D=E7=9C=9F=E5=AE=9E=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=88=E5=BD=95=E5=88=B6/=E4=B8=8A=E4=BC=A0/=E8=AF=95?= =?UTF-8?q?=E5=90=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: companions.voice_intro_url;仓储 updateCompanionVoice;详情与 /api/expert/me 返回 voiceIntroUrl/voiceDuration - 后端: POST /api/companions/:id/voice(音频白名单 mp3/m4a/aac/wav/ogg、5MB、≤60s、限流、仅本人或管理员) - 小程序: api.uploadVoiceIntro(uni.uploadFile + 用户令牌);工作台新增「语音介绍」卡片(录制/重录/试听,60s mp3);详情页改为 uni.createInnerAudioContext 真实播放,卸载销毁 - 修复 uploadImage 未携带用户令牌(生产 fail-closed 后会导致上传失败) - 新增 voice-intro.spec.ts(104 套件/701 用例全绿);冒烟 7/7 --- pwuniapp/common/api.js | 58 +++++++++++++ pwuniapp/pages/expert/detail.vue | 32 +++++-- pwuniapp/pages/expert/workspace.vue | 129 +++++++++++++++++++++++++++- server/src/db/mysql.ts | 4 + server/src/db/repository.ts | 15 +++- server/src/index.ts | 65 ++++++++++++++ server/tests/voice-intro.spec.ts | 54 ++++++++++++ 7 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 server/tests/voice-intro.spec.ts diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index c492004..eb1c181 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -872,6 +872,18 @@ export async function applyPostParty(postId, applyData = {}) { } } +/** + * 用户会话 token 请求头(上传类接口需要) + */ +function authHeader() { + try { + const t = uni.getStorageSync('userToken'); + return t ? { Authorization: `Bearer ${t}` } : {}; + } catch (_) { + return {}; + } +} + /** * 真实上传本地图片至服务端持久化静态存储 * @param {string} tempFilePath 本地图片临时路径 (如 wxfile://tmp_xxx 或 blob:xxx) @@ -917,6 +929,7 @@ export function uploadImage(tempFilePath) { url: uploadUrl, filePath: tempFilePath, name: 'file', + header: authHeader(), success: (uploadRes) => { try { const data = JSON.parse(uploadRes.data); @@ -1097,6 +1110,51 @@ export async function deleteExpertService(expertId, serviceId) { return res; } +/** + * 上传大神语音介绍(录音文件),返回可直接播放的公网地址 + * @param {number} expertId 大神ID + * @param {string} tempFilePath 录音临时文件路径 + * @param {number} duration 时长(秒,≤60) + */ +export function uploadVoiceIntro(expertId, tempFilePath, duration) { + return new Promise((resolve, reject) => { + if (!tempFilePath) return reject(new Error('语音文件路径为空')); + // #ifdef H5 + const uploadUrl = `/api/companions/${Number(expertId)}/voice`; + // #endif + // #ifndef H5 + const uploadUrl = `https://peiww.gynhc.com/api/companions/${Number(expertId)}/voice`; + // #endif + uni.uploadFile({ + url: uploadUrl, + filePath: tempFilePath, + name: 'file', + header: authHeader(), + formData: { + duration: String(Math.min(60, Math.max(1, Math.round(Number(duration) || 15)))), + userId: String(normalizeUserId()), + }, + success: (uploadRes) => { + try { + const data = JSON.parse(uploadRes.data); + if (data && data.code === 20000 && data.data && data.data.voiceIntroUrl) { + let finalUrl = data.data.voiceIntroUrl; + // #ifndef H5 + if (String(finalUrl).startsWith('/uploads/')) finalUrl = `https://peiww.gynhc.com${finalUrl}`; + // #endif + resolve({ url: finalUrl, duration: data.data.voiceDuration }); + } else { + reject(new Error((data && data.message) || '语音上传失败')); + } + } catch (e) { + reject(new Error('语音上传返回格式错误')); + } + }, + fail: (err) => reject(new Error(err.errMsg || '语音上传网络异常')), + }); + }); +} + /** * 获取用户收藏的陪玩列表 */ diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index 9ce0eb8..2cbf310 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -52,14 +52,14 @@ - {{ isPlayingVoice ? '正在播放打招呼原声...' : '点击试听打招呼原声' }} + {{ isPlayingVoice ? '正在播放语音介绍...' : (expert.voiceIntroUrl ? '点击试听语音介绍' : '暂未上传语音介绍') }} - {{ expert.voiceDuration }}s 真实原声 + {{ expert.voiceIntroUrl ? expert.voiceDuration + 's 真实录音' : '暂无可试听内容' }} 试听 @@ -280,6 +280,7 @@ onMounted(() => { onUnmounted(() => { uni.$off('theme-change', onThemeChanged); + try { if (voiceAudio) { voiceAudio.destroy(); voiceAudio = null; } } catch (_) {} }); const stopPropagation = (e) => { @@ -335,6 +336,7 @@ const expert = ref({ const isCompanion = computed(() => expert.value.isCompanion !== false); const selectedServiceId = ref('srv_1'); const isPlayingVoice = ref(false); +let voiceAudio = null; let currentExpertTargetId = null; const refreshFavoriteState = async () => { @@ -526,13 +528,27 @@ const selectService = (srv) => { }; const toggleVoice = () => { - isPlayingVoice.value = !isPlayingVoice.value; - if (isPlayingVoice.value) { - uni.showToast({ title: '正在播放大神打招呼原声', icon: 'none' }); - setTimeout(() => { - isPlayingVoice.value = false; - }, 4000); + const url = String((expert.value && expert.value.voiceIntroUrl) || ''); + if (!url) { + uni.showToast({ title: '该大神暂未上传语音介绍', icon: 'none' }); + return; } + if (isPlayingVoice.value) { + try { voiceAudio && voiceAudio.pause(); } catch (_) {} + isPlayingVoice.value = false; + return; + } + if (!voiceAudio) { + voiceAudio = uni.createInnerAudioContext(); + voiceAudio.onEnded(() => { isPlayingVoice.value = false; }); + voiceAudio.onError(() => { + isPlayingVoice.value = false; + uni.showToast({ title: '语音播放失败,请稍后重试', icon: 'none' }); + }); + } + voiceAudio.src = url; + voiceAudio.play(); + isPlayingVoice.value = true; }; const chatOnline = () => { diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index 4c24ac0..54cdd4c 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -194,6 +194,29 @@ + + + + + + 语音介绍(试听) + + ≤60 秒 · 仅本人录音 + + + + + + {{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }} + + + 语音仅用于买家试听介绍,平台不提供实时语音房;实际语音沟通在游戏内或微信进行。 + + @@ -443,7 +466,7 @@