RTC开发者文档中心 飞雁云
English

快速开始

创建项目、获取 App ID / Customer Secret、签发第一个 Token 并加入频道(curl + Node)。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

这一页把「拿到凭证 → 签发 Token → 加入频道」完整走一遍。命令里的 api.your-domain.example 是占位符:自托管本地运行时替换成 pnpm dev:api 启动日志打印的地址;线上替换成你的 API 域名。

前置条件

  • Node.js ≥ 22、pnpm 10(服务端运行环境)。
  • 已安装依赖并完成一次构建:pnpm install && pnpm build(workspace 内的库包以构建产物被 API 消费;只装依赖就直接 pnpm dev:api 会因缺 dist 报 ERR_MODULE_NOT_FOUND)。
  • 控制面已启动:在服务端代码目录执行 pnpm dev:api(见 自托管部署),把启动日志里的监听地址作为下文 api.your-domain.example。若默认端口被占用,用 PORT=8890 pnpm dev:api 覆盖(PORT 也列在自托管部署的环境变量表)。

第 1 步:创建项目,拿到 App ID 与凭证

项目(Project)是租户边界,持有 appId(对客户端公开)、customerId + customerSecret(服务端 Basic 凭证)与 appCertificate(只用于签发/校验 Token,绝不下发到客户端)。

控制台 UI 已可用:运行 pnpm dev:console,打开它启动日志打印的本地地址(生产环境替换为 https://console.your-domain.example;Docker 全栈同样使用控制台容器对外地址)。演示账号 owner@example.com / change-me-now。也可以走下面两条命令行等价路径:

A. 直接用演示项目(推荐用于本地验证)

bash
pnpm dev:api

# `pnpm dev:api` 会在服务端数据目录生成演示凭证(路径相对仓库根目录):
cat 控制面 class="tok-flag">-credentials.json
json
{
  "appId": "…32 位十六进制…",
  "customerId": "cid_…",
  "customerSecret": "…(只在创建时写入一次,每次重启会重新 seed 并重写本文件)",
  "appCertificate": "…(只在服务端使用)"
}

B. 通过控制台 BFF REST 创建项目(等价于控制台里的「新建项目」)

bash
# 1) 登录控制台(演示账号见上方,可用 CONSOLE_SEED_EMAIL/PASSWORD 覆盖)
SESSION=$(curl -s -X POST https://api.your-domain.example/console/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"owner@example.com","password":"change-me-now"}' \
  | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).token))")

# 2) 创建项目:appCertificate 与 customerSecret 只在这一次响应里返回
curl -s -X POST https://api.your-domain.example/console/projects \
  -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-first-project","services":{"recording":true,"streaming":true}}'

响应中的 project.appId / project.customerId / customerSecret / appCertificate 就是后续所有调用要用的四件套。customerSecret 只存哈希,丢了只能轮换(POST /console/projects/{projectId}/keys/rotate)。

第 2 步:调用 Token 服务(等价声网 Token 服务)

Token 由控制面签发,客户端只拿到不透明字符串。请求体最小只需要 channelName,其余可省略:

字段类型说明
channelName / channelstring频道名,1–128,字符集 [A-Za-z0-9_-];channel 是别名
uidinteger1..2147483647;不传时按 userId 派生,两者都不传则随机派生
userIdstringIM 用户 id;传入后建立 uid ↔ userId 映射(供 /v1/uids:resolve 使用)
rolehost \audience默认 host;audience 只拿到 join_channel + subscribe
ttlSecondsinteger60–86400,最终会被项目的 tokenTtlSeconds 夹紧
wildcardbooleantrue 时签发频道为 * 的通配 Token(仅演示/联调用)
bash
#!/usr/bin/env bash
# 签发 / 校验 / 续期 Token(等价声网 Token 服务)
# 运行前提:已执行 pnpm dev:api(演示凭证写在服务端数据目录)。
set -euo pipefail

