Browse Source

feat: initialize yuban business server

彭峰 1 month ago
commit
64a0c17a7e

+ 7 - 0
.dockerignore

@@ -0,0 +1,7 @@
+.git
+.env
+var
+tmp
+test
+node_modules
+*.log

+ 35 - 0
.env.example

@@ -0,0 +1,35 @@
+NODE_ENV=production
+HOST=0.0.0.0
+PORT=3200
+
+# 浏览器来源白名单,逗号分隔;上线前替换成真实前端域名。
+CORS_ALLOWED_ORIGINS=https://your-web-app.example.com,http://localhost:4200
+
+# 仅允许从这些对象存储域名下载录音。支持 *.example.com 形式。
+ALLOWED_AUDIO_HOSTS=file.yuban.co
+MAX_REMOTE_AUDIO_BYTES=524288000
+REMOTE_DOWNLOAD_TIMEOUT_MS=300000
+
+# 旧 Parse 仅用于校验现有 Session Token,不读取或写入业务表。
+AUTH_MODE=parse
+PARSE_SERVER_URL=https://server.fmode.cn/parse
+PARSE_APP_ID=ncloudmaster
+AUTH_CACHE_TTL_MS=60000
+
+# 讯飞长语音转写 IST 凭据。只能配置在新服务器环境变量中。
+IFLYTEK_APP_ID=
+IFLYTEK_API_KEY=
+IFLYTEK_API_SECRET=
+IFLYTEK_IST_LANGUAGE=autodialect
+IFLYTEK_UPLOAD_URL=https://office-api-ist-dx.iflyaisol.com/v2/upload
+IFLYTEK_RESULT_URL=https://office-api-ist-dx.iflyaisol.com/v2/getResult
+
+FFMPEG_PATH=ffmpeg
+FFPROBE_PATH=ffprobe
+JOB_STORE_DIR=./var/jobs
+JOB_TEMP_DIR=./tmp
+JOB_RETENTION_MS=86400000
+MAX_JOB_CONCURRENCY=1
+MAX_ACTIVE_JOBS_PER_USER=2
+MAX_RETAINED_JOBS_PER_USER=20
+IFLYTEK_POLL_TIMEOUT_MS=2400000

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+.env
+var/
+tmp/
+node_modules/
+.DS_Store
+*.log

+ 16 - 0
Dockerfile

@@ -0,0 +1,16 @@
+FROM node:22-bookworm-slim
+
+RUN apt-get update \
+  && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
+  && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY --chown=node:node package.json ./
+COPY --chown=node:node src ./src
+COPY --chown=node:node openapi.yaml README.md ./
+RUN mkdir -p /app/var/jobs /app/tmp && chown -R node:node /app/var /app/tmp
+
+USER node
+ENV NODE_ENV=production HOST=0.0.0.0 PORT=3200
+EXPOSE 3200
+CMD ["node", "src/index.mjs"]

+ 164 - 0
README.md

@@ -0,0 +1,164 @@
+# yuban-server
+
+`yuban-server` 是自传语伴新增业务能力的独立 Node.js 服务。它只承载旧服务器尚未实现、且不负责正式数据读写的业务接口。
+
+首个能力是“历史长录音异步转写”:浏览器提交旧对象存储中的录音 URL,新服务临时下载并转为 16kHz 单声道 PCM WAV,调用讯飞 IST,最后返回完整正文和说话人分段。浏览器再通过原有 `server.fmode.cn`/Parse 接口把结果保存到 `ChatVoice`。
+
+## 边界
+
+- 不查询或写入 `ChatVoice`、`ChatSession`、`Article`、会员、订单等业务表。
+- 不替代旧服务器中已经正常运行的接口。
+- 只调用旧 Parse 的 `/users/me` 校验现有 Session Token;不会实现第二套用户系统。
+- 转写任务和正文仅作为本机临时运行状态保存,默认 24 小时后删除。
+- 日志不打印录音 URL、Session Token、逐字稿正文或讯飞原始结果。
+- 默认每个用户最多同时运行 2 个任务、保留 20 个临时任务,避免合法账号误触发任务风暴。
+
+```mermaid
+sequenceDiagram
+    participant Web as Angular 前端
+    participant Old as 旧服务器 / Parse
+    participant New as yuban-server
+    participant OSS as 对象存储
+    participant ASR as 讯飞 IST
+
+    Web->>Old: 读取 ChatVoice.audioUrl
+    Web->>New: POST /recording-transcription/jobs
+    New->>Old: /users/me 校验 Session Token
+    New-->>Web: 202 + jobId
+    New->>OSS: 临时下载录音
+    New->>New: ffmpeg 转码
+    New->>ASR: 提交并轮询转写
+    Web->>New: GET /recording-transcription/jobs/:jobId
+    New-->>Web: 完整 text + segments + SHA-256
+    Web->>Old: 使用原接口保存 ChatVoice 权威逐字稿
+```
+
+## 接口
+
+### `GET /health`
+
+健康检查,不返回密钥或任务内容。
+
+### `POST /recording-transcription/jobs`
+
+创建异步任务,正常情况下立即返回 HTTP `202`。
+
+```json
+{
+  "audioUrl": "https://file.yuban.co/path/recording.mp3",
+  "durationMs": 3600000,
+  "roleType": 1,
+  "roleNum": 0,
+  "requestId": "chatVoice-object-id"
+}
+```
+
+请求头:
+
+```text
+Authorization: Bearer <现有 Parse Session Token>
+Idempotency-Key: <ChatVoice objectId 或稳定请求 ID>
+Content-Type: application/json
+```
+
+响应:
+
+```json
+{
+  "success": true,
+  "reused": false,
+  "job": {
+    "id": "7d84a8db-4b55-43d8-b9f7-6d20d35c443f",
+    "status": "queued",
+    "stage": "queued",
+    "progress": 0,
+    "createdAt": "2026-08-05T09:00:00.000Z",
+    "updatedAt": "2026-08-05T09:00:00.000Z",
+    "heartbeatAt": null,
+    "expiresAt": "2026-08-06T09:00:00.000Z"
+  }
+}
+```
+
+### `GET /recording-transcription/jobs/:jobId`
+
+查询任务。`completed` 时增加:
+
+```json
+{
+  "result": {
+    "text": "完整逐字稿……",
+    "segments": [
+      {
+        "text": "片段正文",
+        "startMs": 0,
+        "endMs": 1200,
+        "speakerId": "1"
+      }
+    ],
+    "charCount": 15732,
+    "sha256": "..."
+  }
+}
+```
+
+任务状态为 `queued`、`running`、`completed`、`failed` 或 `cancelled`;阶段为 `queued`、`downloading`、`transcoding`、`submitting`、`transcribing`、`completed`、`failed`、`cancelled`。
+
+### `POST /recording-transcription/jobs/:jobId/cancel`
+
+取消尚未完成的任务。讯飞侧可能已经收到音频,但新服务会停止继续轮询,也不会返回逐字稿。
+
+完整契约见 [openapi.yaml](./openapi.yaml)。
+
+## 本地运行
+
+要求:Node.js 22+、`ffmpeg`、`ffprobe`。
+
+```bash
+cp .env.example .env
+# 填写讯飞凭据和前端来源白名单
+npm test
+npm start
+```
+
+服务默认监听 `http://127.0.0.1:3200`(生产示例使用 `0.0.0.0:3200`)。本项目仅使用 Node 内置模块,不需要安装 npm 运行依赖。
+
+开发环境若暂时不连接旧 Parse,可使用 `NODE_ENV=development AUTH_MODE=disabled`;生产环境会拒绝关闭认证。
+
+## 生产部署
+
+推荐使用 Docker:
+
+```bash
+docker build -t yuban-server:1.0.0 .
+docker run -d \
+  --name yuban-server \
+  --restart unless-stopped \
+  --env-file .env \
+  -p 127.0.0.1:3200:3200 \
+  -v yuban-server-jobs:/app/var/jobs \
+  yuban-server:1.0.0
+```
+
+`deploy/nginx.conf.example` 展示了 HTTPS 反向代理配置。不要让 Node 端口直接暴露到公网。生产环境至少要完成:
+
+1. 配置 HTTPS 域名,例如 `business-api.yuban.co`。
+2. 把 `CORS_ALLOWED_ORIGINS` 限制为真实 Angular 域名。
+3. 保持 `ALLOWED_AUDIO_HOSTS=file.yuban.co` 或更窄。
+4. 仅在服务器环境变量中保存讯飞密钥。
+5. 监控 `/health`、任务失败率、磁盘容量和 ffmpeg/ffprobe 可用性。
+
+## 前端接入
+
+只为历史长录音重转写配置新服务地址:
+
+```html
+<script>
+  window.__YUBAN_RUNTIME_CONFIG__ = {
+    ...(window.__YUBAN_RUNTIME_CONFIG__ || {}),
+    recordingTranscriptionBusinessApiBaseUrl: 'https://business-api.yuban.co'
+  };
+</script>
+```
+
+旧服务器地址不需要更改。实时录音、已有长语音上传、数据保存、故事生成等现有链路仍走原接口。

