Преглед изворни кода

feat: 新增讯飞语音转文字(ASR)接口模块

- api/module/asr/iflytek.ts: 讯飞录音文件转写大模型客户端(签名/HMAC-SHA1/上传/查询)
- api/module/asr/routes.ts: Express路由(文件上传/base64/查询结果)
- api/routes.ts: 注册 /api/asr 子路由
- 新增依赖: multer(文件上传中间件)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cb пре 2 месеци
родитељ
комит
01769f6483
5 измењених фајлова са 435 додато и 2 уклоњено
  1. 180 0
      api/module/asr/iflytek.ts
  2. 79 0
      api/module/asr/routes.ts
  3. 3 0
      api/routes.ts
  4. 171 1
      package-lock.json
  5. 2 1
      package.json

+ 180 - 0
api/module/asr/iflytek.ts

@@ -0,0 +1,180 @@
+// 讯飞录音文件转写大模型 WebAPI 客户端
+// API文档: https://www.xfyun.cn/doc/spark/asr_llm/Ifasr_llm.html
+import crypto from 'crypto';
+
+interface AsrConfig {
+  appId: string;
+  accessKeyId: string;
+  apiSecret: string;
+  host: string;
+}
+
+interface CreateTaskParams {
+  audio: Buffer;
+  fileName: string;
+  language?: string;
+}
+
+interface TaskResult {
+  orderId: string;
+  status: 'waiting' | 'processing' | 'completed' | 'failed';
+  text?: string;
+  error?: string;
+}
+
+function randomString(len: number): string {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
+}
+
+function formatDateTime(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}+0800`;
+}
+
+export class IflytekAsr {
+  private config: AsrConfig;
+  private baseUrl: string;
+
+  constructor(config: AsrConfig) {
+    this.config = config;
+    this.baseUrl = config.host.replace(/\/$/, '');
+  }
+
+  // 标准签名: 对所有URL参数(除signature)排序 → URL编码 → &拼接 → HMAC-SHA1 → Base64
+  private sign(params: Record<string, string>): string {
+    // 1. 排除 signature,按 key 排序
+    const sorted = Object.keys(params)
+      .filter(k => k !== 'signature' && params[k] != null && params[k] !== '')
+      .sort()
+      .map(k => {
+        const encodedKey = encodeURIComponent(k);
+        const encodedVal = encodeURIComponent(params[k]);
+        return `${encodedKey}=${encodedVal}`;
+      })
+      .join('&');
+
+    console.log('[ASR] sign raw:', sorted);
+
+    // 2. HMAC-SHA1 → Base64
+    const hmac = crypto.createHmac('sha1', this.config.apiSecret);
+    return hmac.update(sorted).digest('base64');
+  }
+
+  // 创建转写任务
+  async createTask(params: CreateTaskParams): Promise<string> {
+    const dateTime = formatDateTime(new Date());
+    const signatureRandom = randomString(16);
+    const fileSize = params.audio.length.toString();
+    const language = params.language || 'autodialect';
+
+    // 构建所有参数
+    const queryParams: Record<string, string> = {
+      appId: this.config.appId,
+      accessKeyId: this.config.accessKeyId,
+      dateTime,
+      signatureRandom,
+      fileSize,
+      fileName: params.fileName,
+      language,
+      durationCheckDisable: 'true',  // 关闭时长校验
+    };
+
+    // 生成签名 (不含 signature 字段)
+    const signature = this.sign(queryParams);
+
+    // 构建 URL — 使用与签名相同的编码方式
+    const sortedKeys = Object.keys(queryParams).filter(k => queryParams[k] != null && queryParams[k] !== '').sort();
+    const qs = sortedKeys.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`).join('&');
+    const url = `${this.baseUrl}/v2/upload?${qs}`;
+
+    console.log('[ASR] upload URL:', url);
+
+    const res = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/octet-stream',
+        'signature': signature,
+      },
+      body: new Uint8Array(params.audio),
+    });
+
+    const text = await res.text();
+    console.log('[ASR] upload response:', text.slice(0, 500));
+
+    let json: any;
+    try { json = JSON.parse(text); } catch { throw new Error(`响应解析失败: ${text.slice(0, 200)}`); }
+
+    if (json.code !== '000000') {
+      throw new Error(`讯飞ASR上传失败: [${json.code}] ${json.descInfo || ''}`);
+    }
+
+    const orderId = json.content?.orderId;
+    if (!orderId) throw new Error('未获取到 orderId');
+    return orderId;
+  }
+
+  // 查询转写结果
+  async getResult(orderId: string): Promise<TaskResult> {
+    const dateTime = formatDateTime(new Date());
+    const signatureRandom = randomString(16);
+
+    const queryParams: Record<string, string> = {
+      appId: this.config.appId,
+      accessKeyId: this.config.accessKeyId,
+      dateTime,
+      signatureRandom,
+      orderId,
+      resultType: 'transfer',
+    };
+
+    const signature = this.sign(queryParams);
+
+    const sortedKeys = Object.keys(queryParams).filter(k => queryParams[k] != null && queryParams[k] !== '').sort();
+    const qs = sortedKeys.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`).join('&');
+    const url = `${this.baseUrl}/v2/getResult?${qs}`;
+
+    const res = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'signature': signature,
+      },
+    });
+
+    const text = await res.text();
+
+    let json: any;
+    try { json = JSON.parse(text); } catch { throw new Error(`结果解析失败: ${text.slice(0, 200)}`); }
+
+    if (json.code !== '000000') {
+      if (json.code === '000001') return { orderId, status: 'processing' };
+      return { orderId, status: 'failed', error: `[${json.code}] ${json.descInfo || ''}` };
+    }
+
+    const content = json.content || {};
+    let textResult = '';
+
+    if (content.orderResult) {
+      try {
+        const resultObj = typeof content.orderResult === 'string'
+          ? JSON.parse(content.orderResult) : content.orderResult;
+        if (resultObj.lattice && Array.isArray(resultObj.lattice)) {
+          textResult = resultObj.lattice
+            .map((seg: any) => {
+              const jb = seg.json_1best || '';
+              try {
+                const b = JSON.parse(jb);
+                return b.st?.rt?.[0]?.ws?.map((w: any) => w.cw?.[0]?.w || '').join('') || '';
+              } catch { return seg.onebest || ''; }
+            })
+            .join('');
+        }
+      } catch {
+        textResult = typeof content.orderResult === 'string' ? content.orderResult : '';
+      }
+    }
+
+    return { orderId, status: 'completed', text: textResult || content.descInfo || '' };
+  }
+}

+ 79 - 0
api/module/asr/routes.ts

@@ -0,0 +1,79 @@
+import express from 'express';
+import multer from 'multer';
+import path from 'path';
+import os from 'os';
+import fs from 'fs';
+import { IflytekAsr } from './iflytek.js';
+
+const router = express.Router();
+
+const asr = new IflytekAsr({
+  appId: '783cfeb8',
+  accessKeyId: '5d58267f04be61379a33cf74744377d6',
+  apiSecret: 'YTBkZGIyMTk2MDYyOGIyYTQzNTEwZjZm',
+  host: 'https://office-api-ist-dx.iflyaisol.com',
+});
+
+const upload = multer({
+  dest: path.join(os.tmpdir(), 'asr-uploads'),
+  limits: { fileSize: 500 * 1024 * 1024 },
+});
+
+// POST /api/asr/transcribe — 上传音频 → 创建转写任务
+router.post('/transcribe', upload.single('audio'), async (req, res) => {
+  try {
+    const file = req.file;
+    if (!file) {
+      return res.status(400).json({ success: false, error: '请上传音频文件' });
+    }
+
+    const audioBuffer = fs.readFileSync(file.path);
+    const language = req.body.language || 'autodialect';
+
+    const orderId = await asr.createTask({
+      audio: audioBuffer,
+      fileName: file.originalname || 'audio.wav',
+      language,
+    });
+
+    fs.unlink(file.path, () => {});
+
+    res.json({ success: true, data: { orderId, fileName: file.originalname, size: file.size } });
+  } catch (e: any) {
+    if (req.file) fs.unlink(req.file.path, () => {});
+    res.status(500).json({ success: false, error: e.message });
+  }
+});
+
+// POST /api/asr/transcribe-base64 — base64 上传
+router.post('/transcribe-base64', async (req, res) => {
+  try {
+    const { audio, fileName, language } = req.body;
+    if (!audio) {
+      return res.status(400).json({ success: false, error: '缺少 base64 音频数据' });
+    }
+
+    const audioBuffer = Buffer.from(audio, 'base64');
+    const orderId = await asr.createTask({
+      audio: audioBuffer,
+      fileName: fileName || 'audio.wav',
+      language: language || 'autodialect',
+    });
+
+    res.json({ success: true, data: { orderId } });
+  } catch (e: any) {
+    res.status(500).json({ success: false, error: e.message });
+  }
+});
+
+// GET /api/asr/result/:orderId — 查询转写结果
+router.get('/result/:orderId', async (req, res) => {
+  try {
+    const result = await asr.getResult(req.params.orderId);
+    res.json({ success: true, data: result });
+  } catch (e: any) {
+    res.status(500).json({ success: false, error: e.message });
+  }
+});
+
+export default router;

+ 3 - 0
api/routes.ts

@@ -8,6 +8,7 @@ import financeRoutes from './module/finance/routes.js';
 import storeRoutes from './module/store/routes.js';
 import logRoutes from './module/log/routes.js';
 import authRoutes from './module/auth/routes.js';
+import asrRoutes from './module/asr/routes.js';
 
 // 挂载子路由
 router.use('/order', orderRoutes);
@@ -16,6 +17,7 @@ router.use('/finance', financeRoutes);
 router.use('/store', storeRoutes);
 router.use('/log', logRoutes);
 router.use('/auth', authRoutes);
+router.use('/asr', asrRoutes);
 
 // GET /api — 索引
 router.get('/', (req, res) => {
@@ -28,6 +30,7 @@ router.get('/', (req, res) => {
       '/api/store',
       '/api/log',
       '/api/auth',
+      '/api/asr',
     ],
   });
 });

+ 171 - 1
package-lock.json

@@ -9,7 +9,8 @@
       "version": "1.0.0",
       "license": "ISC",
       "dependencies": {
-        "@fmode/studio": "^0.0.12"
+        "@fmode/studio": "^0.0.12",
+        "multer": "^2.2.0"
       }
     },
     "node_modules/@fmode/studio": {
@@ -38,6 +39,29 @@
         "node": ">=18.0.0"
       }
     },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
     "node_modules/commander": {
       "version": "14.0.3",
       "resolved": "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz",
@@ -46,6 +70,152 @@
       "engines": {
         "node": ">=20"
       }
+    },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/multer": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/multer/-/multer-2.2.0.tgz",
+      "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
     }
   }
 }

+ 2 - 1
package.json

@@ -10,6 +10,7 @@
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "dependencies": {
-    "@fmode/studio": "^0.0.12"
+    "@fmode/studio": "^0.0.12",
+    "multer": "^2.2.0"
   }
 }