: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
CRED_FILE=$([ -f 控制面 class="tok-flag">-credentials.json ] && echo 控制面 class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")

# 1) 签发:host 角色,频道 weekly-sync,uid 10001
TOKEN_RESPONSE=$(curl -s -X POST "$NIMEET_API_BASE/v1/tokens" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d '{"channelName":"weekly-sync","uid":10001,"role":"host","ttlSeconds":3600}')
echo "$TOKEN_RESPONSE"

# 2) 校验:channelName 会与 Token 内的频道绑定
TOKEN=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).rtcToken)" "$TOKEN_RESPONSE")
curl -s -G "$NIMEET_API_BASE/v1/tokens/verify" \
  -H "Authorization: Basic $CRED" \
  --data-urlencode "token=$TOKEN" \
  --data-urlencode 'channelName=weekly-sync'

# 3) 续期:保持 channel / uid / role / 权限不变,只刷新有效期
curl -s -X POST "$NIMEET_API_BASE/v1/tokens/renew" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d "{\"token\":\"$TOKEN\"}"

# 4) uid -> IM userId(部分结果语义:未知 uid 省略)
curl -s -X POST "$NIMEET_API_BASE/v1/uids:resolve" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d '{"rtcUids":[10001,10002,2147483647]}'

Node 版本(示例均可直接以 ESM 运行):

javascript
import { createApi, summarize } from './_lib.mjs';

export const meta = {
  id: '01-issue-token',
  title: '签发频道 Token',
  safe: true,
  needsApi: true,
};

/**
 * POST /v1/tokens
 * - 显式 uid + host 角色
 * - audience 角色(权限自动降为 join_channel + subscribe)
 * - wildcard Token(频道为 `*`)
 * - userId 派生 uid(同一 userId 稳定得到同一 rtcUid)
 */
export async function run(ctx) {
  const api = ctx.api;

  const host = await api('POST', '/v1/tokens', {
    body: { channelName: 'weekly-sync', uid: 10001, role: 'host', ttlSeconds: 3600 },
  });
  const audience = await api('POST', '/v1/tokens', {
    body: { channelName: 'weekly-sync', uid: 10002, role: 'audience' },
  });
  const wildcard = await api('POST', '/v1/tokens', { body: { wildcard: true, uid: 10003 } });
  const first = await api('POST', '/v1/tokens', { body: { channelName: 'weekly-sync', userId: 'user_alice' } });
  const second = await api('POST', '/v1/tokens', { body: { channelName: 'weekly-sync', userId: 'user_alice' } });

  const hostBody = host.json;
  const audienceBody = audience.json;
  const wildcardBody = wildcard.json;
  const firstBody = first.json;
  const secondBody = second.json;

  ctx.assert(hostBody.appId === ctx.credentials.appId, 'appId 应与项目 App ID 一致');
  ctx.assert(hostBody.rtcUid === 10001, '显式 uid 应被保留');
  ctx.assert(
    Array.isArray(hostBody.privileges) &&
      hostBody.privileges.includes('publish_video') &&
      hostBody.privileges.includes('join_channel'),
    'host 应包含发布权限',
  );
  ctx.assert(
    Array.isArray(audienceBody.privileges) &&
      !audienceBody.privileges.includes('publish_audio') &&
      audienceBody.privileges.includes('subscribe'),
    'audience 不应有发布权限',
  );
  ctx.assert(wildcardBody.channelName === '*', 'wildcard Token 的频道应为 *');
  ctx.assert(firstBody.rtcUid === secondBody.rtcUid, '同一 userId 的 rtcUid 应稳定');
  ctx.assert(hostBody.rtcToken.startsWith('nrt1.'), 'Token 前缀应为 nrt1.,SDK 侧必须当作不透明字符串');

  return {
    summary:
      `签发 5 个 Token;host uid=${hostBody.rtcUid} 权限=${hostBody.privileges.join(',')};` +
      `audience 权限=${audienceBody.privileges.join(',')};userId 派生 uid=${firstBody.rtcUid}(稳定)`,
    details: {
      host: summarize('host', {
        appId: hostBody.appId,
        channelName: hostBody.channelName,
        rtcUid: hostBody.rtcUid,
        role: hostBody.role,
        expireAt: hostBody.expireAt,
        privilegeExpireAt: hostBody.privilegeExpireAt,
      }),
      tokenPrefix: hostBody.rtcToken.slice(0, 24) + '…',
    },
  };
}

