275 lines
8.8 KiB
JavaScript
275 lines
8.8 KiB
JavaScript
/**
|
|
* HTTP 层:上传 → 队列 → SSE 进度 → 结果。
|
|
*
|
|
* 密钥处理约定(部署到公网的前提):
|
|
* Key 由调用方通过 X-Provider-Key 头传入,只在本次请求内存中存活,
|
|
* 随请求转发给上游后即丢弃。不写日志、不落盘、不进任务状态、不回显。
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdir, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import express from 'express';
|
|
import multer from 'multer';
|
|
|
|
import config from './config.js';
|
|
import * as ff from './ffmpeg.js';
|
|
import { DEFAULT_PROVIDER, PROVIDERS, PROVIDER_NAMES, getProvider } from './providers.js';
|
|
import * as queue from './queue.js';
|
|
import { TranscribeError, transcribe } from './transcribe.js';
|
|
|
|
const app = express();
|
|
app.disable('x-powered-by');
|
|
|
|
await mkdir(config.tmpDir, { recursive: true });
|
|
|
|
/* ---------------- 上传 ---------------- */
|
|
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination(req, file, cb) {
|
|
const dir = path.join(config.tmpDir, req.jobId);
|
|
mkdir(dir, { recursive: true }).then(() => cb(null, dir), cb);
|
|
},
|
|
filename(req, file, cb) {
|
|
const ext = path.extname(file.originalname || '').toLowerCase();
|
|
cb(null, `source${config.allowedExtensions.has(ext) ? ext : '.bin'}`);
|
|
},
|
|
}),
|
|
limits: {
|
|
fileSize: config.limits.maxUploadBytes,
|
|
files: 1,
|
|
fields: 10,
|
|
},
|
|
fileFilter(req, file, cb) {
|
|
const ext = path.extname(file.originalname || '').toLowerCase();
|
|
if (!config.allowedExtensions.has(ext)) {
|
|
cb(new TranscribeError(
|
|
`不支持的扩展名 ${ext || '(无)'},支持:`
|
|
+ [...config.allowedExtensions].join(' '), { status: 400 }));
|
|
return;
|
|
}
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
/* ---------------- 工具 ---------------- */
|
|
|
|
function clientIp(req) {
|
|
const fwd = req.headers['x-forwarded-for'];
|
|
if (typeof fwd === 'string' && fwd.trim()) return fwd.split(',')[0].trim();
|
|
return req.socket?.remoteAddress || 'unknown';
|
|
}
|
|
|
|
function sendError(res, err) {
|
|
const status = err?.status && Number.isInteger(err.status) ? err.status : 500;
|
|
const payload = {
|
|
error: err?.message || '服务器内部错误',
|
|
code: err?.code || undefined,
|
|
detail: err?.detail || undefined,
|
|
};
|
|
if (status >= 500) {
|
|
// 只在服务端记录,不回显给调用方
|
|
console.error('[error]', err?.name, err?.message, err?.code || '');
|
|
}
|
|
if (!res.headersSent) res.status(status).json(payload);
|
|
else res.end();
|
|
}
|
|
|
|
/* ---------------- 静态资源 ---------------- */
|
|
|
|
app.use(express.static(config.publicDir, { extensions: ['html'] }));
|
|
|
|
/* ---------------- 健康检查与能力清单 ---------------- */
|
|
|
|
app.get('/api/health', async (req, res) => {
|
|
const tools = await ff.checkTools();
|
|
res.json({
|
|
ok: tools.ffmpeg && tools.ffprobe,
|
|
tools,
|
|
queue: queue.stats(),
|
|
limits: {
|
|
maxUploadMB: config.limits.maxUploadBytes / 1048576,
|
|
maxDurationMinutes: config.limits.maxDurationSeconds / 60,
|
|
maxConcurrentJobs: config.limits.maxConcurrentJobs,
|
|
maxJobsPerIp: config.limits.maxJobsPerIp,
|
|
maxSubmissionsPerIpHour: config.limits.maxSubmissionsPerIpHour,
|
|
},
|
|
});
|
|
});
|
|
|
|
app.get('/api/providers', (req, res) => {
|
|
res.json({
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
providers: PROVIDER_NAMES.map((name) => {
|
|
const p = PROVIDERS[name];
|
|
return {
|
|
name,
|
|
label: p.label,
|
|
models: p.models,
|
|
defaultModel: p.defaultModel,
|
|
keyUrl: p.keyUrl,
|
|
hint: p.hint,
|
|
note: p.note,
|
|
caps: p.caps,
|
|
maxBytes: p.maxBytes,
|
|
maxSeconds: p.maxSeconds,
|
|
rejectFormats: p.rejectFormats,
|
|
};
|
|
}),
|
|
});
|
|
});
|
|
|
|
/* ---------------- 主端点:上传并转写(SSE 进度) ---------------- */
|
|
|
|
app.post('/api/transcribe', (req, res, next) => {
|
|
req.jobId = randomUUID();
|
|
upload.single('file')(req, res, (err) => {
|
|
if (err) {
|
|
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
|
return sendError(res, new TranscribeError(
|
|
`文件超过服务器上限 ${(config.limits.maxUploadBytes / 1048576).toFixed(0)}MB`,
|
|
{ status: 413 }));
|
|
}
|
|
return sendError(res, err);
|
|
}
|
|
next();
|
|
});
|
|
}, async (req, res) => {
|
|
const ip = clientIp(req);
|
|
const workDir = path.join(config.tmpDir, req.jobId);
|
|
|
|
const cleanupJob = async () => {
|
|
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
};
|
|
|
|
try {
|
|
if (!req.file) throw new TranscribeError('没有收到音频文件', { status: 400 });
|
|
|
|
const providerName = String(req.body?.provider || DEFAULT_PROVIDER).trim();
|
|
const provider = getProvider(providerName);
|
|
|
|
const apiKey = String(req.headers['x-provider-key'] || '').trim();
|
|
if (!apiKey) {
|
|
throw new TranscribeError(
|
|
`缺少 X-Provider-Key 请求头。请在页面填写 ${provider.label} 的 API Key。`,
|
|
{ status: 401 });
|
|
}
|
|
if (apiKey.length > 512) {
|
|
throw new TranscribeError('API Key 长度异常', { status: 400 });
|
|
}
|
|
|
|
const model = String(req.body?.model || provider.defaultModel).trim() || provider.defaultModel;
|
|
const language = String(req.body?.language || 'auto').trim();
|
|
const timestamps = String(req.body?.timestamps || '') === 'true';
|
|
|
|
// 队列与配额护栏
|
|
queue.recordSubmission(ip);
|
|
await queue.enforceDiskQuota();
|
|
|
|
// ---- SSE 响应头 ----
|
|
res.status(200);
|
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no'); // 让 nginx 不要缓冲 SSE
|
|
res.flushHeaders?.();
|
|
|
|
let closed = false;
|
|
const controller = new AbortController();
|
|
req.on('close', () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
controller.abort();
|
|
});
|
|
|
|
const send = (evt) => {
|
|
if (closed) return;
|
|
try { res.write(`data: ${JSON.stringify(evt)}\n\n`); } catch { closed = true; }
|
|
};
|
|
|
|
send({ type: 'job', jobId: req.jobId, provider: providerName, model,
|
|
queued: queue.stats().queued });
|
|
|
|
const result = await queue.submit(ip, () => transcribe({
|
|
filePath: req.file.path,
|
|
workDir,
|
|
providerName,
|
|
model,
|
|
language,
|
|
apiKey,
|
|
timestamps,
|
|
signal: controller.signal,
|
|
onProgress: send,
|
|
limits: config.limits,
|
|
}));
|
|
|
|
send({ type: 'result', ...result });
|
|
closed = true;
|
|
res.end();
|
|
} catch (err) {
|
|
if (res.headersSent) {
|
|
// SSE 已开始,错误只能走流里回传。但服务端也必须留日志——
|
|
// 否则上游 5xx 的具体响应体查不到,排查只能靠猜。
|
|
console.error(
|
|
`[job ${req.jobId}] 失败 provider=${req.body?.provider || '?'}`
|
|
+ ` status=${err?.status ?? '?'} code=${err?.code || '-'}`
|
|
+ ` :: ${err?.message || err}`
|
|
+ (err?.detail ? `\n 上游响应: ${err.detail}` : '')
|
|
);
|
|
try {
|
|
res.write(`data: ${JSON.stringify({
|
|
type: 'error',
|
|
error: err?.message || '转写失败',
|
|
code: err?.code || undefined,
|
|
detail: err?.detail || undefined,
|
|
})}\n\n`);
|
|
} catch { /* 连接已断 */ }
|
|
res.end();
|
|
} else {
|
|
sendError(res, err);
|
|
}
|
|
} finally {
|
|
// 密钥不在任何持久化路径上,随请求对象一起被回收
|
|
if (req.file) delete req.file;
|
|
cleanupJob();
|
|
}
|
|
});
|
|
|
|
/* ---------------- 兜底 ---------------- */
|
|
|
|
app.use((req, res) => {
|
|
res.status(404).json({ error: '未找到该路径' });
|
|
});
|
|
|
|
app.use((err, req, res, _next) => sendError(res, err));
|
|
|
|
/* ---------------- 启动 ---------------- */
|
|
|
|
const server = app.listen(config.port, config.host, async () => {
|
|
const tools = await ff.checkTools();
|
|
queue.startSweeper();
|
|
console.log(`声转文服务已启动: http://${config.host}:${config.port}`);
|
|
if (config.envFile) console.log(` 配置来源: ${config.envFile}`);
|
|
console.log(` 上传上限: ${(config.limits.maxUploadBytes / 1048576).toFixed(0)}MB`
|
|
+ ` · 时长上限: ${(config.limits.maxDurationSeconds / 60).toFixed(0)}分钟`);
|
|
console.log(` 全局并发: ${config.limits.maxConcurrentJobs}`
|
|
+ ` · 单IP并发: ${config.limits.maxJobsPerIp}`
|
|
+ ` · 单IP每小时: ${config.limits.maxSubmissionsPerIpHour}`);
|
|
console.log(` ffmpeg: ${tools.ffmpeg ? tools.ffmpegVersion : '未找到(切块将不可用)'}`);
|
|
console.log(` ffprobe: ${tools.ffprobe ? tools.ffprobeVersion : '未找到'}`);
|
|
if (!tools.ffmpeg || !tools.ffprobe) {
|
|
console.warn(' ⚠️ ffmpeg/ffprobe 不可用:只能处理单发范围内且容器兼容的音频');
|
|
}
|
|
});
|
|
|
|
function shutdown() {
|
|
server.close(() => process.exit(0));
|
|
setTimeout(() => process.exit(0), 5000).unref();
|
|
}
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
|
|
export default app;
|