web-index/doubao-asr-demo/server/index.js

229 lines
8.2 KiB
JavaScript
Raw Normal View History

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);
});
});