+ 20 - 0
deploy/nginx.conf.example

@@ -0,0 +1,20 @@
+server {
+    listen 443 ssl http2;
+    server_name business-api.yuban.co;
+
+    ssl_certificate /etc/letsencrypt/live/business-api.yuban.co/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/business-api.yuban.co/privkey.pem;
+
+    client_max_body_size 64k;
+
+    location / {
+        proxy_pass http://127.0.0.1:3200;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_connect_timeout 10s;
+        proxy_read_timeout 60s;
+    }
+}

+ 23 - 0
deploy/yuban-server.service

@@ -0,0 +1,23 @@
+[Unit]
+Description=Yuban stateless business capability API
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=yuban
+Group=yuban
+WorkingDirectory=/opt/yuban-server
+EnvironmentFile=/etc/yuban-server.env
+ExecStart=/usr/bin/node /opt/yuban-server/src/index.mjs
+Restart=always
+RestartSec=5
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/opt/yuban-server/var /opt/yuban-server/tmp
+LimitNOFILE=65535
+
+[Install]
+WantedBy=multi-user.target

+ 181 - 0
openapi.yaml

@@ -0,0 +1,181 @@
+openapi: 3.1.0
+info:
+  title: yuban-server business capability API
+  version: 1.0.0
+servers:
+  - url: https://business-api.yuban.co
+paths:
+  /health:
+    get:
+      operationId: health
+      responses:
+        '200':
+          description: Service is healthy
+  /recording-transcription/jobs:
+    post:
+      operationId: createRecordingTranscriptionJob
+      security:
+        - bearerAuth: []
+      parameters:
+        - in: header
+          name: Idempotency-Key
+          schema:
+            type: string
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/CreateRecordingTranscriptionJob'
+      responses:
+        '202':
+          description: Job accepted
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/JobResponse'
+  /recording-transcription/jobs/{jobId}:
+    parameters:
+      - $ref: '#/components/parameters/JobId'
+    get:
+      operationId: getRecordingTranscriptionJob
+      security:
+        - bearerAuth: []
+      responses:
+        '200':
+          description: Current job state
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/JobResponse'
+  /recording-transcription/jobs/{jobId}/cancel:
+    parameters:
+      - $ref: '#/components/parameters/JobId'
+    post:
+      operationId: cancelRecordingTranscriptionJob
+      security:
+        - bearerAuth: []
+      responses:
+        '200':
+          description: Job cancelled or already terminal
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/JobResponse'
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      scheme: bearer
+  parameters:
+    JobId:
+      in: path
+      name: jobId
+      required: true
+      schema:
+        type: string
+        format: uuid
+  schemas:
+    CreateRecordingTranscriptionJob:
+      type: object
+      required: [audioUrl]
+      additionalProperties: false
+      properties:
+        audioUrl:
+          type: string
+          format: uri
+        durationMs:
+          type: integer
+          minimum: 1
+        roleType:
+          type: integer
+          enum: [0, 1]
+          default: 1
+        roleNum:
+          type: integer
+          minimum: 0
+          maximum: 10
+          default: 0
+        requestId:
+          type: string
+          maxLength: 200
+    JobResponse:
+      type: object
+      required: [success, job]
+      properties:
+        success:
+          type: boolean
+          const: true
+        reused:
+          type: boolean
+        job:
+          $ref: '#/components/schemas/Job'
+    Job:
+      type: object
+      required: [id, status, stage, progress, createdAt, updatedAt, expiresAt]
+      properties:
+        id:
+          type: string
+          format: uuid
+        status:
+          type: string
+          enum: [queued, running, completed, failed, cancelled]
+        stage:
+          type: string
+        progress:
+          type: integer
+          minimum: 0
+          maximum: 100
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        heartbeatAt:
+          type: [string, 'null']
+          format: date-time
+        expiresAt:
+          type: string
+          format: date-time
+        result:
+          $ref: '#/components/schemas/TranscriptionResult'
+        error:
+          $ref: '#/components/schemas/Error'
+    TranscriptionResult:
+      type: object
+      required: [text, segments, charCount, sha256]
+      properties:
+        text:
+          type: string
+        segments:
+          type: array
+          items:
+            $ref: '#/components/schemas/Segment'
+        charCount:
+          type: integer
+        sha256:
+          type: string
+          pattern: '^[0-9a-f]{64}$'
+    Segment:
+      type: object
+      required: [text]
+      properties:
+        text:
+          type: string
+        startMs:
+          type: [integer, 'null']
+        endMs:
+          type: [integer, 'null']
+        speakerId:
+          type: [string, 'null']
+    Error:
+      type: object
+      required: [code, message, retryable]
+      properties:
+        code:
+          type: string
+        message:
+          type: string
+        retryable:
+          type: boolean

+ 16 - 0
package-lock.json

@@ -0,0 +1,16 @@
+{
+  "name": "yuban-server",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "yuban-server",
+      "version": "1.0.0",
+      "license": "UNLICENSED",
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    }
+  }
+}

+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "yuban-server",
+  "version": "1.0.0",
+  "description": "Yuban stateless business capability API",
+  "type": "module",
+  "main": "src/index.mjs",
+  "scripts": {
+    "start": "node src/index.mjs",
+    "dev": "NODE_ENV=development node --watch src/index.mjs",
+    "test": "node --test",
+    "check": "node --check src/index.mjs"
+  },
+  "engines": {
+    "node": ">=22.0.0"
+  },
+  "keywords": [
+    "yuban",
+    "business-api",
+    "speech-to-text"
+  ],
+  "author": "",
+  "license": "UNLICENSED",
+  "private": true
+}

+ 85 - 0
src/auth.mjs

@@ -0,0 +1,85 @@
+import { createHash, timingSafeEqual } from 'node:crypto';
+import { AppError } from './errors.mjs';
+
+function sha256(value) {
+  return createHash('sha256').update(String(value)).digest('hex');
+}
+
+function bearerToken(request) {
+  const value = String(request.headers.authorization || '').trim();
+  const match = /^Bearer\s+(.+)$/i.exec(value);
+  return match?.[1]?.trim() || '';
+}
+
+export class Authenticator {
+  constructor(config, fetchImpl = fetch) {
+    this.config = config;
+    this.fetchImpl = fetchImpl;
+    this.cache = new Map();
+  }
+
+  async authenticate(request) {
+    if (this.config.authMode === 'disabled') {
+      return { ownerKey: 'development', userId: 'development' };
+    }
+
+    const token = bearerToken(request);
+    if (!token) {
+      throw new AppError('缺少登录凭证', { code: 'UNAUTHORIZED', status: 401 });
+    }
+    const tokenHash = sha256(token);
+    const cached = this.cache.get(tokenHash);
+    if (cached && cached.expiresAt > Date.now()) return cached.identity;
+
+    let response;
+    try {
+      response = await this.fetchImpl(`${this.config.parseServerUrl}/users/me`, {
+        method: 'GET',
+        headers: {
+          'X-Parse-Application-Id': this.config.parseAppId,
+          'X-Parse-Session-Token': token,
+          Accept: 'application/json',
+        },
+        signal: AbortSignal.timeout(10_000),
+      });
+    } catch {
+      throw new AppError('登录校验服务暂时不可用', {
+        code: 'AUTH_SERVICE_UNAVAILABLE',
+        status: 503,
+        retryable: true,
+      });
+    }
+
+    if (response.status === 401 || response.status === 403 || response.status === 404) {
+      throw new AppError('登录状态已失效', { code: 'UNAUTHORIZED', status: 401 });
+    }
+    if (!response.ok) {
+      throw new AppError('登录校验服务暂时不可用', {
+        code: 'AUTH_SERVICE_UNAVAILABLE',
+        status: 503,
+        retryable: true,
+      });
+    }
+    const user = await response.json();
+    const userId = String(user?.objectId || '').trim();
+    if (!userId) {
+      throw new AppError('登录凭证无效', { code: 'UNAUTHORIZED', status: 401 });
+    }
+    const identity = { ownerKey: sha256(`parse:${userId}`), userId };
+    this.cache.set(tokenHash, {
+      identity,
+      expiresAt: Date.now() + this.config.authCacheTtlMs,
+    });
+    if (this.cache.size > 1_000) {
+      const firstKey = this.cache.keys().next().value;
+      if (firstKey) this.cache.delete(firstKey);
+    }
+    return identity;
+  }
+}
+
+export function sameOwner(expected, actual) {
+  const left = Buffer.from(String(expected || ''));
+  const right = Buffer.from(String(actual || ''));
+  return left.length === right.length && timingSafeEqual(left, right);
+}

