跳到正文
起步应用与指南

端到端教程·后端篇

不装任何第三方依赖,用 Node.js 20 原生能力从零搭一个合作方后端:缓存并刷新合作方令牌、给 App 签发用户令牌、接收上传回报并驱动转写直到落库。

开发者平台处于内测阶段,接口契约以联调时提供的正式文档为准。账号由对接工程师开通,详见联系我们

本篇带你从空目录开始,搭出一个可以直接跑的合作方后端。它只用 Node.js 内置的 fetchnode:http,不装任何第三方依赖,共四个源文件、三个对外接口。走完本篇,你的 App 只需要做三件事:启动时向 /token 换用户令牌;直传完成后把 DownloadUrl 回报给 /recordings/complete;再轮询 /recordings/{id}/transcript 拿逐字稿。其余全部由后端完成。

职责划分

先明确前后端各自经手什么。核心原则只有一条:三个凭证(client_idsecretapi_key)只存在于后端,其中 secret 与 api_key 是机密;App 只能拿到用户令牌。

事项App(你的前端)你的后端(本篇)
持有的凭证仅用户令牌client_idsecretapi_key
令牌调你后端的 /token 换用户令牌缓存合作方令牌,到期前刷新,再为用户签发
文件上传用用户令牌走文件上传 API 直传存储,拿到 DownloadUrl不经手音频字节
转写DownloadUrl 回报给后端提交转写任务、轮询到终态、落库
结果查询轮询你后端的 /recordings/{id}/transcript从库里读出状态与 segments 返回

前置条件

  • Node.js 20.6 及以上:内置 fetch--env-file,本篇零依赖全靠它们。
  • 已开通的 client_idsecretapi_key,由对接工程师发放。注意 api_keysecret 是两个不同的凭证,前者用于转写 API 的密钥头,后者用于令牌签发的 Basic 认证。
  • App 侧已能完成录音同步与文件上传(见 Starter App 指南文件上传 API 总览)。没有 App 时,用一段已上传音频的 DownloadUrl 也能走通本篇全部流程。

第 1 步·项目初始化与环境变量

建目录,建四个源文件,把三个凭证写进 .env

bash
mkdir ikho-backend && cd ikho-backend
node -v   # 需要 v20.6.0 及以上
touch .env .gitignore ikhoAuth.js ikhoTranscribe.js store.js server.js
.env
IKHO_CLIENT_ID=你的client_id
IKHO_CLIENT_SECRET=你的secret
IKHO_API_KEY=你的api_key
.gitignore
# 密钥文件绝不入库
.env

四个文件的分工:

bash
ikho-backend/
├── .env               # 三个凭证,已 gitignore
├── ikhoAuth.js        # 合作方令牌:获取、缓存、到期前刷新
├── ikhoTranscribe.js  # 转写:提交任务、指数退避轮询
├── store.js           # 落库(伪代码,按你的数据库实现)
└── server.js          # HTTP 服务:/token、/recordings/complete 与 /recordings/{id}/transcript

secretapi_key 绝不进前端,绝不进仓库:不打进 App 包、不写进任何被 git 追踪的文件、不出现在客户端可见的响应里。App 侧唯一应该拿到的凭证是有效期约 24 小时的用户令牌。

第 2 步·获取并缓存合作方令牌

合作方令牌有效期约 2 小时(expires_in 为 7200 秒),不能每次请求都重新签发。做法是进程内缓存,并在到期前 5 分钟主动用 refresh_token 续期;刷新失败就退回重新签发。端点与字段见获取合作方令牌刷新合作方令牌

ikhoAuth.js
// ikhoAuth.js —— 合作方令牌:获取、缓存、到期前刷新
const HOST = 'https://platform.ikho.cn/developer/api';

const CLIENT_ID = process.env.IKHO_CLIENT_ID;
const CLIENT_SECRET = process.env.IKHO_CLIENT_SECRET;

// 进程内缓存:{ accessToken, refreshToken, expiresAt }
let cache = null;

// 距到期不足 5 分钟即视为过期,留出刷新窗口
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;

function basicAuth() {
  return 'Basic ' + Buffer.from(CLIENT_ID + ':' + CLIENT_SECRET).toString('base64');
}

// 首次签发:Basic 认证,无请求体
async function requestPartnerToken() {
  const res = await fetch(HOST + '/oauth/partner/access-token', {
    method: 'POST',
    headers: {
      'Authorization': basicAuth(),
      'Content-Type': 'application/x-www-form-urlencoded',
    },
  });
  if (!res.ok) throw new Error('获取合作方令牌失败:HTTP ' + res.status);
  return res.json(); // { access_token, refresh_token, token_type, expires_in }
}

