'use strict'; const crypto = require('crypto'); const superagent = require('superagent'); const fsPromises = require('fs/promises'); const { URL } = require('url'); const path = require('path'); const WebSocket = require('ws'); /** * [生成CheckSum签名] * @param {string} appSecret - 应用密钥 * @param {string} nonce - 随机字符串 * @param {string} curTime - 当前UTC时间戳 * @returns {string} - SHA256加密后的CheckSum */ function generateCheckSum(appSecret, nonce, curTime) { const content = `${appSecret}${nonce}${curTime}`; return crypto.createHash('sha256').update(content).digest('hex'); } function getWidgetTypeByUrl(url) { const lowerUrl = String(url || '').toLowerCase(); const cleanUrl = lowerUrl.split('?')[0]; if (/\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(cleanUrl)) { return 'PICTURE'; } if (/\.(mp4|mov|avi|wmv|flv|mkv|webm|m4v)$/i.test(cleanUrl)) { return 'VIDEO'; } if (/^(rtsp|rtmp):\/\//i.test(lowerUrl) || /\.(m3u8|ts)$/i.test(cleanUrl)) { return 'STREAM_MEDIA'; } return 'HTML'; } async function getMediaMetadata(url) { try { const res = await superagent .get(url) .buffer(true) .timeout(30000); const contentBuffer = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.text || '', 'utf8'); return { size: contentBuffer.length, md5: crypto.createHash('md5').update(contentBuffer).digest('hex') }; } catch (error) { return { size: 0, md5: '00000000000000000000000000000000' }; } } function buildXfyunAuthUrl(hostUrl, apiKey, apiSecret) { const url = new URL(hostUrl); const date = new Date().toUTCString(); const signatureOrigin = `host: ${url.host}\ndate: ${date}\nGET ${url.pathname} HTTP/1.1`; const signature = crypto .createHmac('sha256', apiSecret) .update(signatureOrigin) .digest('base64'); const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`; const authorization = Buffer.from(authorizationOrigin).toString('base64'); url.searchParams.set('authorization', authorization); url.searchParams.set('date', date); url.searchParams.set('host', url.host); return url.toString(); } function getXfyunText(result) { return (result?.ws || []) .map(item => item?.cw?.[0]?.w || '') .join(''); } function getSpeechAudioOptions(file, body = {}) { const ext = path.extname(file?.originalname || '').toLowerCase(); const encoding = body.encoding || (ext === '.mp3' ? 'lame' : 'raw'); const format = body.format || 'audio/L16;rate=16000'; const supportedEncodings = ['raw', 'lame', 'speex', 'speex-wb']; if (!supportedEncodings.includes(encoding)) { throw '不支持的音频编码,仅支持 raw、lame、speex、speex-wb'; } return { encoding, format }; } async function recognizeSpeechByXfyun(audioBuffer, config, audioOptions) { const { appId, apiKey, apiSecret, hostUrl } = config; const authUrl = buildXfyunAuthUrl(hostUrl, apiKey, apiSecret); const frameSize = 1280; const finalResults = []; const { encoding, format } = audioOptions; return new Promise((resolve, reject) => { const socket = new WebSocket(authUrl); let offset = 0; let closed = false; const fail = (error) => { if (!closed) { closed = true; try { socket.close(); } catch (_) { // ignore close error } } reject(error); }; const sendNextFrame = () => { if (offset >= audioBuffer.length) { socket.send(JSON.stringify({ data: { status: 2, format, encoding, audio: '' } })); return; } const chunk = audioBuffer.subarray(offset, offset + frameSize); const status = offset === 0 ? 0 : 1; const payload = { data: { status, format, encoding, audio: chunk.toString('base64') } }; if (status === 0) { payload.common = { app_id: appId }; payload.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vad_eos: 10000 }; } socket.send(JSON.stringify(payload)); offset += frameSize; setTimeout(sendNextFrame, 40); }; socket.addEventListener('open', () => { sendNextFrame(); }); socket.addEventListener('message', (event) => { let message; try { message = JSON.parse(event.data); } catch (error) { fail(new Error('讯飞语音识别返回数据格式错误')); return; } if (message.code !== 0) { fail(new Error(message.message || '讯飞语音识别失败')); return; } if (message.data?.result) { finalResults[message.data.result.sn] = getXfyunText(message.data.result); } if (message.data?.status === 2) { closed = true; socket.close(); resolve(finalResults.filter(Boolean).join('')); } }); socket.addEventListener('error', () => { fail(new Error('讯飞语音识别连接失败')); }); }); } function callVoiceFastGpt(config, text) { return superagent .post(`${config.apiUrl}/api/v1/chat/completions`) .send({ stream: false, detail: false, messages: [{ role: 'user', content: [{ type: 'text', text }] }] }) .set({ Authorization: `Bearer ${config.voiceAppKey}`, 'Content-Type': 'application/json' }) .timeout(60000); } module.exports.setupVoiceStream = (app) => { app.fs.socket.on('connection', (clientSocket, req) => { if (req.url !== '/voice/stream') { return; } app.fs.logger.info('[voice stream] client connected'); const { xfyunSpeech, fastGpt } = app.fs.config; if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) { clientSocket.send(JSON.stringify({ type: 'error', message: '讯飞语音识别配置未正确设置' })); clientSocket.close(); return; } if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) { clientSocket.send(JSON.stringify({ type: 'error', message: 'FastGPT语音应用配置未正确设置' })); clientSocket.close(); return; } const authUrl = buildXfyunAuthUrl(xfyunSpeech.hostUrl, xfyunSpeech.apiKey, xfyunSpeech.apiSecret); const xfyunSocket = new WebSocket(authUrl); const results = []; const pendingAudioChunks = []; let started = false; let ended = false; const totalStartedAt = Date.now(); let speechEndedAt = null; const idleTimer = setTimeout(() => { fail('流式语音识别超时'); }, 30000); const sendToXfyun = (status, audio = '') => { const payload = { data: { status, format: 'audio/L16;rate=16000', encoding: 'raw', audio } }; if (status === 0) { payload.common = { app_id: xfyunSpeech.appId }; payload.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vad_eos: 1000 }; } xfyunSocket.send(JSON.stringify(payload)); }; const fail = (message) => { clearTimeout(idleTimer); app.fs.logger.error('[voice stream] failed:', message); if (clientSocket.readyState === 1) { clientSocket.send(JSON.stringify({ type: 'error', message })); } try { clientSocket.close(); } catch (_) {} try { xfyunSocket.close(); } catch (_) {} }; const beginStream = () => { started = true; const firstChunk = pendingAudioChunks.shift(); if (!firstChunk) { return; } app.fs.logger.info('[voice stream] begin stream'); sendToXfyun(0, firstChunk.toString('base64')); pendingAudioChunks.splice(0).forEach(chunk => { sendToXfyun(1, chunk.toString('base64')); }); }; xfyunSocket.addEventListener('message', async (event) => { let message; try { message = JSON.parse(event.data); } catch (_) { fail('讯飞语音识别返回数据格式错误'); return; } if (message.code !== 0) { fail(message.message || '讯飞语音识别失败'); return; } if (message.data?.result) { results[message.data.result.sn] = getXfyunText(message.data.result); } if (message.data?.status === 2) { clearTimeout(idleTimer); app.fs.logger.info('[voice stream] speech finished'); speechEndedAt = Date.now(); const text = results.filter(Boolean).join(''); if (!text) { fail('未识别到有效语音文本'); return; } try { const fastGptStartedAt = Date.now(); const fastGptRes = await callVoiceFastGpt(fastGpt, text); if (clientSocket.readyState === 1) { clientSocket.send(JSON.stringify({ type: 'result', text, fastGpt: fastGptRes.body, timings: { speechMs: speechEndedAt - totalStartedAt, fastGptMs: Date.now() - fastGptStartedAt, totalMs: Date.now() - totalStartedAt } })); } } catch (error) { fail(error.message || 'FastGPT调用失败'); } } }); xfyunSocket.addEventListener('error', () => fail('讯飞语音识别连接失败')); xfyunSocket.addEventListener('open', () => { app.fs.logger.info('[voice stream] xfyun connected'); }); clientSocket.on('message', (data, isBinary) => { if (isBinary) { if (ended) { return; } const chunk = Buffer.from(data); if (!started) { pendingAudioChunks.push(chunk); if (xfyunSocket.readyState === 1) { beginStream(); } return; } if (xfyunSocket.readyState !== 1) { pendingAudioChunks.push(chunk); return; } sendToXfyun(1, chunk.toString('base64')); return; } let message; try { message = JSON.parse(data.toString()); } catch (_) { fail('客户端消息格式错误'); return; } if (message.type === 'start') { app.fs.logger.info('[voice stream] start received'); if (xfyunSocket.readyState !== 1) { xfyunSocket.addEventListener('open', beginStream, { once: true }); } } if (message.type === 'stop' && started && !ended && xfyunSocket.readyState === 1) { app.fs.logger.info('[voice stream] stop received'); ended = true; sendToXfyun(2, ''); } }); clientSocket.on('close', () => { clearTimeout(idleTimer); app.fs.logger.info('[voice stream] client closed'); try { xfyunSocket.close(); } catch (_) {} }); }); }; /** * [获取播放器列表] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.getPlayerList = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; // [获取查询参数] const { count = 20, start = 0, name } = ctx.query; // [验证参数范围] const validatedCount = Math.min(Math.max(parseInt(count) || 20, 1), 100); const validatedStart = Math.max(parseInt(start) || 0, 0); // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL和参数] const url = `${baseUrl}/v2/player/list`; const queryParams = { count: validatedCount, start: validatedStart }; if (name) { queryParams.name = name; } // [发送请求到诺瓦开放平台] const res = await superagent .get(url) .query(queryParams) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('获取播放器列表失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '获取播放器列表失败'), error: error.response?.body || error.message }; } }; /** * [获取播放器详情] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.getPlayerDetail = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; const { playerId } = ctx.params; if (!playerId) { throw '缺少播放器ID参数'; } // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/${playerId}`; // [发送请求到诺瓦开放平台] const res = await superagent .get(url) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('获取播放器详情失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '获取播放器详情失败'), error: error.response?.body || error.message }; } }; /** * [发送播放器命令] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.sendPlayerCommand = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; const { playerId } = ctx.params; const { command, params } = ctx.request.body; if (!playerId) { throw '缺少播放器ID参数'; } if (!command) { throw '缺少命令参数'; } // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/${playerId}/command`; // [发送请求到诺瓦开放平台] const res = await superagent .post(url) .send({ command, params: params || {} }) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('发送播放器命令失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '发送播放器命令失败'), error: error.response?.body || error.message }; } }; /** * [屏幕开启] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.openScreen = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; // [固定的播放器ID] const playerIds = ["81ea331f7b0a456b9a57f6617634413f"]; // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/real-time-control/screen-status`; // [发送请求到诺瓦开放平台] const res = await superagent .post(url) .send({ playerIds, status: 'OPEN' }) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('屏幕开启失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '屏幕开启失败'), error: error.response?.body || error.message }; } }; /** * [屏幕关闭] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.closeScreen = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; // [固定的播放器ID] const playerIds = ["81ea331f7b0a456b9a57f6617634413f"]; // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/real-time-control/screen-status`; // [发送请求到诺瓦开放平台] const res = await superagent .post(url) .send({ playerIds, status: 'CLOSE' }) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('屏幕关闭失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '屏幕关闭失败'), error: error.response?.body || error.message }; } }; /** * [新增播放器] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.addPlayers = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; const { sns } = ctx.request.body; // [参数校验] if (!sns || !Array.isArray(sns) || sns.length === 0) { throw '缺少设备SN码集合参数'; } // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/iot-add`; // [发送请求到诺瓦开放平台] const res = await superagent .post(url) .send({ sns }) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('新增播放器失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '新增播放器失败'), error: error.response?.body || error.message }; } }; /** * [下发常规节目] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.publishNormalProgram = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; const { pages } = ctx.request.body || {}; // [参数校验] if (!pages || !Array.isArray(pages) || pages.length === 0) { throw '缺少页面参数,格式应为 pages: [{ name, url }]'; } const invalidPage = pages.find(item => !item || !item.name || !item.url); if (invalidPage) { throw 'pages 中每一项都必须包含 name 和 url'; } // [固定播放器ID集合,优先使用配置] const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0 ? novaCloud.fixedPlayerIds : ['81ea331f7b0a456b9a57f6617634413f']; if (fixedPlayerIds.length > 100) { throw '固定播放器ID集合最多支持100个'; } // [根据前端传入的 name + url 自动构造 widgets] const normalizedPages = await Promise.all(pages.map(async (page) => { const type = getWidgetTypeByUrl(page.url); const { size, md5 } = await getMediaMetadata(page.url); return { name: page.name, widgets: [{ type, md5, size, duration: type === 'VIDEO' ? "" : 10000, url: page.url, layout: { x: '0%', y: '0%', width: '100%', height: '100%' } }] }; })); const requestPayload = { playerIds: fixedPlayerIds, pages: normalizedPages }; // [生成请求头参数] const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); // [构建请求URL] const url = `${baseUrl}/v2/player/program/normal`; // [发送请求到诺瓦开放平台] const res = await superagent .post(url) .send(requestPayload) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); // [返回响应数据] ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('下发常规节目失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '下发常规节目失败'), error: error.response?.body || error.message }; } }; /** * [下发单页紧急插播节目] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.publishEmergencyProgramPage = async (ctx, next) => { try { const { novaCloud } = ctx.app.fs.config; if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) { throw '诺瓦云平台配置未正确设置'; } const { appKey, appSecret, baseUrl } = novaCloud; const { name, url } = ctx.request.body || {}; if (!name || !url) { throw '缺少页面参数,必须包含 name 和 url'; } const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0 ? novaCloud.fixedPlayerIds : ['81ea331f7b0a456b9a57f6617634413f']; if (fixedPlayerIds.length > 100) { throw '固定播放器ID集合最多支持100个'; } const type = getWidgetTypeByUrl(url); const { size, md5 } = await getMediaMetadata(url); const fixedDuration = 2 * 60 * 60 * 1000; const requestPayload = { playerIds: fixedPlayerIds, attribute: { spotsType: 'IMMEDIATELY', normalProgramStatus: 'NORMAL', duration: fixedDuration }, page: { name, widgets: [{ name, type, md5, size, duration: type === 'VIDEO' ? 0 : fixedDuration, url, layout: { x: '0%', y: '0%', width: '100%', height: '100%' } }] } }; const nonce = crypto.randomBytes(16).toString('hex'); const curTime = Math.floor(Date.now() / 1000).toString(); const checkSum = generateCheckSum(appSecret, nonce, curTime); const reqUrl = `${baseUrl}/v2/player/emergency-program/page`; const res = await superagent .post(reqUrl) .send(requestPayload) .set({ 'AppKey': appKey, 'Nonce': nonce, 'CurTime': curTime, 'CheckSum': checkSum, 'Content-Type': 'application/json' }) .timeout(30000); ctx.body = res.body; ctx.status = 200; } catch (error) { ctx.logger.error('下发单页紧急插播节目失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '下发单页紧急插播节目失败'), error: error.response?.body || error.message }; } }; /** * [语音识别并调用 FastGPT] * @param {Object} ctx - Koa上下文 * @param {Object} next - 下一个中间件 */ module.exports.voiceChat = async (ctx, next) => { const file = ctx.request.file; try { const totalStartedAt = Date.now(); const { xfyunSpeech, fastGpt } = ctx.app.fs.config; if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) { throw '讯飞语音识别配置未正确设置'; } if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) { throw 'FastGPT语音应用配置未正确设置'; } if (!file) { throw '缺少音频文件,字段名应为 file'; } const audioBuffer = await fsPromises.readFile(file.path); if (!audioBuffer.length) { throw '音频文件不能为空'; } const audioOptions = getSpeechAudioOptions(file, ctx.request.body); const speechStartedAt = Date.now(); const recognizedText = await recognizeSpeechByXfyun(audioBuffer, xfyunSpeech, audioOptions); const speechElapsedMs = Date.now() - speechStartedAt; if (!recognizedText) { throw '未识别到有效语音文本'; } const fastGptStartedAt = Date.now(); const fastGptRes = await callVoiceFastGpt(fastGpt, recognizedText); const fastGptElapsedMs = Date.now() - fastGptStartedAt; ctx.status = 200; ctx.body = { text: recognizedText, fastGpt: fastGptRes.body, timings: { speechMs: speechElapsedMs, fastGptMs: fastGptElapsedMs, totalMs: Date.now() - totalStartedAt } }; } catch (error) { ctx.logger.error('语音识别并调用FastGPT失败:', error); ctx.status = error.status || 500; ctx.body = { message: typeof error === 'string' ? error : (error.message || '语音识别并调用FastGPT失败'), error: error.response?.body || error.message }; } finally { if (file?.path) { try { await fsPromises.unlink(file.path); } catch (_) { // ignore temp file cleanup error } } } };