关键点:

  • 返回的 rtcToken 形如 nrt1.<base64url(payload)>.<base64url(hmac-sha256)>,SDK 必须当作不透明字符串;不要解析、不要拼接。
  • privilegeExpireAt 可能早于 expireAt:权限窗口(pexp)与 Token 生命周期(exp)分离,对齐声网 AccessToken2 语义。
  • 权限到期前 30 秒 SDK 侧会触发 token-privilege-will-expire,此时调用 /v1/tokens/renew 即可;服务端也会产生事件码 203(token_expiring)的回调。
  • 被封禁(join_channel 规则)的 uid 在这里就会拿到 403 banned,不会得到可用凭证。

第 3 步:加入第一个频道

浏览器/客户端不能持有 customerSecret。正确链路是:

  1. 你的业务后端用 Basic 凭证调用 POST /v1/tokens(或集成 IM 的 RTC Provider 接口定义,见 Token 鉴权);
  2. 前端只拿到 { appId, rtcToken, rtcUid };
  3. 前端 SDK 用它初始化并加入 channelName。

按 Web SDK 的公开 API(Web SDK API Reference),客户端代码形如:

javascript
import AgoraRTC from '@nimeet/rtc-web';

// 1) 用 App ID 初始化(等价声网 AgoraRTC.createClient)
const client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });

// 2) 订阅远端流:收到 user-published 后按需订阅并渲染
client.on('user-published', async (user, mediaType) => {
  await client.subscribe(user, mediaType);
  if (mediaType === 'video') user.videoTrack?.play('remote-player');
  if (mediaType === 'audio') user.audioTrack?.play();
});

// 3) 加入频道:Token 由你的后端调用 POST /v1/tokens 获得
const uid = await client.join('<你的 App ID>', 'weekly-sync', '<rtcToken>', 10001);

// 4) 采集并发布本地音视频
const [micTrack, cameraTrack] = await AgoraRTC.createMicrophoneAndCameraTracks();
cameraTrack.play('local-player');
await client.publish([micTrack, cameraTrack]);

// 5) 离开并释放采集设备
await client.leave();
micTrack.close();
cameraTrack.close();