// 续期:Basic 认证,表单体带 refresh_token
async function refreshPartnerToken(refreshToken) {
  const res = await fetch(HOST + '/oauth/partner/refresh-token', {
    method: 'POST',
    headers: {
      'Authorization': basicAuth(),
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({ refresh_token: refreshToken }),
  });
  if (!res.ok) throw new Error('刷新合作方令牌失败:HTTP ' + res.status);
  return res.json(); // 同上,含新的 refresh_token
}

export async function getPartnerToken() {
  const now = Date.now();
  if (cache && now < cache.expiresAt - EXPIRY_MARGIN_MS) {
    return cache.accessToken;
  }
  let data;
  if (cache && cache.refreshToken) {
    try {
      data = await refreshPartnerToken(cache.refreshToken);
    } catch {
      data = await requestPartnerToken(); // 刷新失败则退回重新签发
    }
  } else {
    data = await requestPartnerToken();
  }
  cache = {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: now + data.expires_in * 1000,
  };
  return cache.accessToken;
}

多实例部署时,进程内缓存意味着每个实例各持一份令牌,这是允许的。如果你希望全局只有一份,把缓存换成你现有的集中缓存即可,逻辑不变。

第 3 步·给 App 签发用户令牌的 /token 接口

App 不能直接拿合作方令牌,所以后端要暴露一个 /token 接口:App 带上用户标识来换,后端拿缓存的合作方令牌向平台调获取用户令牌,把结果透传回去。user_id 是你系统里对该用户的稳定标识,长度 6 到 120 个字符。

下面是完整的 server.js,用原生 node:http 实现,一并接好了第 4 步的 /recordings/complete 路由。

server.js
// server.js —— 面向 App 的三个接口:/token、/recordings/complete、/recordings/{id}/transcript
import http from 'node:http';
import { getPartnerToken } from './ikhoAuth.js';
import { submitTranscription, waitForTranscription } from './ikhoTranscribe.js';
import { saveTranscript, markFailed, getTranscript } from './store.js';

const HOST = 'https://platform.ikho.cn/developer/api';
const PORT = process.env.PORT || 8080;

// 启动即校验环境变量,缺一个就不启动
for (const key of ['IKHO_CLIENT_ID', 'IKHO_CLIENT_SECRET', 'IKHO_API_KEY']) {
  if (!process.env[key]) {
    console.error('缺少环境变量 ' + key + ',请检查 .env');
    process.exit(1);
  }
}

function readJson(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', (chunk) => { body += chunk; });
    req.on('end', () => {
      try { resolve(body ? JSON.parse(body) : {}); }
      catch { reject(new Error('请求体不是合法 JSON')); }
    });
    req.on('error', reject);
  });
}

function sendJson(res, status, data) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(data));
}

// POST /token —— 给 App 签发用户令牌
async function handleToken(req, res) {
  const { user_id } = await readJson(req);
  if (typeof user_id !== 'string' || user_id.length < 6 || user_id.length > 120) {
    return sendJson(res, 400, { error: 'user_id 需为 6 到 120 个字符的字符串' });
  }
  // 生产环境应在此校验你自己的登录态,确认调用者就是 user_id 本人
  const partnerToken = await getPartnerToken();
  const upstream = await fetch(HOST + '/open/partner/users/access-token', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + partnerToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ user_id, expires_in: 86400 }),
  });
  if (!upstream.ok) {
    return sendJson(res, 502, { error: '签发用户令牌失败:HTTP ' + upstream.status });
  }
  const data = await upstream.json();
  sendJson(res, 200, {
    access_token: data.access_token,
    token_type: data.token_type,   // 固定为 bearer
    expires_in: data.expires_in,   // 单位为秒
  });
}

// POST /recordings/complete —— 接收 App 的上传完成回报,转发转写
async function handleRecordingComplete(req, res) {
  const { user_id, file_url } = await readJson(req);
  if (!user_id || !file_url) {
    return sendJson(res, 400, { error: '缺少 user_id 或 file_url' });
  }
  const created = await submitTranscription(file_url);
  sendJson(res, 202, {
    transcription_id: created.transcription_id,
    status: created.status,
  });
  // 响应已返回,后台继续轮询直到终态,成功落库、失败记状态
  waitForTranscription(created.transcription_id)
    .then((task) => saveTranscript(user_id, created.transcription_id, task.data))
    .catch((err) => {
      console.error('转写任务 ' + created.transcription_id + ' 失败:' + err.message);
      return markFailed(created.transcription_id, err.message);
    });
}