+ 82 - 0
src/config.mjs

@@ -0,0 +1,82 @@
+import { resolve } from 'node:path';
+import { AppError } from './errors.mjs';
+
+function list(value) {
+  return String(value || '')
+    .split(',')
+    .map(item => item.trim())
+    .filter(Boolean);
+}
+
+function integer(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
+  const parsed = Number(value);
+  if (!Number.isFinite(parsed)) return fallback;
+  return Math.max(min, Math.min(max, Math.round(parsed)));
+}
+
+function required(name, env) {
+  const value = String(env[name] || '').trim();
+  if (!value) {
+    throw new AppError(`缺少环境变量 ${name}`, {
+      code: 'CONFIGURATION_ERROR',
+      status: 500,
+    });
+  }
+  return value;
+}
+
+export function loadConfig(env = process.env) {
+  const nodeEnv = String(env.NODE_ENV || 'development').trim();
+  const authMode = String(env.AUTH_MODE || (nodeEnv === 'test' ? 'disabled' : 'parse')).trim();
+  if (!['parse', 'disabled'].includes(authMode)) {
+    throw new AppError('AUTH_MODE 只允许 parse 或 disabled', {
+      code: 'CONFIGURATION_ERROR',
+    });
+  }
+  if (nodeEnv === 'production' && authMode === 'disabled') {
+    throw new AppError('生产环境禁止关闭认证', { code: 'CONFIGURATION_ERROR' });
+  }
+
+  const allowedAudioHosts = list(env.ALLOWED_AUDIO_HOSTS || 'file.yuban.co');
+  const corsAllowedOrigins = list(
+    env.CORS_ALLOWED_ORIGINS || 'http://localhost:4200,http://127.0.0.1:4200',
+  );
+  if (!allowedAudioHosts.length) {
+    throw new AppError('ALLOWED_AUDIO_HOSTS 不能为空', { code: 'CONFIGURATION_ERROR' });
+  }
+
+  return {
+    nodeEnv,
+    host: String(env.HOST || '127.0.0.1'),
+    port: integer(env.PORT, 3200, { max: 65535 }),
+    corsAllowedOrigins,
+    allowedAudioHosts,
+    maxRemoteAudioBytes: integer(env.MAX_REMOTE_AUDIO_BYTES, 500 * 1024 * 1024),
+    remoteDownloadTimeoutMs: integer(env.REMOTE_DOWNLOAD_TIMEOUT_MS, 5 * 60_000),
+    authMode,
+    parseServerUrl: String(env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse').replace(/\/+$/, ''),
+    parseAppId: String(env.PARSE_APP_ID || 'ncloudmaster').trim(),
+    authCacheTtlMs: integer(env.AUTH_CACHE_TTL_MS, 60_000),
+    iflytek: {
+      appId: required('IFLYTEK_APP_ID', env),
+      apiKey: required('IFLYTEK_API_KEY', env),
+      apiSecret: required('IFLYTEK_API_SECRET', env),
+      language: String(env.IFLYTEK_IST_LANGUAGE || 'autodialect').trim(),
+      uploadUrl: String(
+        env.IFLYTEK_UPLOAD_URL || 'https://office-api-ist-dx.iflyaisol.com/v2/upload',
+      ).trim(),
+      resultUrl: String(
+        env.IFLYTEK_RESULT_URL || 'https://office-api-ist-dx.iflyaisol.com/v2/getResult',
+      ).trim(),
+      pollTimeoutMs: integer(env.IFLYTEK_POLL_TIMEOUT_MS, 40 * 60_000),
+    },
+    ffmpegPath: String(env.FFMPEG_PATH || 'ffmpeg').trim(),
+    ffprobePath: String(env.FFPROBE_PATH || 'ffprobe').trim(),
+    jobStoreDir: resolve(process.cwd(), String(env.JOB_STORE_DIR || './var/jobs')),
+    jobTempDir: resolve(process.cwd(), String(env.JOB_TEMP_DIR || './tmp')),
+    jobRetentionMs: integer(env.JOB_RETENTION_MS, 24 * 60 * 60_000),
+    maxJobConcurrency: integer(env.MAX_JOB_CONCURRENCY, 1, { max: 4 }),
+    maxActiveJobsPerUser: integer(env.MAX_ACTIVE_JOBS_PER_USER, 2, { max: 10 }),
+    maxRetainedJobsPerUser: integer(env.MAX_RETAINED_JOBS_PER_USER, 20, { max: 200 }),
+  };
+}

+ 36 - 0
src/errors.mjs

@@ -0,0 +1,36 @@
+export class AppError extends Error {
+  constructor(message, { code = 'INTERNAL_ERROR', status = 500, retryable = false } = {}) {
+    super(message);
+    this.name = 'AppError';
+    this.code = code;
+    this.status = status;
+    this.retryable = retryable;
+  }
+}
+
+export function publicError(error) {
+  if (error instanceof AppError) {
+    return {
+      status: error.status,
+      body: {
+        success: false,
+        error: {
+          code: error.code,
+          message: error.message,
+          retryable: error.retryable,
+        },
+      },
+    };
+  }
+  return {
+    status: 500,
+    body: {
+      success: false,
+      error: {
+        code: 'INTERNAL_ERROR',
+        message: '服务暂时不可用',
+        retryable: true,
+      },
+    },
+  };
+}

+ 192 - 0
src/http-app.mjs

@@ -0,0 +1,192 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { AppError, publicError } from './errors.mjs';
+import { publicJob } from './job-store.mjs';
+import { sameOwner } from './auth.mjs';
+import { validateRemoteAudioUrl } from './url-security.mjs';
+
+function sendJson(response, status, body) {
+  const payload = JSON.stringify(body);
+  response.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': Buffer.byteLength(payload),
+    'Cache-Control': 'no-store',
+    'X-Content-Type-Options': 'nosniff',
+  });
+  response.end(payload);
+}
+
+async function readJson(request, maxBytes = 32 * 1024) {
+  const chunks = [];
+  let size = 0;
+  for await (const chunk of request) {
+    size += chunk.length;
+    if (size > maxBytes) {
+      throw new AppError('请求体过大', { code: 'REQUEST_TOO_LARGE', status: 413 });
+    }
+    chunks.push(chunk);
+  }
+  if (!chunks.length) return {};
+  try {
+    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
+  } catch {
+    throw new AppError('请求体必须是 JSON', { code: 'INVALID_JSON', status: 400 });
+  }
+}
+
+function normalizedRequest(body) {
+  const durationMs = Number(body?.durationMs);
+  const roleNum = Number(body?.roleNum);
+  const roleType = Number(body?.roleType);
+  return {
+    audioUrl: String(body?.audioUrl || '').trim(),
+    durationMs: Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs) : null,
+    roleType: [0, 1].includes(roleType) ? roleType : 1,
+    roleNum: Number.isFinite(roleNum) ? Math.max(0, Math.min(10, Math.floor(roleNum))) : 0,
+  };
+}
+
+function idempotencyKey(ownerKey, requestId) {
+  const value = String(requestId || '').trim();
+  if (!value) return null;
+  return createHash('sha256').update(`${ownerKey}:${value.slice(0, 200)}`).digest('hex');
+}
+
+function routeJobId(pathname) {
+  const match = /^\/recording-transcription\/jobs\/([0-9a-f-]+)(\/cancel)?$/.exec(pathname);
+  return match ? { id: match[1], cancel: Boolean(match[2]) } : null;
+}
+
+function configureCors(request, response, config) {
+  const origin = String(request.headers.origin || '').trim();
+  if (!origin) return;
+  if (!config.corsAllowedOrigins.includes(origin)) {
+    throw new AppError('当前网页来源不允许访问此服务', {
+      code: 'ORIGIN_NOT_ALLOWED',
+      status: 403,
+    });
+  }
+  response.setHeader('Access-Control-Allow-Origin', origin);
+  response.setHeader('Vary', 'Origin');
+  response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Idempotency-Key');
+  response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+  response.setHeader('Access-Control-Max-Age', '600');
+}
+
+export function createHttpHandler({
+  config,
+  store,
+  worker,
+  authenticator,
+  validateAudioUrl = validateRemoteAudioUrl,
+}) {
+  return async function handle(request, response) {
+    const requestId = randomUUID();
+    response.setHeader('X-Request-Id', requestId);
+    try {
+      configureCors(request, response, config);
+      if (request.method === 'OPTIONS') {
+        response.writeHead(204);
+        response.end();
+        return;
+      }
+      const url = new URL(request.url || '/', 'http://localhost');
+      if (request.method === 'GET' && url.pathname === '/health') {
+        sendJson(response, 200, {
+          success: true,
+          service: 'yuban-server',
+          status: 'ok',
+          capabilities: ['recording-transcription'],
+          time: new Date().toISOString(),
+        });
+        return;
+      }
+
+      const identity = await authenticator.authenticate(request);
+      if (request.method === 'POST' && url.pathname === '/recording-transcription/jobs') {
+        const body = await readJson(request);
+        const input = normalizedRequest(body);
+        await validateAudioUrl(input.audioUrl, config.allowedAudioHosts);
+        const key = idempotencyKey(
+          identity.ownerKey,
+          request.headers['idempotency-key'] || body.requestId,
+        );
+        const existing = key ? store.findByIdempotency(key) : null;
+        if (
+          existing &&
+          !['failed', 'cancelled'].includes(existing.status) &&
+          sameOwner(existing.ownerKey, identity.ownerKey)
+        ) {
+          sendJson(response, 202, { success: true, reused: true, job: publicJob(existing) });
+          return;
+        }
+
+        const ownerJobs = store.listByOwner(identity.ownerKey);
+        const activeJobs = ownerJobs.filter(job => ['queued', 'running'].includes(job.status));
+        if (activeJobs.length >= config.maxActiveJobsPerUser) {
+          throw new AppError('当前正在处理的长录音任务过多,请等待已有任务完成', {
+            code: 'TOO_MANY_ACTIVE_JOBS',
+            status: 429,
+            retryable: true,
+          });
+        }
+        if (ownerJobs.length >= config.maxRetainedJobsPerUser) {
+          throw new AppError('临时任务数量已达上限,请稍后再试', {
+            code: 'JOB_LIMIT_REACHED',
+            status: 429,
+            retryable: true,
+          });
+        }
+
+        const now = new Date();
+        const job = await store.create({
+          id: randomUUID(),
+          ownerKey: identity.ownerKey,
+          idempotencyKey: key,
+          status: 'queued',
+          stage: 'queued',
+          progress: 0,
+          request: input,
+          createdAt: now.toISOString(),
+          updatedAt: now.toISOString(),
+          expiresAt: new Date(now.getTime() + config.jobRetentionMs).toISOString(),
+        });
+        worker.enqueue(job.id);
+        sendJson(response, 202, { success: true, reused: false, job: publicJob(job) });
+        return;
+      }
+
+      const jobRoute = routeJobId(url.pathname);
+      if (jobRoute && request.method === 'GET' && !jobRoute.cancel) {
+        const job = store.get(jobRoute.id);
+        if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
+          throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
+        }
+        sendJson(response, 200, { success: true, job: publicJob(job) });
+        return;
+      }
+      if (jobRoute && request.method === 'POST' && jobRoute.cancel) {
+        const job = store.get(jobRoute.id);
+        if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
+          throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
+        }
+        const cancelled = await worker.cancel(job.id);
+        sendJson(response, 200, { success: true, job: publicJob(cancelled) });
+        return;
+      }
+      throw new AppError('接口不存在', { code: 'NOT_FOUND', status: 404 });
+    } catch (error) {
+      const result = publicError(error);
+      if (!(error instanceof AppError) || error.status >= 500) {
+        console.error('[yuban-server] request failed', {
+          requestId,
+          method: request.method,
+          path: request.url,
+          code: error?.code || 'INTERNAL_ERROR',
+          message: error?.message || 'unknown',
+        });
+      }
+      if (!response.headersSent) sendJson(response, result.status, result.body);
+      else response.end();
+    }
+  };
+}

