/** * 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;