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
这个提交包含在:
@@ -95,6 +95,7 @@ export async function initDatabase(): Promise<void> {
|
||||
hourly_rate INT NOT NULL,
|
||||
voice_duration INT DEFAULT 15,
|
||||
voice_title VARCHAR(128),
|
||||
voice_intro_url VARCHAR(255) DEFAULT '',
|
||||
rating DECIMAL(3,2) DEFAULT 4.90,
|
||||
order_count INT DEFAULT 0,
|
||||
status VARCHAR(32) DEFAULT '接单中',
|
||||
@@ -115,6 +116,9 @@ export async function initDatabase(): Promise<void> {
|
||||
try {
|
||||
await conn.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query("ALTER TABLE companions ADD COLUMN voice_intro_url VARCHAR(255) NOT NULL DEFAULT '' AFTER voice_title");
|
||||
} catch (_) {}
|
||||
|
||||
// 4. 订单表
|
||||
await conn.query(`
|
||||
|
||||
@@ -750,7 +750,7 @@ export class MysqlRepository {
|
||||
SELECT
|
||||
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
||||
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,
|
||||
id_card_masked as idCardMasked, tags, apply_time as applyTime
|
||||
FROM companions
|
||||
@@ -936,6 +936,7 @@ export class MysqlRepository {
|
||||
voiceDuration: synthetic ? 0 : (Number(c.voice_duration) || 15),
|
||||
voiceType: 'voice',
|
||||
voiceTitle: synthetic ? '' : (c.voice_title || '语音招呼'),
|
||||
voiceIntroUrl: synthetic ? '' : (c.voice_intro_url || ''),
|
||||
bio: c.custom_bio || (synthetic ? '' : '专业同城生活陪伴,热情礼貌,守时诚信。'),
|
||||
tags: parsedTags,
|
||||
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> {
|
||||
const comp = await this.resolveCompanionRow(id);
|
||||
if (!comp) return null;
|
||||
|
||||
@@ -4457,6 +4457,9 @@ app.get('/api/expert/me', async (req: Request, res: Response) => {
|
||||
avatar: comp.avatar,
|
||||
track: comp.track,
|
||||
hourlyRate: Number(comp.hourly_rate) || 0,
|
||||
voiceIntroUrl: comp.voice_intro_url || '',
|
||||
voiceDuration: Number(comp.voice_duration) || 0,
|
||||
voiceTitle: comp.voice_title || '',
|
||||
status: comp.status,
|
||||
auditStatus: comp.audit_status,
|
||||
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;
|
||||
|
||||
|
||||
54
server/tests/voice-intro.spec.ts
普通文件
54
server/tests/voice-intro.spec.ts
普通文件
@@ -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()');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户