+ 162 - 0
src/iflytek-ist-client.mjs

@@ -0,0 +1,162 @@
+import { createHmac, randomBytes } from 'node:crypto';
+import { createReadStream } from 'node:fs';
+import { stat } from 'node:fs/promises';
+import { basename } from 'node:path';
+import { AppError } from './errors.mjs';
+import { parseIflytekResult } from './result-parser.mjs';
+
+function javaUrlEncode(value) {
+  return encodeURIComponent(String(value))
+    .replace(/%20/g, '+')
+    .replace(/!/g, '%21')
+    .replace(/\*/g, '%2A')
+    .replace(/\(/g, '%28')
+    .replace(/\)/g, '%29')
+    .replace(/~/g, '%7E');
+}
+
+function signature(secret, params) {
+  const pairs = Object.keys(params)
+    .filter(key => key !== 'signature' && params[key] !== '' && params[key] != null)
+    .sort()
+    .map(key => `${javaUrlEncode(key)}=${javaUrlEncode(params[key])}`);
+  return createHmac('sha1', Buffer.from(secret, 'utf8')).update(pairs.join('&')).digest('base64');
+}
+
+function queryString(params) {
+  return Object.entries(params)
+    .map(([key, value]) => `${key}=${javaUrlEncode(value)}`)
+    .join('&');
+}
+
+function chinaDateTime(now = new Date()) {
+  const china = new Date(now.getTime() + 8 * 60 * 60_000);
+  return `${china.getUTCFullYear()}-${String(china.getUTCMonth() + 1).padStart(2, '0')}-${String(
+    china.getUTCDate(),
+  ).padStart(2, '0')}T${String(china.getUTCHours()).padStart(2, '0')}:${String(
+    china.getUTCMinutes(),
+  ).padStart(2, '0')}:${String(china.getUTCSeconds()).padStart(2, '0')}+0800`;
+}
+
+function providerError(message, code, { retryable = false } = {}) {
+  const safeMessage = String(message || '讯飞长语音服务调用失败').slice(0, 300);
+  const combined = `${code || ''} ${safeMessage}`.toLowerCase();
+  const quota = /额度|余额|credit|quota/.test(combined);
+  const auth = /权限|鉴权|unauthorized|forbidden/.test(combined);
+  return new AppError(safeMessage, {
+    code: quota ? 'PROVIDER_QUOTA_EXHAUSTED' : auth ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_FAILED',
+    status: quota || auth ? 502 : 502,
+    retryable: retryable || /繁忙|timeout|temporar/.test(combined),
+  });
+}
+
+async function jsonResponse(response) {
+  let data;
+  try {
+    data = await response.json();
+  } catch {
+    throw providerError(`讯飞返回了非 JSON 响应(HTTP ${response.status})`, response.status, {
+      retryable: response.status >= 500,
+    });
+  }
+  if (!response.ok) {
+    throw providerError(data?.descInfo || `讯飞请求失败(HTTP ${response.status})`, data?.code, {
+      retryable: response.status >= 500,
+    });
+  }
+  return data;
+}
+
+export class IflytekIstClient {
+  constructor(config, fetchImpl = fetch) {
+    this.config = config;
+    this.fetchImpl = fetchImpl;
+  }
+
+  commonParams() {
+    return {
+      accessKeyId: this.config.iflytek.apiKey,
+      dateTime: chinaDateTime(),
+      signatureRandom: randomBytes(12).toString('base64url'),
+    };
+  }
+
+  async submit(filePath, { durationMs, roleType = 1, roleNum = 0 }) {
+    const file = await stat(filePath);
+    const params = {
+      appId: this.config.iflytek.appId,
+      ...this.commonParams(),
+      fileSize: String(file.size),
+      fileName: basename(filePath),
+      language: this.config.iflytek.language,
+      duration: String(Math.round(durationMs)),
+      pd: 'com',
+      roleType: String(roleType),
+      roleNum: String(roleNum),
+    };
+    const signed = signature(this.config.iflytek.apiSecret, params);
+    let response;
+    try {
+      response = await this.fetchImpl(`${this.config.iflytek.uploadUrl}?${queryString(params)}`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/octet-stream',
+          'Content-Length': String(file.size),
+          signature: signed,
+        },
+        body: createReadStream(filePath),
+        duplex: 'half',
+        signal: AbortSignal.timeout(10 * 60_000),
+      });
+    } catch {
+      throw providerError('连接讯飞上传接口失败', 'NETWORK', { retryable: true });
+    }
+    const data = await jsonResponse(response);
+    if (String(data?.code) !== '000000') {
+      throw providerError(data?.descInfo, data?.code);
+    }
+    const orderId = String(data?.content?.orderId || '').trim();
+    if (!orderId) throw providerError('讯飞未返回转写订单号', data?.code);
+    return {
+      orderId,
+      estimateTimeMs: Number(data?.content?.taskEstimateTime || 0) || null,
+    };
+  }
+
+  async getResult(orderId) {
+    const params = {
+      ...this.commonParams(),
+      orderId,
+      resultType: 'transfer',
+    };
+    const signed = signature(this.config.iflytek.apiSecret, params);
+    let response;
+    try {
+      response = await this.fetchImpl(`${this.config.iflytek.resultUrl}?${queryString(params)}`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', signature: signed },
+        body: '{}',
+        signal: AbortSignal.timeout(30_000),
+      });
+    } catch {
+      throw providerError('连接讯飞查询接口失败', 'NETWORK', { retryable: true });
+    }
+    const data = await jsonResponse(response);
+    if (String(data?.code) !== '000000') {
+      throw providerError(data?.descInfo, data?.code, { retryable: true });
+    }
+    const info = data?.content?.orderInfo || {};
+    if (Number(info.status) === 4) {
+      const result = parseIflytekResult(data?.content?.orderResult);
+      if (!result.text) throw providerError('讯飞转写完成但正文为空', 'EMPTY_RESULT');
+      return { status: 'completed', result };
+    }
+    if (Number(info.status) === -1) {
+      throw providerError('讯飞长语音转写失败', info.failType);
+    }
+    return {
+      status: Number(info.status) === 3 ? 'processing' : 'pending',
+      estimateTimeMs: Number(data?.content?.taskEstimateTime || 0) || null,
+    };
+  }
+}

