Procházet zdrojové kódy

fix: make long transcription jobs recoverable

彭峰 před 1 měsícem
rodič
revize
cf5c1a9e59

+ 6 - 2
.env.example

@@ -14,6 +14,8 @@ REMOTE_DOWNLOAD_TIMEOUT_MS=300000
 AUTH_MODE=parse
 PARSE_SERVER_URL=https://server.fmode.cn/parse
 PARSE_APP_ID=ncloudmaster
+PARSE_MASTER_KEY=
+CANONICAL_PERSISTENCE_ENABLED=false
 AUTH_CACHE_TTL_MS=60000
 
 # 讯飞长语音转写 IST 凭据。只能配置在新服务器环境变量中。
@@ -28,8 +30,10 @@ FFMPEG_PATH=ffmpeg
 FFPROBE_PATH=ffprobe
 JOB_STORE_DIR=./var/jobs
 JOB_TEMP_DIR=./tmp
-JOB_RETENTION_MS=86400000
-MAX_JOB_CONCURRENCY=1
+JOB_RETENTION_MS=2592000000
+MAX_JOB_CONCURRENCY=2
 MAX_ACTIVE_JOBS_PER_USER=2
 MAX_RETAINED_JOBS_PER_USER=20
+MAX_JOB_ATTEMPTS=5
+RETRY_BASE_DELAY_MS=5000
 IFLYTEK_POLL_TIMEOUT_MS=2400000

+ 19 - 10
README.md

@@ -1,15 +1,16 @@
 # yuban-server
 
-`yuban-server` 是自传语伴新增业务能力的独立 Node.js 服务。它只承载旧服务器尚未实现、且不负责正式数据读写的业务接口。
+`yuban-server` 是自传语伴新增业务能力的独立 Node.js 服务。它承载旧服务器尚未实现的长录音异步转写,并可在严格校验用户访问权限后提交权威逐字稿。
 
-首个能力是“历史长录音异步转写”:浏览器提交旧对象存储中的录音 URL,新服务临时下载并转为 16kHz 单声道 PCM WAV,调用讯飞 IST,最后返回完整正文和说话人分段。浏览器再通过原有 `server.fmode.cn`/Parse 接口把结果保存到 `ChatVoice`。
+首个能力是“历史长录音异步转写”:浏览器提交旧对象存储中的录音 URL,新服务临时下载并转为 16kHz 单声道 PCM WAV,调用讯飞 IST,最后返回完整正文和说话人分段。生产启用权威落库后,服务会先写入并回读校验 `ChatVoice`;浏览器写入只作为兼容性兜底。
 
 ## 边界
 
-- 不查询或写入 `ChatVoice`、`ChatSession`、`Article`、会员、订单等业务表。
+- 默认不读写业务表;只有显式启用权威逐字稿落库时,才会校验并更新请求指定的 `ChatVoice`。
+- 永不写入 `ChatSession`、`Article`、会员或订单等其他业务表。
 - 不替代旧服务器中已经正常运行的接口。
 - 只调用旧 Parse 的 `/users/me` 校验现有 Session Token;不会实现第二套用户系统。
-- 转写任务和正文仅作为本机临时运行状态保存,默认 24 小时后删除。
+- 转写任务和正文会原子写入持久目录,默认保留 30 天;生产必须挂载持久卷。
 - 日志不打印录音 URL、Session Token、逐字稿正文或讯飞原始结果。
 - 默认每个用户最多同时运行 2 个任务、保留 20 个临时任务,避免合法账号误触发任务风暴。
 
@@ -30,7 +31,8 @@ sequenceDiagram
     New->>ASR: 提交并轮询转写
     Web->>New: GET /recording-transcription/jobs/:jobId
     New-->>Web: 完整 text + segments + SHA-256
-    Web->>Old: 使用原接口保存 ChatVoice 权威逐字稿
+    New->>Old: 写入并回读校验 ChatVoice 权威逐字稿
+    Web->>Old: 兼容性回读/兜底保存
 ```
 
 ## 接口
@@ -46,6 +48,7 @@ sequenceDiagram
 ```json
 {
   "audioUrl": "https://file.yuban.co/path/recording.mp3",
+  "chatVoiceId": "ChatVoice-object-id",
   "durationMs": 3600000,
   "roleType": 1,
   "roleNum": 0,
@@ -102,7 +105,11 @@ Content-Type: application/json
 }
 ```
 
-任务状态为 `queued`、`running`、`completed`、`failed` 或 `cancelled`;阶段为 `queued`、`downloading`、`transcoding`、`submitting`、`transcribing`、`completed`、`failed`、`cancelled`。
+任务状态为 `queued`、`running`、`retry_wait`、`completed`、`failed` 或 `cancelled`。临时错误会进入 `retry_wait` 并由 Worker 自动重试。
+
+### `POST /recording-transcription/jobs/:jobId/retry`
+
+为已经 `failed` 或 `cancelled` 且仍保留源请求的任务创建新 jobId。新任务包含 `parentJobId`,不会继续查询已经失败的旧任务。
 
 ### `POST /recording-transcription/jobs/:jobId/cancel`
 
@@ -130,19 +137,19 @@ npm start
 推荐使用 Docker:
 
 ```bash
-docker build -t yuban-server:1.0.0 .
+docker build -t yuban-server:1.1.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
+  yuban-server:1.1.0
 ```
 
 `deploy/nginx.conf.example` 展示了 HTTPS 反向代理配置。不要让 Node 端口直接暴露到公网。生产环境至少要完成:
 
-1. 配置 HTTPS 域名,例如 `business-api.yuban.co`。
+1. 生产入口使用 `https://server.yuban.co`;Node 端口只监听反向代理所在主机。
 2. 把 `CORS_ALLOWED_ORIGINS` 限制为真实 Angular 域名(精确匹配,不要使用 `*`):
 
    ```env
@@ -152,6 +159,8 @@ docker run -d \
 4. 仅在服务器环境变量中保存讯飞密钥。
 5. 监控 `/health`、任务失败率、磁盘容量和 ffmpeg/ffprobe 可用性。
 
+权威逐字稿服务端落库需要额外配置 `CANONICAL_PERSISTENCE_ENABLED=true` 和仅存在于服务器环境中的 `PARSE_MASTER_KEY`。创建任务时服务会先用用户 Session Token 校验 ChatVoice 访问权限,完成后才用服务凭据写入并回读 hash/字数。
+
 ## 前端接入
 
 只为历史长录音重转写配置新服务地址:
@@ -160,7 +169,7 @@ docker run -d \
 <script>
   window.__YUBAN_RUNTIME_CONFIG__ = {
     ...(window.__YUBAN_RUNTIME_CONFIG__ || {}),
-    recordingTranscriptionBusinessApiBaseUrl: 'https://business-api.yuban.co'
+    recordingTranscriptionBusinessApiBaseUrl: 'https://server.yuban.co'
   };
 </script>
 ```

