feat(voice): 大神语音介绍真实落地(录制/上传/试听)

- 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
这个提交包含在:
xcbyy
2026-10-20 18:25:11 +08:00
父节点 d9fe43302b
当前提交 720cb97877
共有 7 个文件被更改,包括 346 次插入 和 11 次删除

查看文件

@@ -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) * @param {string} tempFilePath 本地图片临时路径 (如 wxfile://tmp_xxx 或 blob:xxx)
@@ -917,6 +929,7 @@ export function uploadImage(tempFilePath) {
url: uploadUrl, url: uploadUrl,
filePath: tempFilePath, filePath: tempFilePath,
name: 'file', name: 'file',
header: authHeader(),
success: (uploadRes) => { success: (uploadRes) => {
try { try {
const data = JSON.parse(uploadRes.data); const data = JSON.parse(uploadRes.data);
@@ -1097,6 +1110,51 @@ export async function deleteExpertService(expertId, serviceId) {
return res; 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 || '语音上传网络异常')),
});
});
}
/** /**
* 获取用户收藏的陪玩列表 * 获取用户收藏的陪玩列表
*/ */

查看文件

@@ -52,14 +52,14 @@
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" /> <image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
</view> </view>
<view class="voice-info"> <view class="voice-info">
<text class="voice-title">{{ isPlayingVoice ? '正在播放打招呼原声...' : '点击试听打招呼原声' }}</text> <text class="voice-title">{{ isPlayingVoice ? '正在播放语音介绍...' : (expert.voiceIntroUrl ? '点击试听语音介绍' : '暂未上传语音介绍') }}</text>
<view class="voice-wave" v-if="isPlayingVoice"> <view class="voice-wave" v-if="isPlayingVoice">
<view class="bar b1"></view> <view class="bar b1"></view>
<view class="bar b2"></view> <view class="bar b2"></view>
<view class="bar b3"></view> <view class="bar b3"></view>
<view class="bar b4"></view> <view class="bar b4"></view>
</view> </view>
<text class="voice-dur" v-else>{{ expert.voiceDuration }}s 真实原声</text> <text class="voice-dur" v-else>{{ expert.voiceIntroUrl ? expert.voiceDuration + 's 真实录音' : '暂无可试听内容' }}</text>
</view> </view>
<text class="listen-tag">试听</text> <text class="listen-tag">试听</text>
</view> </view>
@@ -280,6 +280,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
uni.$off('theme-change', onThemeChanged); uni.$off('theme-change', onThemeChanged);
try { if (voiceAudio) { voiceAudio.destroy(); voiceAudio = null; } } catch (_) {}
}); });
const stopPropagation = (e) => { const stopPropagation = (e) => {
@@ -335,6 +336,7 @@ const expert = ref({
const isCompanion = computed(() => expert.value.isCompanion !== false); const isCompanion = computed(() => expert.value.isCompanion !== false);
const selectedServiceId = ref('srv_1'); const selectedServiceId = ref('srv_1');
const isPlayingVoice = ref(false); const isPlayingVoice = ref(false);
let voiceAudio = null;
let currentExpertTargetId = null; let currentExpertTargetId = null;
const refreshFavoriteState = async () => { const refreshFavoriteState = async () => {
@@ -526,13 +528,27 @@ const selectService = (srv) => {
}; };
const toggleVoice = () => { const toggleVoice = () => {
isPlayingVoice.value = !isPlayingVoice.value; const url = String((expert.value && expert.value.voiceIntroUrl) || '');
if (isPlayingVoice.value) { if (!url) {
uni.showToast({ title: '正在播放大神打招呼原声', icon: 'none' }); uni.showToast({ title: '该大神暂未上传语音介绍', icon: 'none' });
setTimeout(() => { return;
isPlayingVoice.value = false;
}, 4000);
} }
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 = () => { const chatOnline = () => {

查看文件

@@ -194,6 +194,29 @@
</view> </view>
</view> </view>
<!-- 语音介绍(录音试听):仅上传自己的录音,不做实时语音 -->
<view class="section-card voice-intro-card">
<view class="sec-head">
<view class="title-wrap">
<image class="sec-svg" :src="ICONS.mic" mode="aspectFit" />
<text class="sec-title">语音介绍(试听)</text>
</view>
<text class="sec-sub">≤60 秒 · 仅本人录音</text>
</view>
<view class="voice-row">
<button class="btn-wd-records btn-active" :disabled="voiceUploading" @click="toggleRecord">
{{ voiceRecording ? '停止录音' : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
</button>
<button v-if="expert.voiceIntroUrl" class="btn-wd-records btn-active" @click="playVoice">
{{ voicePlaying ? '停止播放' : '播放试听' }}
</button>
<text class="voice-meta">
{{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }}
</text>
</view>
<text class="voice-tip">语音仅用于买家试听介绍,平台不提供实时语音房;实际语音沟通在游戏内或微信进行。</text>
</view>
<!-- 核心亮点功能:4 位数字门票现场核销中心 (严格依 UIUX 规范 4.2 落地) --> <!-- 核心亮点功能:4 位数字门票现场核销中心 (严格依 UIUX 规范 4.2 落地) -->
<view class="verify-section-card"> <view class="verify-section-card">
<view class="sec-head"> <view class="sec-head">
@@ -443,7 +466,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js'; import { ICONS } from '@/common/icons.js';
import { isExpertCertified } from '@/common/role.js'; import { isExpertCertified } from '@/common/role.js';
@@ -466,6 +489,7 @@ import {
createExpertService, createExpertService,
updateExpertService, updateExpertService,
deleteExpertService, deleteExpertService,
uploadVoiceIntro,
getUserWithdrawals getUserWithdrawals
} from '@/common/api.js'; } from '@/common/api.js';
@@ -562,7 +586,9 @@ const loadExpertProfile = async () => {
nickname: profile.name || expert.value.nickname, nickname: profile.name || expert.value.nickname,
avatar: profile.avatar || expert.value.avatar, avatar: profile.avatar || expert.value.avatar,
track: profile.track || '', track: profile.track || '',
hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate,
voiceIntroUrl: profile.voiceIntroUrl || '',
voiceDuration: Number(profile.voiceDuration) || 0
}; };
} }
} catch (_) {} } catch (_) {}
@@ -577,6 +603,83 @@ const loadExpertServices = async () => {
} }
}; };
// 语音介绍(录音试听)状态与处理
const voiceRecording = ref(false);
const voiceUploading = ref(false);
const voicePlaying = ref(false);
let recorderManager = null;
let voiceAudioCtx = null;
const ensureRecorder = () => {
if (recorderManager) return recorderManager;
recorderManager = uni.getRecorderManager();
recorderManager.onStop((res) => {
voiceRecording.value = false;
const duration = Math.max(1, Math.round(Number(res.duration || 0) / 1000) || 1);
uploadRecordedVoice(res.tempFilePath, duration);
});
recorderManager.onError(() => {
voiceRecording.value = false;
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' });
});
return recorderManager;
};
const uploadRecordedVoice = async (filePath, duration) => {
if (!filePath) return;
voiceUploading.value = true;
try {
const r = await uploadVoiceIntro(currentExpertId(), filePath, duration);
expert.value = { ...expert.value, voiceIntroUrl: r.url, voiceDuration: r.duration };
uni.showToast({ title: '语音介绍已更新', icon: 'none' });
} catch (e) {
uni.showToast({ title: e.message || '语音上传失败', icon: 'none' });
} finally {
voiceUploading.value = false;
}
};
const toggleRecord = () => {
const rm = ensureRecorder();
if (voiceRecording.value) {
rm.stop();
return;
}
const start = () => {
rm.start({ duration: 60000, format: 'mp3', sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000 });
voiceRecording.value = true;
uni.showToast({ title: '开始录音,最长 60 秒', icon: 'none' });
};
uni.authorize({
scope: 'scope.record',
success: start,
fail: () => uni.showToast({ title: '请先允许使用麦克风', icon: 'none' })
});
};
const playVoice = () => {
const url = expert.value.voiceIntroUrl;
if (!url) return;
if (voicePlaying.value) {
try { voiceAudioCtx && voiceAudioCtx.stop(); } catch (_) {}
voicePlaying.value = false;
return;
}
if (!voiceAudioCtx) {
voiceAudioCtx = uni.createInnerAudioContext();
voiceAudioCtx.onEnded(() => { voicePlaying.value = false; });
voiceAudioCtx.onError(() => { voicePlaying.value = false; });
}
voiceAudioCtx.src = url;
voiceAudioCtx.play();
voicePlaying.value = true;
};
onUnmounted(() => {
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
});
const openServiceForm = (srv) => { const openServiceForm = (srv) => {
if (srv) { if (srv) {
serviceForm.value = { serviceForm.value = {
@@ -2180,6 +2283,28 @@ const switchBackToPlayer = () => {
} }
} }
/* 语音介绍卡片 */
.voice-intro-card {
.voice-row {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
margin-top: 12rpx;
}
.voice-meta {
font-size: 24rpx;
color: var(--text-muted);
}
.voice-tip {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: var(--text-muted);
line-height: 1.6;
}
}
/* 服务包操作与表单弹窗 */ /* 服务包操作与表单弹窗 */
.srv-ops { .srv-ops {
display: flex; display: flex;

查看文件

@@ -95,6 +95,7 @@ export async function initDatabase(): Promise<void> {
hourly_rate INT NOT NULL, hourly_rate INT NOT NULL,
voice_duration INT DEFAULT 15, voice_duration INT DEFAULT 15,
voice_title VARCHAR(128), voice_title VARCHAR(128),
voice_intro_url VARCHAR(255) DEFAULT '',
rating DECIMAL(3,2) DEFAULT 4.90, rating DECIMAL(3,2) DEFAULT 4.90,
order_count INT DEFAULT 0, order_count INT DEFAULT 0,
status VARCHAR(32) DEFAULT '接单中', status VARCHAR(32) DEFAULT '接单中',
@@ -115,6 +116,9 @@ export async function initDatabase(): Promise<void> {
try { try {
await conn.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); await conn.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {} } catch (_) {}
try {
await conn.query("ALTER TABLE companions ADD COLUMN voice_intro_url VARCHAR(255) NOT NULL DEFAULT '' AFTER voice_title");
} catch (_) {}
// 4. 订单表 // 4. 订单表
await conn.query(` await conn.query(`

查看文件

@@ -750,7 +750,7 @@ export class MysqlRepository {
SELECT SELECT
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`, id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
hourly_rate as hourlyRate, voice_duration as voiceDuration, hourly_rate as hourlyRate, voice_duration as voiceDuration,
voice_title as voiceTitle, rating, order_count as orderCount, voice_title as voiceTitle, voice_intro_url as voiceIntroUrl, rating, order_count as orderCount,
status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason, status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
id_card_masked as idCardMasked, tags, apply_time as applyTime id_card_masked as idCardMasked, tags, apply_time as applyTime
FROM companions FROM companions
@@ -936,6 +936,7 @@ export class MysqlRepository {
voiceDuration: synthetic ? 0 : (Number(c.voice_duration) || 15), voiceDuration: synthetic ? 0 : (Number(c.voice_duration) || 15),
voiceType: 'voice', voiceType: 'voice',
voiceTitle: synthetic ? '' : (c.voice_title || '语音招呼'), voiceTitle: synthetic ? '' : (c.voice_title || '语音招呼'),
voiceIntroUrl: synthetic ? '' : (c.voice_intro_url || ''),
bio: c.custom_bio || (synthetic ? '' : '专业同城生活陪伴,热情礼貌,守时诚信。'), bio: c.custom_bio || (synthetic ? '' : '专业同城生活陪伴,热情礼貌,守时诚信。'),
tags: parsedTags, tags: parsedTags,
photos: c.custom_photos || (synthetic photos: c.custom_photos || (synthetic
@@ -949,6 +950,18 @@ export class MysqlRepository {
}; };
} }
/** 更新大神语音介绍(录音文件 URL + 时长秒) */
async updateCompanionVoice(companionId: number | string, voiceIntroUrl: string, voiceDuration: number): Promise<any> {
const comp = await this.resolveCompanionRow(companionId);
if (!comp) return null;
const dur = Math.min(60, Math.max(1, Math.round(Number(voiceDuration) || 0)));
await pool.query(
"UPDATE companions SET voice_intro_url = ?, voice_duration = ?, voice_title = COALESCE(NULLIF(voice_title, ''), '语音介绍') WHERE id = ?",
[String(voiceIntroUrl), dur, comp.id]
);
return this.getCompanionById(comp.id);
}
async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise<any> { async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise<any> {
const comp = await this.resolveCompanionRow(id); const comp = await this.resolveCompanionRow(id);
if (!comp) return null; if (!comp) return null;

查看文件

@@ -4457,6 +4457,9 @@ app.get('/api/expert/me', async (req: Request, res: Response) => {
avatar: comp.avatar, avatar: comp.avatar,
track: comp.track, track: comp.track,
hourlyRate: Number(comp.hourly_rate) || 0, hourlyRate: Number(comp.hourly_rate) || 0,
voiceIntroUrl: comp.voice_intro_url || '',
voiceDuration: Number(comp.voice_duration) || 0,
voiceTitle: comp.voice_title || '',
status: comp.status, status: comp.status,
auditStatus: comp.audit_status, auditStatus: comp.audit_status,
isCompanion: true isCompanion: true
@@ -4469,6 +4472,68 @@ app.get('/api/expert/me', async (req: Request, res: Response) => {
} }
}); });
// ===== 大神语音介绍(录音试听)上传 =====
const VOICE_ALLOWED_MIME = [
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/ogg', 'audio/webm',
];
const voiceStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExt = ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.webm'];
const safeExt = allowedExt.includes(ext) ? ext : '.mp3';
cb(null, `voice_${Date.now()}_${Math.round(Math.random() * 1e9)}${safeExt}`);
},
});
const uploadVoice = multer({
storage: voiceStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (_req, file, cb) => {
if (VOICE_ALLOWED_MIME.includes(String(file.mimetype).toLowerCase())) return cb(null, true);
cb(new Error('仅支持 mp3 / m4a / aac / wav / ogg 格式的语音文件'));
},
});
app.post(
'/api/companions/:id/voice',
rateLimit({ name: 'voice_upload', windowMs: 60 * 1000, max: 10, key: rateWriteKey }),
(req: Request, res: Response) => {
uploadVoice.single('file')(req, res, async (err: any) => {
const cleanup = () => {
try { if (req.file && req.file.path) fs.unlinkSync(req.file.path); } catch (_) {}
};
try {
if (err) {
return res.status(400).json({ code: 40000, message: err.message || '语音上传失败' });
}
const companionId = Number(req.params.id);
const own = await repo.getCompanionOwnership(companionId);
if (!own) {
cleanup();
return res.status(404).json({ code: 40400, message: '大神不存在' });
}
assertCompanionActor(req, own, '上传语音介绍');
if (!req.file) {
return res.status(400).json({ code: 40000, message: '未接收到有效语音文件,请使用 file 字段提交' });
}
const duration = Math.min(60, Math.max(1, Math.round(Number(req.body?.duration) || 15)));
const voiceIntroUrl = `/uploads/${req.file.filename}`;
const updated = await repo.updateCompanionVoice(companionId, voiceIntroUrl, duration);
res.json({
code: 20000,
data: { voiceIntroUrl, voiceDuration: duration, companion: updated },
message: '语音介绍已更新',
});
} catch (e: any) {
cleanup();
const statusCode = e.status || 400;
const errCode = e.code || 40000;
res.status(statusCode).json({ code: errCode, message: e.message || '语音上传失败' });
}
});
}
);
// 服务包数量上限(按大神的服务项个数) // 服务包数量上限(按大神的服务项个数)
const SERVICE_MAX_PER_EXPERT = 20; const SERVICE_MAX_PER_EXPERT = 20;

查看文件

@@ -0,0 +1,54 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第八十八批:大神语音介绍(录音试听)真实落地。
* 说明:「语音试听」= 大神自我介绍录音;平台不提供实时语音房/连麦。
*/
describe('第八十八批语音介绍闸门', () => {
const root = path.resolve(__dirname, '../../');
const read = (p: string) => fs.readFileSync(path.join(root, p), 'utf-8');
const indexCode = read('server/src/index.ts');
const repoCode = read('server/src/db/repository.ts');
const mysqlCode = read('server/src/db/mysql.ts');
const api = read('pwuniapp/common/api.js');
const detail = read('pwuniapp/pages/expert/detail.vue');
const workspace = read('pwuniapp/pages/expert/workspace.vue');
it('数据库与仓储支持语音介绍地址/时长', () => {
expect(mysqlCode).toContain('voice_intro_url');
expect(repoCode).toContain('voiceIntroUrl');
expect(repoCode).toContain('async updateCompanionVoice(');
});
it('提供语音上传接口(音频白名单 + 限流 + 归属校验)', () => {
expect(indexCode).toContain("app.post(\n '/api/companions/:id/voice'");
expect(indexCode).toContain("name: 'voice_upload'");
expect(indexCode).toContain("'audio/mpeg'");
expect(indexCode).toContain("assertCompanionActor(req, own, '上传语音介绍')");
expect(indexCode).toContain('fileSize: 5 * 1024 * 1024');
});
it('详情页真实播放(不再用 setTimeout 假动画)', () => {
expect(detail).toContain('voiceIntroUrl');
expect(detail).toContain('uni.createInnerAudioContext()');
expect(detail).not.toContain('4s 真实原声');
// 播放器需在卸载时销毁,避免泄漏
expect(detail).toContain('voiceAudio.destroy()');
});
it('工作台支持录音上传与试听', () => {
expect(workspace).toContain('uni.getRecorderManager()');
expect(workspace).toContain('uploadVoiceIntro(');
expect(workspace).toContain('format: \'mp3\'');
expect(workspace).toContain('duration: 60000');
expect(workspace).toContain('uni.createInnerAudioContext()');
});
it('上传类接口携带用户令牌(含图片上传修复)', () => {
expect(api).toContain('function authHeader()');
expect(api).toContain('export function uploadVoiceIntro(');
expect(api).toContain('header: authHeader()');
});
});