+ 46 - 0
src/index.mjs

@@ -0,0 +1,46 @@
+import { createServer } from 'node:http';
+import { loadLocalEnv } from './load-env.mjs';
+
+await loadLocalEnv();
+
+const [{ loadConfig }, { Authenticator }, { IflytekIstClient }, { JobStore }, { TranscriptionWorker }, { createHttpHandler }] =
+  await Promise.all([
+    import('./config.mjs'),
+    import('./auth.mjs'),
+    import('./iflytek-ist-client.mjs'),
+    import('./job-store.mjs'),
+    import('./transcription-worker.mjs'),
+    import('./http-app.mjs'),
+  ]);
+
+const config = loadConfig();
+const store = new JobStore(config.jobStoreDir, config.jobRetentionMs);
+await store.init();
+
+const provider = new IflytekIstClient(config);
+const worker = new TranscriptionWorker({ config, store, provider });
+await worker.start();
+
+const authenticator = new Authenticator(config);
+const server = createServer(createHttpHandler({ config, store, worker, authenticator }));
+const cleanupTimer = setInterval(() => void store.cleanupExpired(), 60 * 60_000);
+cleanupTimer.unref();
+
+server.listen(config.port, config.host, () => {
+  console.log(`[yuban-server] listening on http://${config.host}:${config.port}`);
+  console.log('[yuban-server] capability ready: recording-transcription');
+});
+
+async function shutdown(signal) {
+  console.log(`[yuban-server] received ${signal}, closing listener`);
+  clearInterval(cleanupTimer);
+  server.close(error => {
+    if (error) {
+      console.error('[yuban-server] close failed', error.message);
+      process.exitCode = 1;
+    }
+  });
+}
+
+process.once('SIGTERM', () => void shutdown('SIGTERM'));
+process.once('SIGINT', () => void shutdown('SIGINT'));

+ 103 - 0
src/job-store.mjs

@@ -0,0 +1,103 @@
+import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+export class JobStore {
+  constructor(directory, retentionMs) {
+    this.directory = directory;
+    this.retentionMs = retentionMs;
+    this.jobs = new Map();
+    this.idempotency = new Map();
+  }
+
+  async init() {
+    await mkdir(this.directory, { recursive: true });
+    for (const name of await readdir(this.directory)) {
+      if (!name.endsWith('.json')) continue;
+      try {
+        const job = JSON.parse(await readFile(join(this.directory, name), 'utf8'));
+        if (!job?.id) continue;
+        this.jobs.set(job.id, job);
+        if (job.idempotencyKey) {
+          const current = this.get(this.idempotency.get(job.idempotencyKey));
+          if (!current || Date.parse(job.createdAt || '') >= Date.parse(current.createdAt || '')) {
+            this.idempotency.set(job.idempotencyKey, job.id);
+          }
+        }
+      } catch {
+        // 损坏的单个临时任务不会阻止服务启动。
+      }
+    }
+    await this.cleanupExpired();
+  }
+
+  get(id) {
+    return this.jobs.get(id) || null;
+  }
+
+  findByIdempotency(key) {
+    const id = this.idempotency.get(key);
+    return id ? this.get(id) : null;
+  }
+
+  listRecoverable() {
+    return [...this.jobs.values()].filter(job => ['queued', 'running'].includes(job.status));
+  }
+
+  listByOwner(ownerKey) {
+    return [...this.jobs.values()].filter(job => job.ownerKey === ownerKey);
+  }
+
+  async create(job) {
+    this.jobs.set(job.id, job);
+    if (job.idempotencyKey) this.idempotency.set(job.idempotencyKey, job.id);
+    await this.persist(job);
+    return job;
+  }
+
+  async update(id, patch) {
+    const current = this.get(id);
+    if (!current) return null;
+    const next = {
+      ...current,
+      ...patch,
+      updatedAt: new Date().toISOString(),
+    };
+    this.jobs.set(id, next);
+    await this.persist(next);
+    return next;
+  }
+
+  async persist(job) {
+    const finalPath = join(this.directory, `${job.id}.json`);
+    const temporaryPath = join(this.directory, `.${job.id}.${process.pid}.tmp`);
+    await writeFile(temporaryPath, JSON.stringify(job), { mode: 0o600 });
+    await rename(temporaryPath, finalPath);
+  }
+
+  async cleanupExpired(now = Date.now()) {
+    for (const job of [...this.jobs.values()]) {
+      const expiresAt = Date.parse(job.expiresAt || '');
+      if (!Number.isFinite(expiresAt) || expiresAt > now) continue;
+      this.jobs.delete(job.id);
+      if (job.idempotencyKey && this.idempotency.get(job.idempotencyKey) === job.id) {
+        this.idempotency.delete(job.idempotencyKey);
+      }
+      await unlink(join(this.directory, `${job.id}.json`)).catch(() => undefined);
+    }
+  }
+}
+
+export function publicJob(job) {
+  return {
+    id: job.id,
+    status: job.status,
+    stage: job.stage,
+    progress: job.progress,
+    createdAt: job.createdAt,
+    updatedAt: job.updatedAt,
+    heartbeatAt: job.heartbeatAt || null,
+    expiresAt: job.expiresAt,
+    result: job.status === 'completed' ? job.result : undefined,
+    error: job.status === 'failed' ? job.error : undefined,
+  };
+}

+ 28 - 0
src/load-env.mjs

