# 端到端教程·后端篇

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

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

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

## 职责划分

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

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

## 前置条件

  
- Node.js 20.6 及以上:内置 `fetch` 与 `--env-file`,本篇零依赖全靠它们。
  
- 已开通的 `client_id`、`secret` 与 `api_key`,由对接工程师发放。注意 `api_key` 与 `secret` 是两个不同的凭证,前者用于转写 API 的密钥头,后者用于令牌签发的 Basic 认证。
  
- App 侧已能完成录音同步与文件上传(见 [Starter App 指南](/docs/embedded/starter-app-guide/)与[文件上传 API 总览](/docs/api-reference/file/overview/))。没有 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
```

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

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

合作方令牌有效期约 2 小时(`expires_in` 为 7200 秒),不能每次请求都重新签发。做法是进程内缓存,并在到期前 5 分钟主动用 `refresh_token` 续期;刷新失败就退回重新签发。端点与字段见[获取合作方令牌](/docs/api-reference/auth/get-partner-token/)与[刷新合作方令牌](/docs/api-reference/auth/refresh-partner-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 带上用户标识来换,后端拿缓存的合作方令牌向平台调[获取用户令牌](/docs/api-reference/auth/get-user-token/),把结果透传回去。`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 在[合并分片完成上传](/docs/api-reference/file/complete-upload/)后拿到 `DownloadUrl`,把它连同 `user_id` 一起 POST 给上面的 `/recordings/complete`;后端将其作为 `file_url` 提交给[提交音频转写](/docs/api-reference/transcription/submit/)。注意转写 API 的鉴权与令牌接口不同,用的是 `X-Client-Id` 与 `X-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 步·轮询任务直到终态

转写是异步任务。按[查询转写任务](/docs/api-reference/transcription/get-task/)轮询:状态为 `PENDING`、`RECEIVED`、`STARTED`、`PROGRESS` 时继续等;`SUCCESS` 表示 `data` 已就绪;`FAILURE`、`REVOKED` 为终态失败。轮询间隔用指数退避,避免对长音频高频空转。

  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 落库与消费建议

`SUCCESS` 时 `data` 携带完整逐字稿:`text`(全文)、`language`、`duration`(秒),以及逐句的 `segments`,每条含 `speaker`、`start_ms`、`end_ms`、`text`。建议主表加片段表两张表落库:

  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_token`、`refresh_token` 与 `expires_in`。失败则先核对 `client_id` 与 `secret`。

  

  
    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_token`、`token_type` 为 `bearer`、`expires_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"
```

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

  

## 常见错误与排查

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

更完整的错误码语义与逐项排查步骤,见[常见错误与排查](/docs/embedded/errors/)。

## 下一步

[转写 API 参考 核对每个参数与响应字段,按需调整语种、模型与说话人分离配置。 打开 API 参考](/docs/api-reference/transcription/overview/)

[常见错误与排查 接口报错时,按错误码逐项定位问题。 查看排查指南](/docs/embedded/errors/)
