http-app.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { createHash, randomUUID } from 'node:crypto';
  2. import { AppError, publicError } from './errors.mjs';
  3. import { publicJob } from './job-store.mjs';
  4. import { sameOwner } from './auth.mjs';
  5. import { validateRemoteAudioUrl } from './url-security.mjs';
  6. function sendJson(response, status, body) {
  7. const payload = JSON.stringify(body);
  8. response.writeHead(status, {
  9. 'Content-Type': 'application/json; charset=utf-8',
  10. 'Content-Length': Buffer.byteLength(payload),
  11. 'Cache-Control': 'no-store',
  12. 'X-Content-Type-Options': 'nosniff',
  13. });
  14. response.end(payload);
  15. }
  16. async function readJson(request, maxBytes = 32 * 1024) {
  17. const chunks = [];
  18. let size = 0;
  19. for await (const chunk of request) {
  20. size += chunk.length;
  21. if (size > maxBytes) {
  22. throw new AppError('请求体过大', { code: 'REQUEST_TOO_LARGE', status: 413 });
  23. }
  24. chunks.push(chunk);
  25. }
  26. if (!chunks.length) return {};
  27. try {
  28. return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  29. } catch {
  30. throw new AppError('请求体必须是 JSON', { code: 'INVALID_JSON', status: 400 });
  31. }
  32. }
  33. function normalizedRequest(body) {
  34. const durationMs = Number(body?.durationMs);
  35. const roleNum = Number(body?.roleNum);
  36. const roleType = Number(body?.roleType);
  37. return {
  38. audioUrl: String(body?.audioUrl || '').trim(),
  39. chatVoiceId: String(body?.chatVoiceId || '').trim().slice(0, 64) || null,
  40. durationMs: Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs) : null,
  41. roleType: [0, 1].includes(roleType) ? roleType : 1,
  42. roleNum: Number.isFinite(roleNum) ? Math.max(0, Math.min(10, Math.floor(roleNum))) : 0,
  43. };
  44. }
  45. function idempotencyKey(ownerKey, requestId) {
  46. const value = String(requestId || '').trim();
  47. if (!value) return null;
  48. return createHash('sha256').update(`${ownerKey}:${value.slice(0, 200)}`).digest('hex');
  49. }
  50. function routeJobId(pathname) {
  51. const match = /^\/recording-transcription\/jobs\/([0-9a-f-]+)(?:\/(cancel|retry))?$/.exec(
  52. pathname,
  53. );
  54. return match ? { id: match[1], action: match[2] || 'get' } : null;
  55. }
  56. function configureCors(request, response, config) {
  57. const origin = String(request.headers.origin || '').trim();
  58. if (!origin) return;
  59. if (!config.corsAllowedOrigins.includes(origin)) {
  60. throw new AppError('当前网页来源不允许访问此服务', {
  61. code: 'ORIGIN_NOT_ALLOWED',
  62. status: 403,
  63. });
  64. }
  65. response.setHeader('Access-Control-Allow-Origin', origin);
  66. response.setHeader('Vary', 'Origin');
  67. response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Idempotency-Key');
  68. response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  69. response.setHeader('Access-Control-Max-Age', '600');
  70. }
  71. export function createHttpHandler({
  72. config,
  73. store,
  74. worker,
  75. authenticator,
  76. persistence = { enabled: false, async validateAccess() {} },
  77. validateAudioUrl = validateRemoteAudioUrl,
  78. }) {
  79. const publicView = job =>
  80. publicJob(job, {
  81. queuePosition:
  82. typeof worker.queuePosition === 'function' ? worker.queuePosition(job.id) : null,
  83. });
  84. return async function handle(request, response) {
  85. const requestId = randomUUID();
  86. response.setHeader('X-Request-Id', requestId);
  87. try {
  88. configureCors(request, response, config);
  89. if (request.method === 'OPTIONS') {
  90. response.writeHead(204);
  91. response.end();
  92. return;
  93. }
  94. const url = new URL(request.url || '/', 'http://localhost');
  95. if (request.method === 'GET' && url.pathname === '/health') {
  96. sendJson(response, 200, {
  97. success: true,
  98. service: 'yuban-server',
  99. status: 'ok',
  100. version: config.serviceVersion || 'unknown',
  101. gitSha: config.gitSha || null,
  102. workerReady: true,
  103. storeReady: true,
  104. capabilities: ['recording-transcription'],
  105. time: new Date().toISOString(),
  106. });
  107. return;
  108. }
  109. const identity = await authenticator.authenticate(request);
  110. if (request.method === 'POST' && url.pathname === '/recording-transcription/jobs') {
  111. const body = await readJson(request);
  112. const input = normalizedRequest(body);
  113. await validateAudioUrl(input.audioUrl, config.allowedAudioHosts);
  114. await persistence.validateAccess(input.chatVoiceId, request.headers.authorization);
  115. const key = idempotencyKey(
  116. identity.ownerKey,
  117. request.headers['idempotency-key'] || body.requestId,
  118. );
  119. const existing = key ? store.findByIdempotency(key) : null;
  120. if (
  121. existing &&
  122. !['failed', 'cancelled'].includes(existing.status) &&
  123. sameOwner(existing.ownerKey, identity.ownerKey)
  124. ) {
  125. sendJson(response, 202, { success: true, reused: true, job: publicView(existing) });
  126. return;
  127. }
  128. const ownerJobs = store.listByOwner(identity.ownerKey);
  129. const activeJobs = ownerJobs.filter(job => ['queued', 'running'].includes(job.status));
  130. if (activeJobs.length >= config.maxActiveJobsPerUser) {
  131. throw new AppError('当前正在处理的长录音任务过多,请等待已有任务完成', {
  132. code: 'TOO_MANY_ACTIVE_JOBS',
  133. status: 429,
  134. retryable: true,
  135. });
  136. }
  137. if (ownerJobs.length >= config.maxRetainedJobsPerUser) {
  138. throw new AppError('临时任务数量已达上限,请稍后再试', {
  139. code: 'JOB_LIMIT_REACHED',
  140. status: 429,
  141. retryable: true,
  142. });
  143. }
  144. const now = new Date();
  145. const job = await store.create({
  146. id: randomUUID(),
  147. ownerKey: identity.ownerKey,
  148. idempotencyKey: key,
  149. status: 'queued',
  150. stage: 'queued',
  151. progress: 0,
  152. attempt: 0,
  153. runAttempt: 0,
  154. request: input,
  155. createdAt: now.toISOString(),
  156. updatedAt: now.toISOString(),
  157. expiresAt: new Date(now.getTime() + config.jobRetentionMs).toISOString(),
  158. });
  159. worker.enqueue(job.id);
  160. console.info('[yuban-server] transcription job created', {
  161. requestId,
  162. jobId: job.id,
  163. durationMs: input.durationMs,
  164. });
  165. sendJson(response, 202, { success: true, reused: false, job: publicView(job) });
  166. return;
  167. }
  168. const jobRoute = routeJobId(url.pathname);
  169. if (jobRoute && request.method === 'GET' && jobRoute.action === 'get') {
  170. const job = store.get(jobRoute.id);
  171. if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
  172. throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
  173. }
  174. sendJson(response, 200, { success: true, job: publicView(job) });
  175. return;
  176. }
  177. if (jobRoute && request.method === 'POST' && jobRoute.action === 'cancel') {
  178. const job = store.get(jobRoute.id);
  179. if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
  180. throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
  181. }
  182. const cancelled = await worker.cancel(job.id);
  183. sendJson(response, 200, { success: true, job: publicView(cancelled) });
  184. return;
  185. }
  186. if (jobRoute && request.method === 'POST' && jobRoute.action === 'retry') {
  187. const job = store.get(jobRoute.id);
  188. if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
  189. throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
  190. }
  191. const activeJobs = store
  192. .listByOwner(identity.ownerKey)
  193. .filter(item => ['queued', 'running', 'retry_wait'].includes(item.status));
  194. if (activeJobs.length >= config.maxActiveJobsPerUser) {
  195. throw new AppError('当前正在处理的长录音任务过多,请等待已有任务完成', {
  196. code: 'TOO_MANY_ACTIVE_JOBS',
  197. status: 429,
  198. retryable: true,
  199. });
  200. }
  201. const retried = await worker.retry(job.id);
  202. sendJson(response, 202, { success: true, reused: false, job: publicView(retried) });
  203. return;
  204. }
  205. throw new AppError('接口不存在', { code: 'NOT_FOUND', status: 404 });
  206. } catch (error) {
  207. const result = publicError(error);
  208. if (!(error instanceof AppError) || error.status >= 500) {
  209. console.error('[yuban-server] request failed', {
  210. requestId,
  211. method: request.method,
  212. path: request.url,
  213. code: error?.code || 'INTERNAL_ERROR',
  214. message: error?.message || 'unknown',
  215. });
  216. }
  217. if (!response.headersSent) sendJson(response, result.status, result.body);
  218. else response.end();
  219. }
  220. };
  221. }