@@ -0,0 +1,28 @@
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+
+export async function loadLocalEnv(filePath = resolve(process.cwd(), '.env')) {
+  let source;
+  try {
+    source = await readFile(filePath, 'utf8');
+  } catch (error) {
+    if (error?.code === 'ENOENT') return;
+    throw error;
+  }
+
+  for (const rawLine of source.split(/\r?\n/)) {
+    const line = rawLine.trim();
+    if (!line || line.startsWith('#')) continue;
+    const separator = line.indexOf('=');
+    if (separator <= 0) continue;
+    const key = line.slice(0, separator).trim();
+    let value = line.slice(separator + 1).trim();
+    if (
+      (value.startsWith('"') && value.endsWith('"')) ||
+      (value.startsWith("'") && value.endsWith("'"))
+    ) {
+      value = value.slice(1, -1);
+    }
+    if (!(key in process.env)) process.env[key] = value;
+  }
+}

+ 147 - 0
src/media.mjs

@@ -0,0 +1,147 @@
+import { createWriteStream } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import { Readable, Transform } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import { spawn } from 'node:child_process';
+import { dirname } from 'node:path';
+import { AppError } from './errors.mjs';
+import { validateRemoteAudioUrl } from './url-security.mjs';
+
+function run(command, args, { captureStdout = false } = {}) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(command, args, {
+      stdio: ['ignore', captureStdout ? 'pipe' : 'ignore', 'pipe'],
+    });
+    let stdout = '';
+    let stderr = '';
+    child.stdout?.on('data', chunk => {
+      if (stdout.length < 8_000) stdout += String(chunk);
+    });
+    child.stderr.on('data', chunk => {
+      if (stderr.length < 8_000) stderr += String(chunk);
+    });
+    child.once('error', error => {
+      reject(
+        new AppError(`无法启动媒体工具 ${command}`, {
+          code: 'MEDIA_TOOL_UNAVAILABLE',
+          status: 500,
+          retryable: false,
+          cause: error,
+        }),
+      );
+    });
+    child.once('close', code => {
+      if (code === 0) resolve(stdout);
+      else {
+        reject(
+          new AppError(`媒体处理失败:${stderr.slice(-600) || `退出码 ${code}`}`, {
+            code: 'MEDIA_PROCESSING_FAILED',
+            status: 422,
+            retryable: false,
+          }),
+        );
+      }
+    });
+  });
+}
+
+export async function downloadRemoteAudio(rawUrl, outputPath, config, fetchImpl = fetch) {
+  await mkdir(dirname(outputPath), { recursive: true });
+  let url = await validateRemoteAudioUrl(rawUrl, config.allowedAudioHosts);
+
+  for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
+    let response;
+    try {
+      response = await fetchImpl(url, {
+        method: 'GET',
+        redirect: 'manual',
+        headers: { Accept: 'audio/*,application/octet-stream;q=0.9' },
+        signal: AbortSignal.timeout(config.remoteDownloadTimeoutMs),
+      });
+    } catch {
+      throw new AppError('下载录音文件失败', {
+        code: 'AUDIO_DOWNLOAD_FAILED',
+        status: 502,
+        retryable: true,
+      });
+    }
+
+    if ([301, 302, 303, 307, 308].includes(response.status)) {
+      const location = response.headers.get('location');
+      if (!location || redirectCount === 3) {
+        throw new AppError('录音下载重定向次数过多', {
+          code: 'AUDIO_REDIRECT_REJECTED',
+          status: 422,
+        });
+      }
+      url = await validateRemoteAudioUrl(new URL(location, url).toString(), config.allowedAudioHosts);
+      continue;
+    }
+    if (!response.ok || !response.body) {
+      throw new AppError(`录音文件下载失败(HTTP ${response.status})`, {
+        code: 'AUDIO_DOWNLOAD_FAILED',
+        status: response.status >= 500 ? 502 : 422,
+        retryable: response.status >= 500,
+      });
+    }
+
+    const declaredSize = Number(response.headers.get('content-length') || 0);
+    if (declaredSize > config.maxRemoteAudioBytes) {
+      throw new AppError('录音文件超过大小上限', {
+        code: 'AUDIO_TOO_LARGE',
+        status: 413,
+      });
+    }
+    let downloadedBytes = 0;
+    const guard = new Transform({
+      transform(chunk, _encoding, callback) {
+        downloadedBytes += chunk.length;
+        if (downloadedBytes > config.maxRemoteAudioBytes) {
+          callback(
+            new AppError('录音文件超过大小上限', {
+              code: 'AUDIO_TOO_LARGE',
+              status: 413,
+            }),
+          );
+          return;
+        }
+        callback(null, chunk);
+      },
+    });
+    await pipeline(Readable.fromWeb(response.body), guard, createWriteStream(outputPath, { flags: 'wx' }));
+    return { downloadedBytes, finalUrlHost: url.hostname };
+  }
+  throw new AppError('录音文件下载失败', { code: 'AUDIO_DOWNLOAD_FAILED', status: 502 });
+}
+
+export async function transcodeToPcmWav(inputPath, outputPath, config) {
+  await run(config.ffmpegPath, [
+    '-y',
+    '-i',
+    inputPath,
+    '-vn',
+    '-ac',
+    '1',
+    '-ar',
+    '16000',
+    '-sample_fmt',
+    's16',
+    outputPath,
+  ]);
+}
+
+export async function probeDurationMs(filePath, config) {
+  const stdout = await run(
+    config.ffprobePath,
+    ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', filePath],
+    { captureStdout: true },
+  );
+  const seconds = Number(String(stdout).trim());
+  if (!Number.isFinite(seconds) || seconds <= 0) {
+    throw new AppError('无法确定录音时长', {
+      code: 'INVALID_AUDIO_DURATION',
+      status: 422,
+    });
+  }
+  return Math.round(seconds * 1_000);
+}

+ 61 - 0
src/result-parser.mjs

@@ -0,0 +1,61 @@
+function parseMilliseconds(value) {
+  const parsed = Number(value);
+  return Number.isFinite(parsed) ? Math.round(parsed) : null;
+}
+
+function sentenceFromSt(st) {
+  let text = '';
+  for (const rtItem of Array.isArray(st?.rt) ? st.rt : []) {
+    for (const wsItem of Array.isArray(rtItem?.ws) ? rtItem.ws : []) {
+      const first = Array.isArray(wsItem?.cw) ? wsItem.cw[0] : null;
+      if (first?.w && !(first.wp === 'p' && !String(first.w).trim())) {
+        text += String(first.w);
+      }
+    }
+  }
+  return text.trim();
+}
+
+export function parseIflytekResult(orderResult) {
+  if (!orderResult) return { text: '', segments: [] };
+  let result;
+  try {
+    result = typeof orderResult === 'string' ? JSON.parse(orderResult) : orderResult;
+  } catch {
+    return { text: '', segments: [] };
+  }
+
+  const rawSegments = [];
+  for (const item of Array.isArray(result?.lattice) ? result.lattice : []) {
+    try {
+      const oneBest =
+        typeof item?.json_1best === 'string' ? JSON.parse(item.json_1best) : item?.json_1best;
+      const st = oneBest?.st;
+      const text = sentenceFromSt(st);
+      if (!text) continue;
+      rawSegments.push({
+        text,
+        startMs: parseMilliseconds(st?.bg),
+        endMs: parseMilliseconds(st?.ed),
+        speakerId: st?.rl == null ? null : String(st.rl),
+      });
+    } catch {
+      // 单个损坏片段不影响其余转写结果。
+    }
+  }
+
+  const segments = [];
+  for (const segment of rawSegments) {
+    const previous = segments.at(-1);
+    if (previous && previous.speakerId === segment.speakerId) {
+      previous.text += segment.text;
+      if (segment.endMs != null) previous.endMs = segment.endMs;
+    } else {
+      segments.push({ ...segment });
+    }
+  }
+  return {
+    text: rawSegments.map(segment => segment.text).join(' ').trim(),
+    segments,
+  };
+}

+ 167 - 0
src/transcription-worker.mjs