+ 27 - 0
deploy/PRODUCTION_ROLLOUT.md

@@ -0,0 +1,27 @@
+# `server.yuban.co` 生产发布清单
+
+当前 DNS 指向华为云公网地址 `121.37.7.67`。本清单只描述发布和验证步骤,不包含任何账号、密钥或自动登录脚本。
+
+## 发布前
+
+1. 备份 `/etc/yuban-server.env` 和持久任务目录。
+2. 确认服务器为 Node.js 22+,并能执行 `ffmpeg`、`ffprobe`。
+3. 配置持久绝对路径 `JOB_STORE_DIR`;不能放在版本发布时会被替换的目录中。
+4. 设置 `MAX_JOB_CONCURRENCY=2`、`MAX_JOB_ATTEMPTS=5`、`JOB_RETENTION_MS=2592000000`。
+5. 设置 `CANONICAL_PERSISTENCE_ENABLED=true`,并在服务器环境变量中提供 `PARSE_MASTER_KEY`;该密钥不得进入 Git、网页或日志。
+6. 记录本次 `SERVICE_VERSION` 和 `GIT_SHA`,便于从 `/health` 确认实际运行版本。
+
+## 灰度发布
+
+1. 先执行 `npm test` 和 `npm run check`。
+2. 发布新版本,但保留上一版本镜像或目录以便快速回滚。
+3. 重启服务后确认 `GET https://server.yuban.co/health` 返回新版本、`workerReady=true`、`storeReady=true`。
+4. 用测试账号提交一条短录音,验证 `queued → running → completed`,以及 `ChatVoice` 回读的 hash、字数和 `long_complete` 一致。
+5. 人工模拟一次可重试失败,验证任务进入 `retry_wait` 并自动增加 attempt;模拟永久失败后调用 `/retry`,必须得到新的 jobId。
+6. 再用 30 分钟录音验收,确认页面离开后任务仍继续、故事任务从 `waiting_for_transcript` 自动推进。
+
+## 观察与回滚
+
+- 至少观察 24 小时:任务成功率、`retry_wait` 数量、最终失败码、平均排队时间、任务目录磁盘占用、Parse 回读失败率。
+- 日志只记录 jobId、attempt、stage、错误码和耗时,不得记录音频 URL、Session Token、逐字稿或 Parse Master Key。
+- 若权威落库异常,可先关闭 `CANONICAL_PERSISTENCE_ENABLED` 并回滚服务版本;持久任务目录不可删除,前端仍可通过已有 jobId 恢复查询。

+ 3 - 3
deploy/nginx.conf.example