// GET /recordings/{id}/transcript —— App 轮询逐字稿
async function handleTranscript(req, res, transcriptionId) {
  const found = await getTranscript(transcriptionId);
  if (!found) {
    // 尚未落库 = 任务仍在转写,让 App 继续轮询
    return sendJson(res, 200, { status: 'PROGRESS', segments: null });
  }
  sendJson(res, 200, { status: found.status, segments: found.segments });
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.method === 'POST' && req.url === '/token') {
      return await handleToken(req, res);
    }
    if (req.method === 'POST' && req.url === '/recordings/complete') {
      return await handleRecordingComplete(req, res);
    }
    const transcriptMatch = req.url.match(/^\/recordings\/([\w-]+)\/transcript$/);
    if (req.method === 'GET' && transcriptMatch) {
      return await handleTranscript(req, res, transcriptMatch[1]);
    }
    sendJson(res, 404, { error: '未找到该接口' });
  } catch (err) {
    console.error(err);
    sendJson(res, 500, { error: err.message });
  }
});

server.listen(PORT, () => {
  console.log('后端已启动:http://localhost:' + PORT);
});

本篇为教程,把「提交后在进程内继续轮询」写在了请求处理里。生产环境建议先把任务写进持久化的任务表或队列,由独立的工作进程轮询,进程重启也不会丢任务。

第 4 步·接收上传回报,提交转写

音频由 App 用用户令牌直传存储,你的后端不经手字节。App 在合并分片完成上传后拿到 DownloadUrl,把它连同 user_id 一起 POST 给上面的 /recordings/complete;后端将其作为 file_url 提交给提交音频转写。注意转写 API 的鉴权与令牌接口不同,用的是 X-Client-IdX-Client-Api-Key 两个密钥头。

ikhoTranscribe.js(上)
// ikhoTranscribe.js(上)—— 提交转写任务
const HOST = 'https://platform.ikho.cn/developer/api';

const API_HEADERS = {
  'X-Client-Id': process.env.IKHO_CLIENT_ID,
  'X-Client-Api-Key': process.env.IKHO_API_KEY,
};

export async function submitTranscription(fileUrl) {
  const res = await fetch(HOST + '/open/partner/ai/transcriptions/', {
    method: 'POST',
    headers: { ...API_HEADERS, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      file_url: fileUrl,
      params: {
        transcribe: {
          language: 'auto',            // 自动识别语种
          model: 'ikho-asr-pro',       // 精度优先;要更低时延用 ikho-asr-fast
          detection_level: 'segment',  // 按片段回报语种
        },
        vad: { decode_silence: false },
        diarization: { enabled: true, return_embedding: false },
      },
    }),
  });
  if (!res.ok) throw new Error('提交转写失败:HTTP ' + res.status);
  return res.json(); // { transcription_id, status: 'PENDING', data: {} }
}

第 5 步·轮询任务直到终态

转写是异步任务。按查询转写任务轮询:状态为 PENDINGRECEIVEDSTARTEDPROGRESS 时继续等;SUCCESS 表示 data 已就绪;FAILUREREVOKED 为终态失败。轮询间隔用指数退避,避免对长音频高频空转。

ikhoTranscribe.js(下)
// ikhoTranscribe.js(下,接上)—— 指数退避轮询
const IN_PROGRESS = new Set(['PENDING', 'RECEIVED', 'STARTED', 'PROGRESS']);

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function getTranscription(transcriptionId) {
  const res = await fetch(
    HOST + '/open/partner/ai/transcriptions/' + transcriptionId,
    { headers: API_HEADERS },
  );
  if (!res.ok) throw new Error('查询转写任务失败:HTTP ' + res.status);
  return res.json(); // { transcription_id, status, data }
}

// 2 秒起步,每次乘 1.5,单次间隔封顶 30 秒,总时长封顶 30 分钟
export async function waitForTranscription(transcriptionId) {
  const deadline = Date.now() + 30 * 60 * 1000;
  let interval = 2000;
  while (Date.now() < deadline) {
    const task = await getTranscription(transcriptionId);
    if (task.status === 'SUCCESS') return task;
    if (!IN_PROGRESS.has(task.status)) {
      // FAILURE 或 REVOKED
      throw new Error('任务进入终态失败:' + task.status);
    }
    await sleep(interval);
    interval = Math.min(interval * 1.5, 30 * 1000);
  }
  throw new Error('轮询超时:任务 ' + transcriptionId + ' 未在期限内完成');
}

第 6 步·segments 落库与消费建议