@@ -0,0 +1,167 @@
+import { createHash } from 'node:crypto';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { AppError } from './errors.mjs';
+import { downloadRemoteAudio, probeDurationMs, transcodeToPcmWav } from './media.mjs';
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function normalizedTranscript(text) {
+  return String(text || '').replace(/\r\n?/g, '\n').trim();
+}
+
+export class TranscriptionWorker {
+  constructor({ config, store, provider }) {
+    this.config = config;
+    this.store = store;
+    this.provider = provider;
+    this.queue = [];
+    this.queuedIds = new Set();
+    this.active = 0;
+  }
+
+  async start() {
+    for (const job of this.store.listRecoverable()) this.enqueue(job.id);
+  }
+
+  enqueue(jobId) {
+    if (this.queuedIds.has(jobId)) return;
+    this.queuedIds.add(jobId);
+    this.queue.push(jobId);
+    queueMicrotask(() => this.drain());
+  }
+
+  async cancel(jobId) {
+    const job = this.store.get(jobId);
+    if (!job || ['completed', 'failed', 'cancelled'].includes(job.status)) return job;
+    return this.store.update(jobId, {
+      status: 'cancelled',
+      stage: 'cancelled',
+      progress: job.progress,
+      cancellationRequested: true,
+    });
+  }
+
+  async drain() {
+    while (this.active < this.config.maxJobConcurrency && this.queue.length) {
+      const jobId = this.queue.shift();
+      this.queuedIds.delete(jobId);
+      this.active += 1;
+      void this.run(jobId)
+        .catch(() => undefined)
+        .finally(() => {
+          this.active -= 1;
+          queueMicrotask(() => this.drain());
+        });
+    }
+  }
+
+  async checkpoint(jobId, patch) {
+    const current = this.store.get(jobId);
+    if (!current || current.status === 'cancelled' || current.cancellationRequested) {
+      throw new AppError('任务已取消', { code: 'JOB_CANCELLED', status: 409 });
+    }
+    return this.store.update(jobId, {
+      status: 'running',
+      heartbeatAt: new Date().toISOString(),
+      ...patch,
+    });
+  }
+
+  async run(jobId) {
+    let job = this.store.get(jobId);
+    if (!job || job.status === 'cancelled' || job.status === 'completed') return;
+    let workDirectory;
+    try {
+      if (!job.providerOrderId) {
+        await mkdir(this.config.jobTempDir || tmpdir(), { recursive: true });
+        workDirectory = await mkdtemp(join(this.config.jobTempDir || tmpdir(), `${job.id}-`));
+        const sourcePath = join(workDirectory, 'source-audio');
+        const wavPath = join(workDirectory, 'recording.wav');
+
+        await this.checkpoint(jobId, { stage: 'downloading', progress: 10 });
+        await downloadRemoteAudio(job.request.audioUrl, sourcePath, this.config);
+        await this.checkpoint(jobId, { stage: 'transcoding', progress: 25 });
+        await transcodeToPcmWav(sourcePath, wavPath, this.config);
+        const durationMs = await probeDurationMs(wavPath, this.config);
+        await this.checkpoint(jobId, { stage: 'submitting', progress: 40, durationMs });
+        const submitted = await this.provider.submit(wavPath, {
+          durationMs,
+          roleType: job.request.roleType,
+          roleNum: job.request.roleNum,
+        });
+        job = await this.checkpoint(jobId, {
+          stage: 'transcribing',
+          progress: 50,
+          providerOrderId: submitted.orderId,
+          providerEstimateTimeMs: submitted.estimateTimeMs,
+        });
+      }
+
+      const pollStartedAt = Date.now();
+      let transientFailures = 0;
+      for (let attempt = 0; Date.now() - pollStartedAt < this.config.iflytek.pollTimeoutMs; attempt += 1) {
+        job = this.store.get(jobId);
+        if (!job || job.status === 'cancelled' || job.cancellationRequested) return;
+        try {
+          const response = await this.provider.getResult(job.providerOrderId);
+          transientFailures = 0;
+          if (response.status === 'completed') {
+            const text = normalizedTranscript(response.result.text);
+            const completedAt = new Date().toISOString();
+            await this.store.update(jobId, {
+              status: 'completed',
+              stage: 'completed',
+              progress: 100,
+              heartbeatAt: completedAt,
+              completedAt,
+              result: {
+                text,
+                segments: response.result.segments,
+                charCount: text.length,
+                sha256: createHash('sha256').update(text, 'utf8').digest('hex'),
+              },
+              request: undefined,
+            });
+            return;
+          }
+          const elapsedRatio = Math.min(1, (Date.now() - pollStartedAt) / this.config.iflytek.pollTimeoutMs);
+          await this.checkpoint(jobId, {
+            stage: 'transcribing',
+            progress: Math.min(90, Math.round(50 + elapsedRatio * 40)),
+          });
+        } catch (error) {
+          if (!(error instanceof AppError) || !error.retryable || transientFailures >= 2) throw error;
+          transientFailures += 1;
+        }
+        const elapsed = Date.now() - pollStartedAt;
+        await sleep(elapsed < 60_000 ? 5_000 : elapsed < 10 * 60_000 ? 15_000 : 30_000);
+      }
+      throw new AppError('长录音转写仍在处理中,请重新查询或稍后重试', {
+        code: 'PROVIDER_TIMEOUT',
+        status: 504,
+        retryable: true,
+      });
+    } catch (error) {
+      const latest = this.store.get(jobId);
+      if (latest && latest.status !== 'cancelled') {
+        const appError = error instanceof AppError ? error : new AppError('任务执行失败');
+        await this.store.update(jobId, {
+          status: 'failed',
+          stage: 'failed',
+          heartbeatAt: new Date().toISOString(),
+          error: {
+            code: appError.code,
+            message: appError.message,
+            retryable: appError.retryable,
+          },
+        });
+      }
+    } finally {
+      if (workDirectory) await rm(workDirectory, { recursive: true, force: true });
+    }
+  }
+}

+ 104 - 0
src/url-security.mjs

@@ -0,0 +1,104 @@
+import { isIP } from 'node:net';
+import { lookup } from 'node:dns/promises';
+import { AppError } from './errors.mjs';
+
+function ipv4Parts(address) {
+  const parts = address.split('.').map(Number);
+  return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
+    ? parts
+    : null;
+}
+
+export function isPrivateOrReservedAddress(address) {
+  const normalized = String(address || '').trim().toLowerCase();
+  const version = isIP(normalized);
+  if (version === 4) {
+    const parts = ipv4Parts(normalized);
+    if (!parts) return true;
+    const [a, b, c] = parts;
+    return (
+      a === 0 ||
+      a === 10 ||
+      a === 127 ||
+      (a === 100 && b >= 64 && b <= 127) ||
+      (a === 169 && b === 254) ||
+      (a === 172 && b >= 16 && b <= 31) ||
+      (a === 192 && b === 0) ||
+      (a === 192 && b === 168) ||
+      (a === 192 && b === 0 && c === 2) ||
+      (a === 198 && (b === 18 || b === 19)) ||
+      (a === 198 && b === 51 && c === 100) ||
+      (a === 203 && b === 0 && c === 113) ||
+      a >= 224
+    );
+  }
+  if (version === 6) {
+    return (
+      normalized === '::' ||
+      normalized === '::1' ||
+      normalized.startsWith('fc') ||
+      normalized.startsWith('fd') ||
+      /^fe[89ab]/.test(normalized) ||
+      normalized.startsWith('ff') ||
+      normalized.startsWith('2001:db8:') ||
+      normalized.startsWith('::ffff:127.') ||
+      normalized.startsWith('::ffff:10.') ||
+      normalized.startsWith('::ffff:192.168.')
+    );
+  }
+  return true;
+}
+
+export function hostMatchesAllowlist(hostname, allowlist) {
+  const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
+  return allowlist.some(rawPattern => {
+    const pattern = String(rawPattern || '').toLowerCase().replace(/\.$/, '');
+    if (pattern.startsWith('*.')) {
+      const suffix = pattern.slice(1);
+      return host.endsWith(suffix) && host.length > suffix.length;
+    }
+    return host === pattern;
+  });
+}
+
+export async function validateRemoteAudioUrl(rawUrl, allowlist, resolver = lookup) {
+  let url;
+  try {
+    url = new URL(String(rawUrl || ''));
+  } catch {
+    throw new AppError('audioUrl 不是有效地址', {
+      code: 'INVALID_AUDIO_URL',
+      status: 400,
+    });
+  }
+  if (url.protocol !== 'https:' || url.username || url.password) {
+    throw new AppError('audioUrl 只允许不含账号信息的 HTTPS 地址', {
+      code: 'INVALID_AUDIO_URL',
+      status: 400,
+    });
+  }
+  if (!hostMatchesAllowlist(url.hostname, allowlist)) {
+    throw new AppError('audioUrl 域名不在允许列表中', {
+      code: 'AUDIO_HOST_NOT_ALLOWED',
+      status: 403,
+    });
+  }
+
+  let addresses;
+  try {
+    addresses = await resolver(url.hostname, { all: true, verbatim: true });
+  } catch {
+    throw new AppError('无法解析录音文件域名', {
+      code: 'AUDIO_HOST_UNREACHABLE',
+      status: 422,
+      retryable: true,
+    });
+  }
+  if (!addresses.length || addresses.some(item => isPrivateOrReservedAddress(item.address))) {
+    throw new AppError('audioUrl 解析到了不允许的网络地址', {
+      code: 'AUDIO_ADDRESS_NOT_ALLOWED',
+      status: 403,
+    });
+  }
+  return url;
+}

