2026-06-15 15:04:43 -07:00
|
|
|
|
import express from 'express';
|
|
|
|
|
|
import { fetch } from 'undici';
|
|
|
|
|
|
|
|
|
|
|
|
const ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
|
|
|
|
|
const ASR_MAX_BYTES = Number(process.env.H5_ASR_MAX_BYTES ?? 5 * 1024 * 1024);
|
|
|
|
|
|
const ASR_TIMEOUT_MS = Number(process.env.H5_ASR_TIMEOUT_MS ?? 45_000);
|
|
|
|
|
|
|
2026-06-15 22:09:38 -07:00
|
|
|
|
export function sanitizeAsrMessage(message) {
|
|
|
|
|
|
if (!message || typeof message !== 'string') return '识别失败,请重试';
|
|
|
|
|
|
const trimmed = message.trim();
|
|
|
|
|
|
if (!trimmed) return '识别失败,请重试';
|
|
|
|
|
|
if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) {
|
|
|
|
|
|
return '音频格式无法识别,请重新录制';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/timeout|timed out/i.test(trimmed)) return '识别超时,请重试';
|
|
|
|
|
|
if (trimmed.length > 160) return `${trimmed.slice(0, 160)}…`;
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
export function attachAsrRoutes(api, deps) {
|
|
|
|
|
|
const multipartRaw = express.raw({
|
|
|
|
|
|
type: (req) => (req.headers['content-type'] ?? '').includes('multipart/form-data'),
|
|
|
|
|
|
limit: ASR_MAX_BYTES,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 登录校验由 api 全局中间件负责(req.currentUser)
|
|
|
|
|
|
api.post('/asr/oneshot', multipartRaw, async (req, res) => {
|
|
|
|
|
|
const contentType = req.headers['content-type'] ?? '';
|
|
|
|
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
|
|
|
|
return deps.sendError(res, req, 400, 'invalid_request', 'Content-Type 必须为 multipart/form-data');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!req.body?.length) {
|
|
|
|
|
|
return deps.sendError(res, req, 400, 'invalid_request', '音频内容为空');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
const timer = setTimeout(() => controller.abort(), ASR_TIMEOUT_MS);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const upstream = await fetch(`${ASR_TARGET}/asr/oneshot`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': contentType },
|
|
|
|
|
|
body: req.body,
|
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
const text = await upstream.text();
|
|
|
|
|
|
let payload = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = JSON.parse(text);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
payload = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!upstream.ok) {
|
2026-06-15 22:09:38 -07:00
|
|
|
|
const message = sanitizeAsrMessage(payload?.message ?? text ?? 'ASR 请求失败');
|
|
|
|
|
|
console.warn('[asr] upstream HTTP error', upstream.status, message);
|
|
|
|
|
|
return deps.sendError(res, req, upstream.status, 'asr_failed', message);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
if (payload?.code !== 200) {
|
2026-06-15 22:09:38 -07:00
|
|
|
|
const message = sanitizeAsrMessage(payload?.message ?? '识别失败');
|
|
|
|
|
|
console.warn('[asr] upstream business error', payload?.code, message);
|
|
|
|
|
|
return deps.sendError(res, req, 502, 'asr_failed', message);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
return deps.sendData(res, req, { text: payload?.data?.text ?? '' });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const message =
|
|
|
|
|
|
err instanceof Error && err.name === 'AbortError'
|
|
|
|
|
|
? '识别超时,请重试'
|
2026-06-15 22:09:38 -07:00
|
|
|
|
: sanitizeAsrMessage(err instanceof Error ? err.message : 'ASR 请求失败');
|
|
|
|
|
|
console.warn('[asr] proxy failed', message);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
return deps.sendError(res, req, 502, 'asr_failed', message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|