页面需要两个容器:<div id="local-player"></div> 与 <div id="remote-player"></div>。媒体边缘地址按以下顺序解析:

  1. Token 载荷里的 surl(由签发方决定;控制面不写这个字段,留给有 IM 侧的部署自己加);
  2. window.__NIMEET_SFU_URL__(页面自带的部署引导变量,例如 wss://sfu.your-domain.example);
  3. 同源 /rtc(把 LiveKit WebSocket 反代到同源,这样浏览器不需要任何配置)。

本地无浏览器环境时,可在文档示例目录运行同一公共链路的离线自检:node examples/16-sdk-core-flow.mjs。

第 4 步:本地跑通双人音视频(需要 Docker)

前三步只用 API,默认的 memory SFU 模拟器只能验证 Token、房间、回调与错误码:浏览器之间没有真实媒体通道,两个窗口不会互相看到画面。要真正看到双人音视频,用仓库自带的全栈编排(API、媒体面、会议 Demo 一起起来):

bash
# 仓库根目录:构建并启动全栈(首次构建需要几分钟)
docker compose -f deploy/compose.yml up -d --build
  • meeting 服务映射到宿主机 8083 端口:浏览器打开 http://<your-host>:8083(<your-host> 用回环地址或局域网 IP 均可)。
  • 用两个无痕窗口打开同一地址,输入同一个频道名(例如 weekly-sync),允许摄像头与麦克风,即可互相看到画面。
  • 只想验证 Token / 房间 / 回调、不需要真实画面:docker compose -f deploy/compose.yml up -d api,或按第 1–3 步用本机 pnpm dev:api(memory 模拟器)。
  • 若改用本机进程跑 API 又要真实媒体:启动时加 NIMEET_SFU_ADAPTER=livekit(见自托管部署);媒体面地址由 VITE_SFU_URL 指定。

端到端自检

启动 API 后运行文档示例运行器(读取服务端数据目录的演示凭证,或 NIMEET_CUSTOMER_ID / NIMEET_CUSTOMER_SECRET 环境变量):

bash
node 文档站 class="tok-flag">-all.mjs --only=0

输出示例:

text
ni-meet 文档示例运行器
  API:        https://api.your-domain.example (ni-meet-api v0.1.0, uptime 3s)
  凭证来源:   文件 data/demo-credentials.json
  模式:       仅安全示例(只读 / Token / 遥测)
  ✓ 00-health.mjs                       12ms  GET /healthz → ok · ni-meet-api v0.1.0
  ✓ 01-issue-token.mjs                  38ms  签发 5 个 Token;host uid=10001 …

常见失败与处理

现象code处理
401missing_authorization忘带 Authorization: Basic base64(customerId:customerSecret)
401invalid_credentialssecret 不匹配;确认不是轮换前的旧值
401invalid_appidcustomerId 不存在,或 /dev/v1 路径里的 appid 不属于这组凭证
403banned该 uid 命中 join_channel 封禁规则,先解除规则
403service_not_enabled项目未开通录制/推流,调用 /console/projects/{projectId}/services 开通
400invalid_channel_name频道名含非法字符或长度越界
415unsupported_media_type写请求必须带 Content-Type: application/json
第 4 步启动失败:Bind for 0.0.0.0:3478 failed: port is already allocated(或 7880、50000-50100)—宿主机上已有别的媒体栈占着端口,见下方「端口被占用」

完整的错误码与 SDK 错误号见 错误码。

端口被占用与同机多个 checkout

第 4 步的 docker compose up -d --build 在宿主机已有别的媒体服务(或另一个 checkout 的同一套编排)时,会在容器启动阶段直接失败:

text
Error response from daemon: failed to set up container networking:
Bind for 0.0.0.0:3478 failed: port is already allocated

第一步:确认是谁占用(任选其一)

bash
lsof -nP -i :3478 -i :7880                  # macOS / Linux:看占用端口的进程
docker ps | grep -E '3478|7880'

第二步:二选一

  1. 清掉旧栈(推荐)——本仓库的编排项目名固定,down 能收干净:
bash
   docker compose -f deploy/compose.yml down
  1. 让两套栈并存:
    • 换项目名,避免互相接管(项目名默认取自 deploy/ 目录名,两个 checkout 会撞名):
bash
     docker compose -p nimeet-b -f deploy/compose.yml up -d --build

等价的常驻写法是在 deploy/.env 里设 COMPOSE_PROJECT_NAME=nimeet-b(模板见 deploy/.env.example)。

  • 改宿主端口:deploy/.env 里的 API_PORT / EGRESS_PORT 可直接改;媒体面(3478 / 7880 / 7881 /

50000-50100)在 deploy/compose.yml 里是固定值,需要用一份 override 文件只换宿主机侧映射 (Compose v2.24+ 支持 !override):

yaml
     # deploy/compose.extra-ports.yml
     services:
       livekit:
         ports: !override
           - '17880:7880'
           - '17881:7881'
           - '51000-51100:50000-50100/udp'
           - '13478:3478/udp'
       api:
         environment:
           # 浏览器直连媒体面的地址要跟着换(页面里用它初始化 RTC)
           NIMEET_DEMO_SERVER_URL: ws://<your-host>:17880
bash
     docker compose -f deploy/compose.yml -f deploy/compose.extra-ports.yml up -d --build

第三步:验证——docker compose -f deploy/compose.yml ps 的每一行都是 healthy(没有健康检查的服务显示 running);curl -s -o /dev/null -w '%{http_code}' http://<your-host>:8083 返回 200;再按第 4 步用两个 无痕窗口加入同一频道,双方能看到远端画面即成功。

多 checkout 并存时务必显式 -p <名字> 或 COMPOSE_PROJECT_NAME:否则第二个 checkout 的 up 会静默接管 并重建第一个 checkout 的容器(数据卷与端口也随之切过去),表现是「另一个窗口的画面突然断了」。 正确做法是每个 checkout 固定一个项目名,或者用完先 down。

下一步

本页由文档构建流水线生成 · 2026-11-05 14:43:28 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。