+ 106 - 0
test/http-app.test.mjs

@@ -0,0 +1,106 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { JobStore } from '../src/job-store.mjs';
+import { createHttpHandler } from '../src/http-app.mjs';
+
+async function fixture() {
+  const directory = await mkdtemp(join(tmpdir(), 'yuban-server-test-'));
+  const store = new JobStore(directory, 86_400_000);
+  await store.init();
+  const enqueued = [];
+  const worker = {
+    enqueue(id) {
+      enqueued.push(id);
+    },
+    async cancel(id) {
+      return store.update(id, { status: 'cancelled', stage: 'cancelled' });
+    },
+  };
+  const config = {
+    corsAllowedOrigins: ['http://localhost:4200'],
+    allowedAudioHosts: ['file.yuban.co'],
+    jobRetentionMs: 86_400_000,
+    maxActiveJobsPerUser: 2,
+    maxRetainedJobsPerUser: 20,
+  };
+  const authenticator = {
+    async authenticate() {
+      return { ownerKey: 'owner-a', userId: 'user-a' };
+    },
+  };
+  const server = createServer(
+    createHttpHandler({
+      config,
+      store,
+      worker,
+      authenticator,
+      validateAudioUrl: async value => new URL(value),
+    }),
+  );
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  return {
+    store,
+    enqueued,
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    async close() {
+      await new Promise(resolve => server.close(resolve));
+      await rm(directory, { recursive: true, force: true });
+    },
+  };
+}
+
+test('健康检查不要求认证', async t => {
+  const app = await fixture();
+  t.after(() => app.close());
+  const response = await fetch(`${app.baseUrl}/health`);
+  assert.equal(response.status, 200);
+  const body = await response.json();
+  assert.equal(body.service, 'yuban-server');
+});
+
+test('创建任务立即返回 202,查询不会暴露音频地址或所有者', async t => {
+  const app = await fixture();
+  t.after(() => app.close());
+  const created = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Origin: 'http://localhost:4200',
+      'Idempotency-Key': 'chat-voice-1',
+    },
+    body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
+  });
+  assert.equal(created.status, 202);
+  const createdBody = await created.json();
+  assert.equal(createdBody.job.status, 'queued');
+  assert.equal(app.enqueued.length, 1);
+
+  const response = await fetch(
+    `${app.baseUrl}/recording-transcription/jobs/${createdBody.job.id}`,
+  );
+  const text = await response.text();
+  assert.equal(response.status, 200);
+  assert.equal(text.includes('file.yuban.co'), false);
+  assert.equal(text.includes('owner-a'), false);
+});
+
+test('同一用户和幂等键复用已有任务', async t => {
+  const app = await fixture();
+  t.after(() => app.close());
+  const create = () =>
+    fetch(`${app.baseUrl}/recording-transcription/jobs`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-recording' },
+      body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
+    });
+  const first = await (await create()).json();
+  const second = await (await create()).json();
+  assert.equal(first.job.id, second.job.id);
+  assert.equal(second.reused, true);
+  assert.equal(app.enqueued.length, 1);
+});

+ 31 - 0
test/job-store.test.mjs

@@ -0,0 +1,31 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { JobStore } from '../src/job-store.mjs';
+
+test('清理旧重试任务时不删除新任务的幂等映射', async t => {
+  const directory = await mkdtemp(join(tmpdir(), 'yuban-job-store-test-'));
+  t.after(() => rm(directory, { recursive: true, force: true }));
+  const store = new JobStore(directory, 86_400_000);
+  await store.init();
+  const common = {
+    ownerKey: 'owner',
+    idempotencyKey: 'same-key',
+    status: 'failed',
+    stage: 'failed',
+    progress: 10,
+    createdAt: '2026-08-05T00:00:00.000Z',
+    updatedAt: '2026-08-05T00:00:00.000Z',
+  };
+  await store.create({ ...common, id: 'old', expiresAt: '2026-08-05T00:01:00.000Z' });
+  await store.create({
+    ...common,
+    id: 'new',
+    status: 'queued',
+    expiresAt: '2026-08-07T00:00:00.000Z',
+  });
+  await store.cleanupExpired(Date.parse('2026-08-06T00:00:00.000Z'));
+  assert.equal(store.findByIdempotency('same-key')?.id, 'new');
+});

+ 36 - 0
test/result-parser.test.mjs

@@ -0,0 +1,36 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { parseIflytekResult } from '../src/result-parser.mjs';
+
+function lattice(text, { bg, ed, role }) {
+  return {
+    json_1best: JSON.stringify({
+      st: {
+        bg: String(bg),
+        ed: String(ed),
+        rl: role,
+        rt: [{ ws: [...text].map(character => ({ cw: [{ w: character }] })) }],
+      },
+    }),
+  };
+}
+
+test('解析正文、时间和说话人,并合并连续同一说话人', () => {
+  const raw = JSON.stringify({
+    lattice: [
+      lattice('你好', { bg: 0, ed: 500, role: 1 }),
+      lattice('世界', { bg: 500, ed: 900, role: 1 }),
+      lattice('您好', { bg: 900, ed: 1300, role: 2 }),
+    ],
+  });
+  const result = parseIflytekResult(raw);
+  assert.equal(result.text, '你好 世界 您好');
+  assert.deepEqual(result.segments, [
+    { text: '你好世界', startMs: 0, endMs: 900, speakerId: '1' },
+    { text: '您好', startMs: 900, endMs: 1300, speakerId: '2' },
+  ]);
+});
+
+test('损坏结果安全返回空正文', () => {
+  assert.deepEqual(parseIflytekResult('{bad json'), { text: '', segments: [] });
+});

+ 50 - 0
test/url-security.test.mjs

@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+  hostMatchesAllowlist,
+  isPrivateOrReservedAddress,
+  validateRemoteAudioUrl,
+} from '../src/url-security.mjs';
+
+test('音频域名必须匹配显式白名单', () => {
+  assert.equal(hostMatchesAllowlist('file.yuban.co', ['file.yuban.co']), true);
+  assert.equal(hostMatchesAllowlist('a.media.example.com', ['*.media.example.com']), true);
+  assert.equal(hostMatchesAllowlist('media.example.com', ['*.media.example.com']), false);
+  assert.equal(hostMatchesAllowlist('attacker.example', ['file.yuban.co']), false);
+});
+
+test('阻止私网、回环和保留地址', () => {
+  for (const address of ['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '::1', 'fd00::1']) {
+    assert.equal(isPrivateOrReservedAddress(address), true, address);
+  }
+  assert.equal(isPrivateOrReservedAddress('1.1.1.1'), false);
+  assert.equal(isPrivateOrReservedAddress('2606:4700:4700::1111'), false);
+});
+
+test('允许白名单 HTTPS 公网地址', async () => {
+  const url = await validateRemoteAudioUrl(
+    'https://file.yuban.co/audio/test.mp3',
+    ['file.yuban.co'],
+    async () => [{ address: '1.1.1.1', family: 4 }],
+  );
+  assert.equal(url.hostname, 'file.yuban.co');
+});
+
+test('拒绝非 HTTPS 和私网解析', async () => {
+  await assert.rejects(
+    validateRemoteAudioUrl(
+      'http://file.yuban.co/test.mp3',
+      ['file.yuban.co'],
+      async () => [{ address: '1.1.1.1', family: 4 }],
+    ),
+    error => error.code === 'INVALID_AUDIO_URL',
+  );
+  await assert.rejects(
+    validateRemoteAudioUrl(
+      'https://file.yuban.co/test.mp3',
+      ['file.yuban.co'],
+      async () => [{ address: '127.0.0.1', family: 4 }],
+    ),
+    error => error.code === 'AUDIO_ADDRESS_NOT_ALLOWED',
+  );
+});