229 lines
8.2 KiB
JavaScript
229 lines
8.2 KiB
JavaScript
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);
|
||
});
|
||
});
|