import express from "express"; import fetch, { Headers } from "node-fetch"; import cors from "cors"; import dotenv from "dotenv"; import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; dotenv.config(); const apiApp = express(); const port = Number.parseInt(process.env.PORT ?? "5174", 10); const submitUrl = process.env.DOU_BAO_SUBMIT_URL ?? "https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit"; const resultUrl = process.env.DOU_BAO_RESULT_URL ?? "https://openspeech.bytedance.com/api/v3/auc/bigmodel/query"; const shouldServeStatic = true; apiApp.use(cors()); apiApp.use(express.json({ limit: "100mb" })); // 基础访问日志,便于定位请求是否到达后端 apiApp.use((req, _res, next) => { console.log(`[incoming] ${req.method} ${req.path}`); next(); }); // 同端口托管静态页面 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const staticRoot = path.resolve(__dirname, ".."); apiApp.use(express.static(staticRoot)); apiApp.get(["/", "/index.html"], (req, res) => { res.sendFile(path.resolve(staticRoot, "index.html")); }); function assertApiKeyConfigured() { if (!process.env.DOU_BAO_API_KEY) { throw new Error("服务器未配置 DOU_BAO_API_KEY,无法向豆包接口鉴权"); } } function createUpstreamHeaders(overrideRequestId) { assertApiKeyConfigured(); const headers = new Headers(); const requestId = overrideRequestId || crypto.randomUUID(); headers.set("Content-Type", "application/json"); headers.set("x-api-key", process.env.DOU_BAO_API_KEY); headers.set("X-Api-Resource-Id", process.env.DOU_BAO_RESOURCE_ID ?? "volc.bigasr.auc"); headers.set("X-Api-Request-Id", requestId); headers.set("X-Api-Sequence", "-1"); return { headers, requestId }; } function summarizeSubmitPayload(payload) { if (!payload) { return {}; } const audioDataLength = typeof payload?.audio?.data === "string" ? payload.audio.data.length : 0; return { hasAudioData: audioDataLength > 0, audioDataLength, audioFormat: payload?.audio?.format, audioCodec: payload?.audio?.codec, sampleRate: payload?.audio?.rate, bits: payload?.audio?.bits, channel: payload?.audio?.channel, hasAudioUrl: typeof payload?.audio?.url === "string", modelName: payload?.request?.model_name, uid: payload?.user?.uid }; } function pickTranscript(obj) { const segments = obj?.data?.result?.segments || obj?.result?.segments; if (Array.isArray(segments) && segments.length > 0) { const texts = segments .map(s => (typeof s?.text === 'string' && s.text.trim()) || (typeof s?.transcript === 'string' && s.transcript.trim()) || '') .filter(Boolean); if (texts.length) return texts.join('\n'); } const candidates = [ obj?.data?.result?.text, obj?.result?.text, obj?.result?.output?.choices?.[0]?.text, obj?.result?.output?.transcript, obj?.result?.transcript, obj?.data?.output?.choices?.[0]?.text, obj?.data?.transcript, obj?.text, obj?.transcript ]; return candidates.find(v => typeof v === 'string' && v.trim().length > 0) || null; } async function forwardJson({ url, payload, logLabel, overrideRequestId }) { const { headers, requestId } = createUpstreamHeaders(overrideRequestId); const payloadSummary = summarizeSubmitPayload(payload); console.log(`[${logLabel}] 转发至 ${url}`, payloadSummary, { request: payload?.request }); const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); const rawText = await response.text(); let parsed; try { parsed = JSON.parse(rawText); } catch (error) { parsed = { message: "解析豆包响应失败", raw: rawText }; } // 尝试兼容更多返回字段,并附带原始响应便于前端调试 const normalized = { ...parsed, __upstream: { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers.entries()), rawText, requestId }, result: parsed.result ?? parsed.data ?? parsed ?? {}, data: parsed.data ?? parsed.result ?? parsed ?? {} }; // 若上游未返回任务ID,使用本次 requestId 作为回退,确保前端能继续轮询 if (!normalized.result.job_key && !normalized.data.job_key) { normalized.result.job_key = requestId; normalized.data.job_key = requestId; } // 不在服务端强制注入 transcript,避免前端“抢先以纯文本结束轮询” // const transcript = pickTranscript(normalized) || null; // if (transcript) normalized.transcript = transcript; console.log(`[${logLabel}] 响应 ${response.status}`, normalized); return { status: response.status, body: normalized }; } apiApp.post("/api/asr/submit", async (req, res) => { try { if (!req.body || Object.keys(req.body).length === 0) { return res.status(400).json({ message: "请求体为空,无法提交任务" }); } const result = await forwardJson({ url: submitUrl, payload: req.body, logLabel: "submit" }); return res.status(result.status).json(result.body); } catch (error) { console.error("[submit]", error); const message = error instanceof Error ? error.message : String(error); return res.status(500).json({ message: "提交任务失败", error: message }); } }); apiApp.post("/api/asr/result", async (req, res) => { try { if (!req.body || !req.body.job_key) { return res.status(400).json({ message: "缺少 job_key,无法查询任务" }); } const jobKey = String(req.body.job_key || req.body.taskId || req.body.task_id || ""); if (!jobKey) { return res.status(400).json({ message: "缺少 job_key,无法查询任务" }); } // 按官方示例,query 使用 X-Api-Request-Id 作为查询键,body 可为空 const result = await forwardJson({ url: resultUrl, payload: {}, logLabel: "result", overrideRequestId: jobKey }); return res.status(result.status).json(result.body); } catch (error) { console.error("[result]", error); const message = error instanceof Error ? error.message : String(error); return res.status(500).json({ message: "查询任务失败", error: message }); } }); apiApp.get("/health", (req, res) => { res.json({ status: "ok", submitUrl, resultUrl, hasApiKey: Boolean(process.env.DOU_BAO_API_KEY) }); }); const apiServer = apiApp.listen(port, "0.0.0.0", () => { console.log(`豆包 ASR 代理服务已启动,端口 ${port}`); }); // Keep-Alive:定期自我心跳,支持工作时段窗口控制 const KEEP_ALIVE_MS = Number.parseInt(process.env.KEEP_ALIVE_MS ?? "300000", 10); // 默认5分钟 const KEEP_ALIVE_WINDOW = process.env.KEEP_ALIVE_WINDOW || "08:00-22:00"; // 形如 "08:00-22:00" const KEEP_ALIVE_TZ = process.env.KEEP_ALIVE_TZ || process.env.TZ || "Asia/Shanghai"; function withinKeepAliveWindow(now = new Date()){ try{ // 获取目标时区的小时与分钟 const parts = new Intl.DateTimeFormat('en-GB', { timeZone: KEEP_ALIVE_TZ, hour12: false, hour: '2-digit', minute: '2-digit' }) .formatToParts(now); const hh = Number(parts.find(p=>p.type==='hour')?.value || '0'); const mm = Number(parts.find(p=>p.type==='minute')?.value || '0'); const cur = hh * 60 + mm; const [startStr, endStr] = KEEP_ALIVE_WINDOW.split('-'); const [sh, sm] = (startStr||'08:00').split(':').map(n=>Number(n)||0); const [eh, em] = (endStr||'22:00').split(':').map(n=>Number(n)||0); const start = sh * 60 + sm; const end = eh * 60 + em; if (start === end) return true; // 全时段 if (start < end) return cur >= start && cur < end; // 正常区间 // 跨午夜区间 return cur >= start || cur < end; }catch(_){ return true; } } if (KEEP_ALIVE_MS > 0) { setInterval(async () => { if (!withinKeepAliveWindow()) return; try { const resp = await fetch(`http://127.0.0.1:${port}/health`); process.stdout.write("."); await resp.text().catch(()=>{}); } catch (e) { console.log("[keep-alive] ping 失败", e?.message || e); } }, KEEP_ALIVE_MS); } // 已在同端口托管静态页面,无需单独 staticApp process.on("SIGINT", () => { apiServer.close(() => { console.log("代理服务已关闭"); process.exit(0); }); });