SUCCESSdata 携带完整逐字稿:text(全文)、languageduration(秒),以及逐句的 segments,每条含 speakerstart_msend_mstext。建议主表加片段表两张表落库:

store.js
// store.js —— 落库(伪代码):把注释换成你数据库的真实实现
export async function saveTranscript(userId, transcriptionId, data) {
  // 幂等:以 transcription_id 为唯一键,重复回报直接跳过
  // INSERT INTO transcripts (id, user_id, language, duration, full_text)
  //   VALUES (transcriptionId, userId, data.language, data.duration, data.text)
  //   ON CONFLICT (id) DO NOTHING;

  // 片段逐条落库,保留说话人与毫秒时间戳
  // data.segments.forEach((seg, i) => {
  //   INSERT INTO transcript_segments
  //     (transcript_id, seq, speaker, start_ms, end_ms, text)
  //   VALUES (transcriptionId, i, seg.speaker, seg.start_ms, seg.end_ms, seg.text);
  // });

  console.log('已落库:' + transcriptionId + ',片段数 ' + data.segments.length);
}

export async function markFailed(transcriptionId, reason) {
  // UPDATE transcripts SET status = 'FAILURE', fail_reason = reason WHERE id = transcriptionId;
  // (不存在则插入一条 FAILURE 记录,保证 App 轮询能拿到终态)
}

export async function getTranscript(transcriptionId) {
  // SELECT status, segments FROM transcripts WHERE id = transcriptionId;
  // 返回 { status: 'SUCCESS' | 'FAILURE', segments: [...] | null };查无记录返回 null(表示仍在转写)
  return null;
}
  • 幂等:以 transcription_id 作唯一键。网络重试或重复回报时,同一任务只落一次。
  • 全文与片段分开存:text 放主表供全文检索;界面渲染与回放定位用 segments 的毫秒时间戳。
  • 音频要长期回放先转存:DownloadUrl 有效期约 24 小时,落库时如需长期保留音频,应先把文件转存到你自己的存储,库里存你自己的地址。
  • 说话人标签直接存原值:speaker 是转写返回的标签,建议原样入库,改名、合并等展示逻辑放在你的产品层。

全链路 curl 冒烟清单

按顺序跑一遍,每步都有明确的通过标准。

1
验证凭证:直接向平台换合作方令牌
bash
# 先把 .env 中的三个值 export 成同名环境变量
curl -X POST "https://platform.ikho.cn/developer/api/oauth/partner/access-token" \
  -u "$IKHO_CLIENT_ID:$IKHO_CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded"

通过标准:返回 access_tokenrefresh_tokenexpires_in。失败则先核对 client_idsecret

2
启动本地服务
bash
node --env-file=.env server.js

通过标准:打印「后端已启动:http://localhost:8080」。缺环境变量会直接退出并提示缺哪个。

3
换用户令牌:调你自己的 /token
bash
curl -X POST "http://localhost:8080/token" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "demo_user_001" }'

通过标准:返回 access_tokentoken_typebearerexpires_in 为 86400。

4
回报上传完成:触发转写
bash
# file_url 换成文件上传 API 合并分片后返回的真实 DownloadUrl
curl -X POST "http://localhost:8080/recordings/complete" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "demo_user_001",
    "file_url": "https://storage.ikho.cn/download/9f3c1a?..."
  }'

通过标准:立即返回 202 与 transcription_id,服务日志开始轮询。

5
确认终态:看日志,或直接查平台任务
bash
curl "https://platform.ikho.cn/developer/api/open/partner/ai/transcriptions/$TRANSCRIPTION_ID" \
  -H "X-Client-Id: $IKHO_CLIENT_ID" \
  -H "X-Client-Api-Key: $IKHO_API_KEY"

通过标准:任务最终 statusSUCCESSdata.segments 非空;服务日志出现「已落库」与片段数。

常见错误与排查

现象常见原因与处理
换合作方令牌返回 401client_idsecret 配错,或 Basic 头拼装有误。核对 .env,确认没有把 api_key 误当 secret 用。
/token 返回 400user_id 不是字符串,或长度不在 6 到 120 个字符之间。
提交转写被拒转写 API 用 X-Client-IdX-Client-Api-Key 密钥头鉴权,不接受 Bearer 令牌。两套鉴权不要混用。
任务终态为 FAILURE最常见是 file_url 不可公网访问或已过期(DownloadUrl 有效期约 24 小时)。重新走合并分片拿新地址后重交。
长时间停在 PENDING任务在排队,按退避继续轮询即可。内测期配额与限流以联调口径为准,持续异常联系对接工程师。

更完整的错误码语义与逐项排查步骤,见常见错误与排查

下一步