@@ -1,9 +1,9 @@
 server {
     listen 443 ssl http2;
-    server_name business-api.yuban.co;
+    server_name server.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;
+    ssl_certificate /etc/letsencrypt/live/server.yuban.co/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/server.yuban.co/privkey.pem;
 
     client_max_body_size 64k;
 

+ 1 - 1
deploy/yuban-server.service

@@ -1,5 +1,5 @@
 [Unit]
-Description=Yuban stateless business capability API
+Description=Yuban durable business capability API
 After=network-online.target
 Wants=network-online.target
 

+ 34 - 1
openapi.yaml

@@ -62,6 +62,20 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/JobResponse'
+  /recording-transcription/jobs/{jobId}/retry:
+    parameters:
+      - $ref: '#/components/parameters/JobId'
+    post:
+      operationId: retryRecordingTranscriptionJob
+      security:
+        - bearerAuth: []
+      responses:
+        '202':
+          description: A new attempt was created
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/JobResponse'
 components:
   securitySchemes:
     bearerAuth:
@@ -84,6 +98,9 @@ components:
         audioUrl:
           type: string
           format: uri
+        chatVoiceId:
+          type: string
+          maxLength: 64
         durationMs:
           type: integer
           minimum: 1
@@ -119,7 +136,7 @@ components:
           format: uuid
         status:
           type: string
-          enum: [queued, running, completed, failed, cancelled]
+          enum: [queued, running, retry_wait, completed, failed, cancelled]
         stage:
           type: string
         progress:
@@ -135,6 +152,20 @@ components:
         heartbeatAt:
           type: [string, 'null']
           format: date-time
+        attempt:
+          type: integer
+          minimum: 0
+        parentJobId:
+          type: [string, 'null']
+          format: uuid
+        nextRetryAt:
+          type: [string, 'null']
+          format: date-time
+        queuePosition:
+          type: [integer, 'null']
+          minimum: 1
+        canRetry:
+          type: boolean
         expiresAt:
           type: string
           format: date-time
@@ -157,6 +188,8 @@ components:
         sha256:
           type: string
           pattern: '^[0-9a-f]{64}$'
+        canonicalPersisted:
+          type: boolean
     Segment:
       type: object
       required: [text]

+ 2 - 2
package-lock.json

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

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "yuban-server",
-  "version": "1.0.0",
-  "description": "Yuban stateless business capability API",
+  "version": "1.1.0",
+  "description": "Yuban durable business capability API",
   "type": "module",
   "main": "src/index.mjs",
   "scripts": {

+ 68 - 0
src/canonical-transcript.mjs

@@ -0,0 +1,68 @@
+import { createHash } from 'node:crypto';
+
+export const TRANSCRIPT_SCHEMA_VERSION = 1;
+
+export function normalizeCanonicalText(value) {
+  return String(value ?? '')
+    .replace(/\r\n?/g, '\n')
+    .replace(/[ \t]+\n/g, '\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+export function canonicalHash(value) {
+  return createHash('sha256').update(normalizeCanonicalText(value), 'utf8').digest('hex');
+}
+
+export function canonicalCharCount(value) {
+  return normalizeCanonicalText(value).replace(/\s+/g, '').length;
+}
+
+function normalizedSegments(value) {
+  return (Array.isArray(value) ? value : []).flatMap((raw, index) => {
+    const text = normalizeCanonicalText(raw?.text);
+    if (!text) return [];
+    const speakerId = String(raw?.speakerId || '').trim() || 'speaker-unknown';
+    const number = speakerId.replace(/\D/g, '') || String(index + 1);
+    const startMs = Number(raw?.startMs);
+    const endMs = Number(raw?.endMs);
+    return [{
+      speakerId,
+      speakerName:
+        String(raw?.speakerName || '').trim() ||
+        (speakerId === 'speaker-unknown' ? '讲述者' : `说话人${number}`),
+      text,
+      timestamp: Number.isFinite(startMs) && startMs >= 0 ? startMs : null,
+      ...(Number.isFinite(startMs) && startMs >= 0 ? { startMs } : {}),
+      ...(Number.isFinite(endMs) && endMs >= 0 ? { endMs } : {}),
+    }];
+  });
+}
+
+export function buildCanonicalTranscript(textValue, segmentValue, completedAt = new Date().toISOString()) {
+  const transcription = normalizeCanonicalText(textValue);
+  if (!transcription) throw new Error('CANONICAL_TRANSCRIPT_EMPTY');
+  const providerSegments = normalizedSegments(segmentValue);
+  const joined = normalizeCanonicalText(providerSegments.map(item => item.text).join(''));
+  const transcriptionSegments = canonicalHash(joined) === canonicalHash(transcription)
+    ? providerSegments
+    : [{
+        speakerId: 'speaker-unknown',
+        speakerName: '讲述者',
+        text: transcription,
+        timestamp: 0,
+        synthetic: true,
+      }];
+  return {
+    transcription,
+    transcriptionSegments,
+    transcriptionSource: 'xunfei_long',
+    transcriptionStatus: 'completed',
+    transcriptionCompleteness: 'long_complete',
+    transcriptionHash: canonicalHash(transcription),
+    transcriptionCharCount: canonicalCharCount(transcription),
+    transcriptionSegmentCount: transcriptionSegments.length,
+    transcriptionCompletedAt: completedAt,
+    transcriptionSchemaVersion: TRANSCRIPT_SCHEMA_VERSION,
+  };
+}

+ 98 - 0
src/chatvoice-persistence.mjs

@@ -0,0 +1,98 @@
+import { AppError } from './errors.mjs';
+import { buildCanonicalTranscript } from './canonical-transcript.mjs';
+
+function sessionToken(authorization) {
+  return /^Bearer\s+(.+)$/i.exec(String(authorization || '').trim())?.[1]?.trim() || '';
+}
+
+export class ChatVoicePersistence {
+  constructor(config, fetchImpl = fetch) {
+    this.config = config;
+    this.fetchImpl = fetchImpl;
+    this.enabled = Boolean(config.canonicalPersistenceEnabled && config.parseMasterKey);
+    if (config.canonicalPersistenceEnabled && !config.parseMasterKey) {
+      throw new AppError('启用权威逐字稿落库时必须配置 PARSE_MASTER_KEY', {
+        code: 'CONFIGURATION_ERROR',
+        status: 500,
+      });
+    }
+  }
+
+  headers({ authorization, master = false } = {}) {
+    const headers = {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': this.config.parseAppId,
+    };
+    if (master) headers['X-Parse-Master-Key'] = this.config.parseMasterKey;
+    else headers['X-Parse-Session-Token'] = sessionToken(authorization);
+    return headers;
+  }
+
+  async validateAccess(chatVoiceId, authorization) {
+    if (!this.enabled || !chatVoiceId) return;
+    const response = await this.fetchImpl(
+      `${this.config.parseServerUrl}/classes/ChatVoice/${encodeURIComponent(chatVoiceId)}`,
+      {
+        headers: this.headers({ authorization }),
+        signal: AbortSignal.timeout(10_000),
+      },
+    ).catch(() => null);
+    if (!response?.ok) {
+      throw new AppError('无权访问指定录音或录音不存在', {
+        code: 'CHAT_VOICE_NOT_ACCESSIBLE',
+        status: response?.status === 404 ? 404 : 403,
+      });
+    }
+  }
+
+  async commit(job, result) {
+    const chatVoiceId = String(job?.request?.chatVoiceId || '').trim();
+    if (!this.enabled || !chatVoiceId) return { persisted: false, canonical: null };
+    const completedAt = new Date().toISOString();
+    const canonical = buildCanonicalTranscript(result.text, result.segments, completedAt);
+    const body = {
+      longTranscription: canonical.transcription,
+      longTranscriptionSegments: result.segments || [],
+      longTranscriptionStatus: 'completed',
+      transcriptionRevalidationStatus: 'completed',
+      transcriptionRevalidationUpdatedAt: { __type: 'Date', iso: completedAt },
+      transcriptionRevalidationErrorCode: { __op: 'Delete' },
+      transcriptionRevalidationError: { __op: 'Delete' },
+      speakerSegments: canonical.transcriptionSegments,
+      ...canonical,
+      transcriptionCompletedAt: { __type: 'Date', iso: completedAt },
+    };
+    const url = `${this.config.parseServerUrl}/classes/ChatVoice/${encodeURIComponent(chatVoiceId)}`;
+    const saved = await this.fetchImpl(url, {
+      method: 'PUT',
+      headers: this.headers({ master: true }),
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(20_000),
+    }).catch(() => null);
+    if (!saved?.ok) {
+      throw new AppError('权威逐字稿写入 ChatVoice 失败', {
+        code: 'CANONICAL_PERSISTENCE_FAILED',
+        status: 502,
+        retryable: true,
+      });
+    }
+    const readback = await this.fetchImpl(url, {
+      headers: this.headers({ master: true }),
+      signal: AbortSignal.timeout(10_000),
+    }).catch(() => null);
+    const record = readback?.ok ? await readback.json().catch(() => null) : null;
+    if (
+      !record ||
+      String(record.transcriptionHash || '') !== canonical.transcriptionHash ||
+      Number(record.transcriptionCharCount) !== canonical.transcriptionCharCount ||
+      String(record.transcriptionCompleteness || '') !== 'long_complete'
+    ) {
+      throw new AppError('权威逐字稿回读校验失败', {
+        code: 'CANONICAL_READBACK_MISMATCH',
+        status: 502,
+        retryable: true,
+      });
+    }
+    return { persisted: true, canonical };
+  }
+}

+ 8 - 1
src/config.mjs

@@ -47,6 +47,8 @@ export function loadConfig(env = process.env) {
 
   return {
     nodeEnv,
+    serviceVersion: String(env.SERVICE_VERSION || '1.1.0').trim(),
+    gitSha: String(env.GIT_SHA || '').trim().slice(0, 40),
     host: String(env.HOST || '127.0.0.1'),
     port: integer(env.PORT, 3200, { max: 65535 }),
     corsAllowedOrigins,
@@ -56,6 +58,7 @@ export function loadConfig(env = process.env) {
     authMode,
     parseServerUrl: String(env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse').replace(/\/+$/, ''),
     parseAppId: String(env.PARSE_APP_ID || 'ncloudmaster').trim(),
+    parseMasterKey: String(env.PARSE_MASTER_KEY || '').trim(),
     authCacheTtlMs: integer(env.AUTH_CACHE_TTL_MS, 60_000),
     iflytek: {
       appId: required('IFLYTEK_APP_ID', env),
@@ -74,9 +77,13 @@ export function loadConfig(env = process.env) {
     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),
+    jobRetentionMs: integer(env.JOB_RETENTION_MS, 30 * 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 }),
+    maxJobAttempts: integer(env.MAX_JOB_ATTEMPTS, 5, { max: 10 }),
+    retryBaseDelayMs: integer(env.RETRY_BASE_DELAY_MS, 5_000, { min: 100, max: 60 * 60_000 }),
+    canonicalPersistenceEnabled:
+      String(env.CANONICAL_PERSISTENCE_ENABLED || '').trim().toLowerCase() === 'true',
   };
 }

+ 49 - 8
src/http-app.mjs

@@ -39,6 +39,7 @@ function normalizedRequest(body) {
   const roleType = Number(body?.roleType);
   return {
     audioUrl: String(body?.audioUrl || '').trim(),
+    chatVoiceId: String(body?.chatVoiceId || '').trim().slice(0, 64) || null,
     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,
@@ -52,8 +53,10 @@ function idempotencyKey(ownerKey, requestId) {
 }
 
 function routeJobId(pathname) {
-  const match = /^\/recording-transcription\/jobs\/([0-9a-f-]+)(\/cancel)?$/.exec(pathname);
-  return match ? { id: match[1], cancel: Boolean(match[2]) } : null;
+  const match = /^\/recording-transcription\/jobs\/([0-9a-f-]+)(?:\/(cancel|retry))?$/.exec(
+    pathname,
+  );
+  return match ? { id: match[1], action: match[2] || 'get' } : null;
 }
 
 function configureCors(request, response, config) {
@@ -77,8 +80,15 @@ export function createHttpHandler({
   store,
   worker,
   authenticator,
+  persistence = { enabled: false, async validateAccess() {} },
   validateAudioUrl = validateRemoteAudioUrl,
 }) {
+  const publicView = job =>
+    publicJob(job, {
+      queuePosition:
+        typeof worker.queuePosition === 'function' ? worker.queuePosition(job.id) : null,
+    });
+
   return async function handle(request, response) {
     const requestId = randomUUID();
     response.setHeader('X-Request-Id', requestId);
@@ -95,6 +105,10 @@ export function createHttpHandler({
           success: true,
           service: 'yuban-server',
           status: 'ok',
+          version: config.serviceVersion || 'unknown',
+          gitSha: config.gitSha || null,
+          workerReady: true,
+          storeReady: true,
           capabilities: ['recording-transcription'],
           time: new Date().toISOString(),
         });
@@ -106,6 +120,7 @@ export function createHttpHandler({
         const body = await readJson(request);
         const input = normalizedRequest(body);
         await validateAudioUrl(input.audioUrl, config.allowedAudioHosts);
+        await persistence.validateAccess(input.chatVoiceId, request.headers.authorization);
         const key = idempotencyKey(
           identity.ownerKey,
           request.headers['idempotency-key'] || body.requestId,
@@ -116,7 +131,7 @@ export function createHttpHandler({
           !['failed', 'cancelled'].includes(existing.status) &&
           sameOwner(existing.ownerKey, identity.ownerKey)
         ) {
-          sendJson(response, 202, { success: true, reused: true, job: publicJob(existing) });
+          sendJson(response, 202, { success: true, reused: true, job: publicView(existing) });
           return;
         }
 
@@ -145,32 +160,58 @@ export function createHttpHandler({
           status: 'queued',
           stage: 'queued',
           progress: 0,
+          attempt: 0,
+          runAttempt: 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) });
+        console.info('[yuban-server] transcription job created', {
+          requestId,
+          jobId: job.id,
+          durationMs: input.durationMs,
+        });
+        sendJson(response, 202, { success: true, reused: false, job: publicView(job) });
         return;
       }
 
       const jobRoute = routeJobId(url.pathname);
-      if (jobRoute && request.method === 'GET' && !jobRoute.cancel) {
+      if (jobRoute && request.method === 'GET' && jobRoute.action === 'get') {
         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) });
+        sendJson(response, 200, { success: true, job: publicView(job) });
         return;
       }
-      if (jobRoute && request.method === 'POST' && jobRoute.cancel) {
+      if (jobRoute && request.method === 'POST' && jobRoute.action === '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) });
+        sendJson(response, 200, { success: true, job: publicView(cancelled) });
+        return;
+      }
+      if (jobRoute && request.method === 'POST' && jobRoute.action === 'retry') {
+        const job = store.get(jobRoute.id);
+        if (!job || !sameOwner(job.ownerKey, identity.ownerKey)) {
+          throw new AppError('转写任务不存在', { code: 'JOB_NOT_FOUND', status: 404 });
+        }
+        const activeJobs = store
+          .listByOwner(identity.ownerKey)
+          .filter(item => ['queued', 'running', 'retry_wait'].includes(item.status));
+        if (activeJobs.length >= config.maxActiveJobsPerUser) {
+          throw new AppError('当前正在处理的长录音任务过多,请等待已有任务完成', {
+            code: 'TOO_MANY_ACTIVE_JOBS',
+            status: 429,
+            retryable: true,
+          });
+        }
+        const retried = await worker.retry(job.id);
+        sendJson(response, 202, { success: true, reused: false, job: publicView(retried) });
         return;
       }
       throw new AppError('接口不存在', { code: 'NOT_FOUND', status: 404 });

+ 7 - 3
src/index.mjs

@@ -3,7 +3,7 @@ import { loadLocalEnv } from './load-env.mjs';
 
 await loadLocalEnv();
 
-const [{ loadConfig }, { Authenticator }, { IflytekIstClient }, { JobStore }, { TranscriptionWorker }, { createHttpHandler }] =
+const [{ loadConfig }, { Authenticator }, { IflytekIstClient }, { JobStore }, { TranscriptionWorker }, { createHttpHandler }, { ChatVoicePersistence }] =
   await Promise.all([
     import('./config.mjs'),
     import('./auth.mjs'),
@@ -11,6 +11,7 @@ const [{ loadConfig }, { Authenticator }, { IflytekIstClient }, { JobStore }, {
     import('./job-store.mjs'),
     import('./transcription-worker.mjs'),
     import('./http-app.mjs'),
+    import('./chatvoice-persistence.mjs'),
   ]);
 
 const config = loadConfig();
@@ -18,11 +19,14 @@ const store = new JobStore(config.jobStoreDir, config.jobRetentionMs);
 await store.init();
 
 const provider = new IflytekIstClient(config);
-const worker = new TranscriptionWorker({ config, store, provider });
+const persistence = new ChatVoicePersistence(config);
+const worker = new TranscriptionWorker({ config, store, provider, persistence });
 await worker.start();
 
 const authenticator = new Authenticator(config);
-const server = createServer(createHttpHandler({ config, store, worker, authenticator }));
+const server = createServer(
+  createHttpHandler({ config, store, worker, authenticator, persistence }),
+);
 const cleanupTimer = setInterval(() => void store.cleanupExpired(), 60 * 60_000);
 cleanupTimer.unref();
 

+ 17 - 4
src/job-store.mjs

@@ -1,3 +1,4 @@
+import { randomUUID } from 'node:crypto';
 import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 
@@ -40,7 +41,9 @@ export class JobStore {
   }
 
   listRecoverable() {
-    return [...this.jobs.values()].filter(job => ['queued', 'running'].includes(job.status));
+    return [...this.jobs.values()].filter(job =>
+      ['queued', 'running', 'retry_wait'].includes(job.status),
+    );
   }
 
   listByOwner(ownerKey) {
@@ -69,7 +72,10 @@ export class JobStore {
 
   async persist(job) {
     const finalPath = join(this.directory, `${job.id}.json`);
-    const temporaryPath = join(this.directory, `.${job.id}.${process.pid}.tmp`);
+    const temporaryPath = join(
+      this.directory,
+      `.${job.id}.${process.pid}.${randomUUID()}.tmp`,
+    );
     await writeFile(temporaryPath, JSON.stringify(job), { mode: 0o600 });
     await rename(temporaryPath, finalPath);
   }
@@ -87,7 +93,7 @@ export class JobStore {
   }
 }
 
-export function publicJob(job) {
+export function publicJob(job, extras = {}) {
   return {
     id: job.id,
     status: job.status,
@@ -96,8 +102,15 @@ export function publicJob(job) {
     createdAt: job.createdAt,
     updatedAt: job.updatedAt,
     heartbeatAt: job.heartbeatAt || null,
+    attempt: Math.max(0, Number(job.attempt || 0)),
+    parentJobId: job.parentJobId || null,
+    nextRetryAt: job.nextRetryAt || null,
+    queuePosition: Number.isFinite(extras.queuePosition) ? extras.queuePosition : null,
+    canRetry:
+      ['failed', 'cancelled'].includes(job.status) &&
+      Boolean(job.request?.audioUrl),
     expiresAt: job.expiresAt,
     result: job.status === 'completed' ? job.result : undefined,
-    error: job.status === 'failed' ? job.error : undefined,
+    error: ['failed', 'retry_wait'].includes(job.status) ? job.error : undefined,
   };
 }

+ 153 - 17
src/transcription-worker.mjs

@@ -1,8 +1,9 @@
-import { createHash } from 'node:crypto';
+import { randomUUID } 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 { buildCanonicalTranscript } from './canonical-transcript.mjs';
 import { downloadRemoteAudio, probeDurationMs, transcodeToPcmWav } from './media.mjs';
 
 function sleep(ms) {
@@ -14,12 +15,23 @@ function normalizedTranscript(text) {
 }
 
 export class TranscriptionWorker {
-  constructor({ config, store, provider }) {
+  constructor({
+    config,
+    store,
+    provider,
+    persistence = { enabled: false, async commit() { return { persisted: false }; } },
+    media = { downloadRemoteAudio, probeDurationMs, transcodeToPcmWav },
+    sleepFn = sleep,
+  }) {
     this.config = config;
     this.store = store;
     this.provider = provider;
+    this.persistence = persistence;
+    this.media = media;
+    this.sleep = sleepFn;
     this.queue = [];
     this.queuedIds = new Set();
+    this.retryTimers = new Map();
     this.active = 0;
   }
 
@@ -28,6 +40,22 @@ export class TranscriptionWorker {
   }
 
   enqueue(jobId) {
+    const job = this.store.get(jobId);
+    if (!job || ['completed', 'failed', 'cancelled'].includes(job.status)) return;
+    const retryAt = Date.parse(job.nextRetryAt || '');
+    if (job.status === 'retry_wait' && Number.isFinite(retryAt) && retryAt > Date.now()) {
+      if (this.retryTimers.has(jobId)) return;
+      const timer = setTimeout(() => {
+        this.retryTimers.delete(jobId);
+        this.enqueue(jobId);
+      }, retryAt - Date.now());
+      timer.unref?.();
+      this.retryTimers.set(jobId, timer);
+      return;
+    }
+    const timer = this.retryTimers.get(jobId);
+    if (timer) clearTimeout(timer);
+    this.retryTimers.delete(jobId);
     if (this.queuedIds.has(jobId)) return;
     this.queuedIds.add(jobId);
     this.queue.push(jobId);
@@ -37,6 +65,9 @@ export class TranscriptionWorker {
   async cancel(jobId) {
     const job = this.store.get(jobId);
     if (!job || ['completed', 'failed', 'cancelled'].includes(job.status)) return job;
+    const timer = this.retryTimers.get(jobId);
+    if (timer) clearTimeout(timer);
+    this.retryTimers.delete(jobId);
     return this.store.update(jobId, {
       status: 'cancelled',
       stage: 'cancelled',
@@ -45,6 +76,48 @@ export class TranscriptionWorker {
     });
   }
 
+  queuePosition(jobId) {
+    const index = this.queue.indexOf(jobId);
+    return index >= 0 ? index + 1 : null;
+  }
+
+  async retry(jobId) {
+    const previous = this.store.get(jobId);
+    if (
+      !previous ||
+      !['failed', 'cancelled'].includes(previous.status) ||
+      !previous.request?.audioUrl
+    ) {
+      throw new AppError('当前任务不能重新执行', {
+        code: 'JOB_NOT_RETRYABLE',
+        status: 409,
+      });
+    }
+    const now = new Date();
+    const job = await this.store.create({
+      id: randomUUID(),
+      ownerKey: previous.ownerKey,
+      idempotencyKey: previous.idempotencyKey,
+      parentJobId: previous.id,
+      status: 'queued',
+      stage: 'queued',
+      progress: 0,
+      attempt: Math.max(0, Number(previous.attempt || 0)),
+      runAttempt: 0,
+      request: previous.request,
+      createdAt: now.toISOString(),
+      updatedAt: now.toISOString(),
+      expiresAt: new Date(now.getTime() + this.config.jobRetentionMs).toISOString(),
+    });
+    console.info('[yuban-server] transcription job manually retried', {
+      jobId: job.id,
+      parentJobId: previous.id,
+      attempt: job.attempt + 1,
+    });
+    this.enqueue(job.id);
+    return job;
+  }
+
   async drain() {
     while (this.active < this.config.maxJobConcurrency && this.queue.length) {
       const jobId = this.queue.shift();
@@ -74,8 +147,24 @@ export class TranscriptionWorker {
   async run(jobId) {
     let job = this.store.get(jobId);
     if (!job || job.status === 'cancelled' || job.status === 'completed') return;
+    const retryAt = Date.parse(job.nextRetryAt || '');
+    if (job.status === 'retry_wait' && Number.isFinite(retryAt) && retryAt > Date.now()) {
+      this.enqueue(jobId);
+      return;
+    }
     let workDirectory;
     try {
+      job = await this.checkpoint(jobId, {
+        attempt: Math.max(0, Number(job.attempt || 0)) + 1,
+        runAttempt: Math.max(0, Number(job.runAttempt || 0)) + 1,
+        nextRetryAt: null,
+        error: undefined,
+      });
+      console.info('[yuban-server] transcription job started', {
+        jobId,
+        attempt: job.attempt,
+        resumedProviderOrder: Boolean(job.providerOrderId),
+      });
       if (!job.providerOrderId) {
         await mkdir(this.config.jobTempDir || tmpdir(), { recursive: true });
         workDirectory = await mkdtemp(join(this.config.jobTempDir || tmpdir(), `${job.id}-`));
@@ -83,10 +172,10 @@ export class TranscriptionWorker {
         const wavPath = join(workDirectory, 'recording.wav');
 
         await this.checkpoint(jobId, { stage: 'downloading', progress: 10 });
-        await downloadRemoteAudio(job.request.audioUrl, sourcePath, this.config);
+        await this.media.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.media.transcodeToPcmWav(sourcePath, wavPath, this.config);
+        const durationMs = await this.media.probeDurationMs(wavPath, this.config);
         await this.checkpoint(jobId, { stage: 'submitting', progress: 40, durationMs });
         const submitted = await this.provider.submit(wavPath, {
           durationMs,
@@ -111,7 +200,14 @@ export class TranscriptionWorker {
           transientFailures = 0;
           if (response.status === 'completed') {
             const text = normalizedTranscript(response.result.text);
+            job = await this.checkpoint(jobId, { stage: 'persisting', progress: 95 });
+            const persistence = await this.persistence.commit(job, {
+              text,
+              segments: response.result.segments,
+            });
             const completedAt = new Date().toISOString();
+            const canonical = persistence?.canonical ||
+              buildCanonicalTranscript(text, response.result.segments, completedAt);
             await this.store.update(jobId, {
               status: 'completed',
               stage: 'completed',
@@ -119,12 +215,21 @@ export class TranscriptionWorker {
               heartbeatAt: completedAt,
               completedAt,
               result: {
-                text,
+                text: canonical.transcription,
                 segments: response.result.segments,
-                charCount: text.length,
-                sha256: createHash('sha256').update(text, 'utf8').digest('hex'),
+                charCount: canonical.transcriptionCharCount,
+                sha256: canonical.transcriptionHash,
+                canonicalPersisted: Boolean(persistence?.persisted),
               },
               request: undefined,
+              nextRetryAt: null,
+              error: undefined,
+            });
+            console.info('[yuban-server] transcription job completed', {
+              jobId,
+              attempt: job.attempt,
+              durationMs: job.durationMs || null,
+              charCount: canonical.transcriptionCharCount,
             });
             return;
           }
@@ -138,7 +243,7 @@ export class TranscriptionWorker {
           transientFailures += 1;
         }
         const elapsed = Date.now() - pollStartedAt;
-        await sleep(elapsed < 60_000 ? 5_000 : elapsed < 10 * 60_000 ? 15_000 : 30_000);
+        await this.sleep(elapsed < 60_000 ? 5_000 : elapsed < 10 * 60_000 ? 15_000 : 30_000);
       }
       throw new AppError('长录音转写仍在处理中,请重新查询或稍后重试', {
         code: 'PROVIDER_TIMEOUT',
@@ -149,16 +254,47 @@ export class TranscriptionWorker {
       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: {
+        const errorView = {
+          code: appError.code,
+          message: appError.message,
+          retryable: appError.retryable,
+        };
+        const runAttempt = Math.max(1, Number(latest.runAttempt || 1));
+        if (appError.retryable && runAttempt < this.config.maxJobAttempts) {
+          const delayMs = Math.min(
+            15 * 60_000,
+            this.config.retryBaseDelayMs * 2 ** Math.max(0, runAttempt - 1),
+          );
+          const nextRetryAt = new Date(Date.now() + delayMs).toISOString();
+          await this.store.update(jobId, {
+            status: 'retry_wait',
+            stage: 'retry_wait',
+            heartbeatAt: new Date().toISOString(),
+            nextRetryAt,
+            error: errorView,
+          });
+          console.warn('[yuban-server] transcription job scheduled for retry', {
+            jobId,
+            attempt: latest.attempt,
+            code: appError.code,
+            nextRetryAt,
+          });
+          this.enqueue(jobId);
+        } else {
+          await this.store.update(jobId, {
+            status: 'failed',
+            stage: 'failed',
+            heartbeatAt: new Date().toISOString(),
+            nextRetryAt: null,
+            error: errorView,
+          });
+          console.error('[yuban-server] transcription job failed', {
+            jobId,
+            attempt: latest.attempt,
             code: appError.code,
-            message: appError.message,
             retryable: appError.retryable,
-          },
-        });
+          });
+        }
       }
     } finally {
       if (workDirectory) await rm(workDirectory, { recursive: true, force: true });

+ 49 - 0
test/chatvoice-persistence.test.mjs

@@ -0,0 +1,49 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { buildCanonicalTranscript } from '../src/canonical-transcript.mjs';
+import { ChatVoicePersistence } from '../src/chatvoice-persistence.mjs';
+
+test('canonical 字数和哈希规则与故事素材校验一致', () => {
+  const canonical = buildCanonicalTranscript(
+    '第一段。  \n\n\n第二段。',
+    [{ text: '不完整分段', speakerId: '1', startMs: 0, endMs: 1000 }],
+    '2026-08-26T00:00:00.000Z',
+  );
+  assert.equal(canonical.transcription, '第一段。\n\n第二段。');
+  assert.equal(canonical.transcriptionCharCount, 8);
+  assert.equal(canonical.transcriptionSegments.length, 1);
+  assert.equal(canonical.transcriptionSegments[0].synthetic, true);
+  assert.match(canonical.transcriptionHash, /^[0-9a-f]{64}$/);
+});
+
+test('服务端写入 ChatVoice 后必须通过 hash 和字数回读', async () => {
+  let stored = null;
+  const fetchImpl = async (_url, options = {}) => {
+    if (options.method === 'PUT') {
+      stored = JSON.parse(options.body);
+      return { ok: true, status: 200, async json() { return { updatedAt: 'now' }; } };
+    }
+    return {
+      ok: true,
+      status: 200,
+      async json() { return stored || { objectId: 'voice-1' }; },
+    };
+  };
+  const persistence = new ChatVoicePersistence({
+    canonicalPersistenceEnabled: true,
+    parseMasterKey: 'test-master-key',
+    parseServerUrl: 'https://parse.example.test/parse',
+    parseAppId: 'test-app',
+  }, fetchImpl);
+  await persistence.validateAccess('voice-1', 'Bearer session-token');
+  const result = await persistence.commit({
+    request: { chatVoiceId: 'voice-1' },
+  }, {
+    text: '完整逐字稿',
+    segments: [{ text: '完整逐字稿', speakerId: '1', startMs: 0, endMs: 1000 }],
+  });
+  assert.equal(result.persisted, true);
+  assert.equal(stored.transcriptionStatus, 'completed');
+  assert.equal(stored.transcriptionCompleteness, 'long_complete');
+  assert.equal(stored.transcriptionRevalidationStatus, 'completed');
+});

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

@@ -12,6 +12,7 @@ async function fixture() {
   const store = new JobStore(directory, 86_400_000);
   await store.init();
   const enqueued = [];
+  let retrySequence = 0;
   const worker = {
     enqueue(id) {
       enqueued.push(id);
@@ -19,6 +20,25 @@ async function fixture() {
     async cancel(id) {
       return store.update(id, { status: 'cancelled', stage: 'cancelled' });
     },
+    async retry(id) {
+      const previous = store.get(id);
+      retrySequence += 1;
+      const now = new Date();
+      const next = await store.create({
+        ...previous,
+        id: `00000000-0000-4000-8000-${String(retrySequence).padStart(12, '0')}`,
+        parentJobId: previous.id,
+        status: 'queued',
+        stage: 'queued',
+        progress: 0,
+        runAttempt: 0,
+        createdAt: now.toISOString(),
+        updatedAt: now.toISOString(),
+        expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
+      });
+      enqueued.push(next.id);
+      return next;
+    },
   };
   const config = {
     corsAllowedOrigins: [
@@ -146,3 +166,28 @@ test('同一用户和幂等键复用已有任务', async t => {
   assert.equal(second.reused, true);
   assert.equal(app.enqueued.length, 1);
 });
+
+test('失败任务通过 retry 接口创建新的 attempt,而不是继续查询旧 jobId', async t => {
+  const app = await fixture();
+  t.after(() => app.close());
+  const created = await (await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'retry-recording' },
+    body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
+  })).json();
+  await app.store.update(created.job.id, {
+    status: 'failed',
+    stage: 'failed',
+    error: { code: 'NETWORK', message: '网络错误', retryable: true },
+  });
+
+  const response = await fetch(
+    `${app.baseUrl}/recording-transcription/jobs/${created.job.id}/retry`,
+    { method: 'POST' },
+  );
+  const body = await response.json();
+  assert.equal(response.status, 202);
+  assert.notEqual(body.job.id, created.job.id);
+  assert.equal(body.job.parentJobId, created.job.id);
+  assert.equal(body.job.status, 'queued');
+});

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

@@ -29,3 +29,25 @@ test('清理旧重试任务时不删除新任务的幂等映射', async t => {
   await store.cleanupExpired(Date.parse('2026-08-06T00:00:00.000Z'));
   assert.equal(store.findByIdempotency('same-key')?.id, 'new');
 });
+
+test('同一任务的并发状态写入使用独立临时文件', async t => {
+  const directory = await mkdtemp(join(tmpdir(), 'yuban-job-store-race-test-'));
+  t.after(() => rm(directory, { recursive: true, force: true }));
+  const store = new JobStore(directory, 86_400_000);
+  await store.init();
+  const now = new Date().toISOString();
+  await store.create({
+    id: 'race-job',
+    status: 'running',
+    stage: 'transcribing',
+    progress: 50,
+    createdAt: now,
+    updatedAt: now,
+    expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
+  });
+  await Promise.all([
+    store.update('race-job', { progress: 60 }),
+    store.update('race-job', { heartbeatAt: now }),
+  ]);
+  assert.equal(store.get('race-job').id, 'race-job');
+});

+ 130 - 0
test/transcription-worker.test.mjs

@@ -0,0 +1,130 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { AppError } from '../src/errors.mjs';
+import { JobStore } from '../src/job-store.mjs';
+import { TranscriptionWorker } from '../src/transcription-worker.mjs';
+
+async function waitFor(predicate, timeoutMs = 2_000) {
+  const startedAt = Date.now();
+  while (Date.now() - startedAt < timeoutMs) {
+    const value = predicate();
+    if (value) return value;
+    await new Promise(resolve => setTimeout(resolve, 5));
+  }
+  throw new Error('等待任务状态超时');
+}
+
+async function fixture(t, provider) {
+  const directory = await mkdtemp(join(tmpdir(), 'yuban-worker-test-'));
+  t.after(() => rm(directory, { recursive: true, force: true }));
+  const store = new JobStore(directory, 86_400_000);
+  await store.init();
+  const config = {
+    maxJobConcurrency: 1,
+    maxJobAttempts: 3,
+    retryBaseDelayMs: 5,
+    jobRetentionMs: 86_400_000,
+    jobTempDir: directory,
+    iflytek: { pollTimeoutMs: 1_000 },
+  };
+  const media = {
+    async downloadRemoteAudio() {},
+    async transcodeToPcmWav() {},
+    async probeDurationMs() { return 1_800_000; },
+  };
+  const worker = new TranscriptionWorker({
+    config,
+    store,
+    provider,
+    media,
+    sleepFn: async () => {},
+  });
+  return { store, worker };
+}
+
+async function createJob(store, overrides = {}) {
+  const now = new Date();
+  return store.create({
+    id: overrides.id || '00000000-0000-4000-8000-000000000001',
+    ownerKey: 'owner',
+    idempotencyKey: 'stable-key',
+    status: 'queued',
+    stage: 'queued',
+    progress: 0,
+    attempt: 0,
+    runAttempt: 0,
+    request: {
+      audioUrl: 'https://file.yuban.co/recording.webm',
+      durationMs: 1_800_000,
+      roleType: 1,
+      roleNum: 0,
+    },
+    createdAt: now.toISOString(),
+    updatedAt: now.toISOString(),
+    expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
+    ...overrides,
+  });
+}
+
+test('可重试错误进入 retry_wait,并由 Worker 自动完成下一次 attempt', async t => {
+  let submitCount = 0;
+  const provider = {
+    async submit() {
+      submitCount += 1;
+      if (submitCount === 1) {
+        throw new AppError('讯飞暂时不可用', {
+          code: 'PROVIDER_TEMPORARY',
+          status: 502,
+          retryable: true,
+        });
+      }
+      return { orderId: 'provider-order-1', estimateTimeMs: 1 };
+    },
+    async getResult() {
+      return {
+        status: 'completed',
+        result: {
+          text: '中断前后的完整逐字稿',
+          segments: [{ text: '中断前后的完整逐字稿', startMs: 0, endMs: 1000, speakerId: '1' }],
+        },
+      };
+    },
+  };
+  const { store, worker } = await fixture(t, provider);
+  const job = await createJob(store);
+  worker.enqueue(job.id);
+
+  const completed = await waitFor(() => {
+    const current = store.get(job.id);
+    return current?.status === 'completed' ? current : null;
+  });
+  assert.equal(submitCount, 2);
+  assert.equal(completed.attempt, 2);
+  assert.equal(completed.result.text, '中断前后的完整逐字稿');
+  assert.equal(completed.error, undefined);
+});
+
+test('人工重试失败任务会创建新的 jobId 并保留父任务', async t => {
+  const provider = {
+    async submit() { return { orderId: 'unused', estimateTimeMs: 1 }; },
+    async getResult() { return { status: 'pending' }; },
+  };
+  const { store, worker } = await fixture(t, provider);
+  const failed = await createJob(store, {
+    status: 'failed',
+    stage: 'failed',
+    attempt: 3,
+    runAttempt: 3,
+    error: { code: 'NETWORK', message: '网络错误', retryable: true },
+  });
+  const retried = await worker.retry(failed.id);
+  assert.notEqual(retried.id, failed.id);
+  assert.equal(retried.parentJobId, failed.id);
+  assert.equal(retried.status, 'queued');
+  assert.equal(retried.attempt, 3);
+  assert.equal(store.findByIdempotency('stable-key').id, retried.id);
+  await worker.cancel(retried.id);
+});