端到端教程·后端篇
不装任何第三方依赖,用 Node.js 20 原生能力从零搭一个合作方后端:缓存并刷新合作方令牌、给 App 签发用户令牌、接收上传回报并驱动转写直到落库。
开发者平台处于内测阶段,接口契约以联调时提供的正式文档为准。账号由对接工程师开通,详见联系我们。
本篇带你从空目录开始,搭出一个可以直接跑的合作方后端。它只用 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 直传存储,拿到 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 指南与文件上传 API 总览)。没有 App 时,用一段已上传音频的
DownloadUrl也能走通本篇全部流程。
第 1 步·项目初始化与环境变量
建目录,建四个源文件,把三个凭证写进 .env。
mkdir ikho-backend && cd ikho-backend
node -v # 需要 v20.6.0 及以上
touch .env .gitignore ikhoAuth.js ikhoTranscribe.js store.js server.js
IKHO_CLIENT_ID=你的client_id
IKHO_CLIENT_SECRET=你的secret
IKHO_API_KEY=你的api_key
# 密钥文件绝不入库
.env
四个文件的分工:
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 续期;刷新失败就退回重新签发。端点与字段见获取合作方令牌与刷新合作方令牌。
// 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 —— 面向 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-Id 与 X-Client-Api-Key 两个密钥头。
// 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 步·轮询任务直到终态
转写是异步任务。按查询转写任务轮询:状态为 PENDING、RECEIVED、STARTED、PROGRESS 时继续等;SUCCESS 表示 data 已就绪;FAILURE、REVOKED 为终态失败。轮询间隔用指数退避,避免对长音频高频空转。
// 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 —— 落库(伪代码):把注释换成你数据库的真实实现
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 冒烟清单
按顺序跑一遍,每步都有明确的通过标准。
# 先把 .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。
node --env-file=.env server.js
通过标准:打印「后端已启动:http://localhost:8080」。缺环境变量会直接退出并提示缺哪个。
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。
# 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,服务日志开始轮询。
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 | 任务在排队,按退避继续轮询即可。内测期配额与限流以联调口径为准,持续异常联系对接工程师。 |
更完整的错误码语义与逐项排查步骤,见常见错误与排查。