频道管理
频道列表、频道内用户、用户状态、踢人(kicking-rule)、禁言、角色切换与封禁规则。
频道管理覆盖两类操作:查询(频道、用户、用户状态)与治理(踢人、禁言、角色切换、封禁规则)。平台 API 使用 camelCase 字段;/dev/v1/* 提供语义等价的声网兼容入口,两者读写同一份业务事实。
能力总览
| 能力 | 平台 API | 声网兼容 |
|---|---|---|
| 频道列表 | GET /v1/channels | GET /dev/v1/channel/{appid} |
| 频道内用户 | GET /v1/channels/{channelName}/users | GET /dev/v1/channel/user/{appid}/{channelName} |
| 用户状态 | GET /v1/channels/{channelName}/users/{uid} | GET /dev/v1/channel/user/property/{appid}/{uid}/{channelName} |
| 踢人 | DELETE /v1/channels/{channelName}/users/{uid} | POST /dev/v1/kicking-rule(join_channel,并立即踢下线) |
| 禁言 / 解禁 | POST /v1/channels/{channelName}/users/{uid}/mute · /unmute | 通过 kicking-rule 的 publish_audio / publish_video 表达 |
| 角色切换 | POST /v1/channels/{channelName}/users/{uid}/role | 客户端 setClientRole 对应语义,无 REST 等价物 |
| 封禁规则 | GET/POST/PATCH/DELETE /v1/ban-rules | GET/POST/PUT/DELETE /dev/v1/kicking-rule |
查询频道
GET /v1/channels?page_no=0&page_size=100
| 参数 | 位置 | 说明 |
|---|---|---|
page_no | query | 0 起,默认 0 |
page_size | query | 1–500,默认 100 |
响应 { total, pageNo, pageSize, channels: [{ channelName, userCount, profile, createdAt }] }。userCount 来自媒体面的实时在线人数,profile 取 communication / live,createdAt 在频道未经控制面创建时可能为 null。
声网兼容入口 GET /dev/v1/channel/{appid}?page_no=&page_size= 返回 { success: true, data: { channels: [{ channel_name, user_count }], total_size } }。
查询频道内用户
GET /v1/channels/{channelName}/users → { channel: { name, profile, createdAt }, users: [UserView] }
UserView 字段:uid、userId(可能为 null)、role、platform、joinedAt、audioMuted、videoMuted、publishing.{audio,video,screen}。
频道不存在时返回 404 channel_not_found(不是空列表)。声网兼容入口在频道不存在时返回 { success: true, data: { channel_exist: false } }——这是声网的表达习惯。
声网兼容接口的两种形态:
communication频道:{ channel_exist: true, mode: 1, total, users: [uid…] }live频道:{ channel_exist: true, mode: 2, broadcasters: [uid…], audience: [uid…], audience_total },加?hosts_only=true时省略观众列表。
查询单个用户
GET /v1/channels/{channelName}/users/{uid} → UserView;不在频道内返回 404 user_not_found。
声网兼容入口返回 { in_channel, uid, join, role, platform } 或 { in_channel: false };role 数值:1 communication 用户、2 主播、3 观众。
踢人
DELETE /v1/channels/{channelName}/users/{uid}
请求体可选:{ "durationSeconds": 600, "reason": "policy-violation" }(durationSeconds 0–86430,默认 600)。
动作序列(一次调用内完成):
- 建一条
join_channel封禁规则; - 从媒体面移除该参与者;
- 记录离开原因
3(被踢),并产生user_leave/broadcaster_leave回调。
响应:{ "kicked": true, "uid": 10001, "banRuleId": 7, "expireAt": "2026-10-26T03:00:00.000Z" }。
禁言与解除
POST /v1/channels/{channelName}/users/{uid}/mute
| 字段 | 说明 |
|---|---|
kind | audio / video / all(默认 all) |
banPublish | 默认 true:除静音当前已发布 track 外,额外禁止重新发布(建 publish_audio / publish_video 规则) |
durationSeconds | 封禁窗口,默认 600 |
响应:{ muted: true, uid, kind, mutedTracks: ["TR_10001_a"], banRuleIds: [8] }。mutedTracks 是被服务端静音的 track sid。
POST .../unmute 做两件事:取消 track 静音,并删除对应的 publish_* 规则(只保留仍有需要的权限位)。
角色切换
POST /v1/channels/{channelName}/users/{uid}/role,请求体 { "role": "host" | "audience" }。
host:canPublish=true、canPublishData=trueaudience:canPublish=false(仍可订阅)
成功返回 { uid, role },并产生回调:提升为 111 role_to_broadcaster,降级为 112 role_to_audience。
封禁规则(kicking-rule)
平台 API:
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /v1/ban-rules?channelName=&uid= | 列出未过期规则 |
POST | /v1/ban-rules | 新建,成功 201 |
PATCH | /v1/ban-rules/{id} | 更新时长 / 权限位 |
DELETE | /v1/ban-rules/{id} | 删除,成功 204 |
创建请求体:channelName、uid、ip 至少给一个;privileges 必填且至少一项(join_channel / publish_audio / publish_video);durationSeconds 默认 3600。
语义:
join_channel:签发 Token 时直接403 banned,且若在线立即踢下线(离开原因 3)。publish_audio/publish_video:禁止对应媒体发布;可被unmute显式解除。- 规则的
expireAt为null表示永久(durationSeconds: 0目前走这条路径,见「已知限制」)。
声网兼容入口 POST /dev/v1/kicking-rule 额外接受 time(分钟,0–1440)或 time_in_seconds(0–86430);两者都缺省时按 3600 秒处理。PUT 用 appid + id 定位,DELETE 用 appid + id。
声网路径 ↔ 我们路径对照
| 声网路径 | 我们的等价路径 | 备注 |
|---|---|---|
GET /dev/v1/channel/{appid} | GET /v1/channels | 声网返回 channel_name / user_count / total_size;平台返回 camelCase 且带 pageNo / pageSize |
GET /dev/v1/channel/user/{appid}/{channelName} | GET /v1/channels/{channelName}/users | 声网按频道 profile 返回 users 或 broadcasters/audience,并用 channel_exist 表达不存在 |
GET /dev/v1/channel/user/property/{appid}/{uid}/{channelName} | GET /v1/channels/{channelName}/users/{uid} | 声网用 in_channel + 数值 role;我们返回完整 UserView |
POST /dev/v1/kicking-rule | POST /v1/ban-rules | 声网 time(分钟)/time_in_seconds(秒);我们统一 durationSeconds。join_channel 会立即踢下线 |
GET /dev/v1/kicking-rule?appid= | GET /v1/ban-rules | 声网按 appid(query)鉴权并列出;我们按项目凭证列出,支持 channelName / uid 过滤 |
PUT /dev/v1/kicking-rule | PATCH /v1/ban-rules/{id} | 都支持改时长与权限位 |
DELETE /dev/v1/kicking-rule | DELETE /v1/ban-rules/{id} | 声网用 body 里的 appid+id(DELETE 带 body),我们用路径参数 |
| (无 REST 等价物) | POST /v1/channels/{c}/users/{uid}/mute /unmute | 声网仅能通过 publish_* 规则表达「禁止发布」;我们额外提供即时服务端静音 |
(无 REST 等价物,客户端 setClientRole) | POST /v1/channels/{c}/users/{uid}/role | 服务端改角色,并产生 111/112 回调 |
(无,声网用 kicking-rule 的 join_channel 实现) | DELETE /v1/channels/{c}/users/{uid} | 踢人 = 移除在线会话 + 建 join_channel 规则,一次完成 |
端到端示例
查询与声网兼容对照(安全,只读):
export const meta = {
id: '06-channel-users',
title: '频道内用户列表与用户详情',
safe: true,
needsApi: true,
};
/**
* GET /v1/channels/{channelName}/users → { channel: { name, profile, createdAt }, users: [UserView] }
* GET /v1/channels/{channelName}/users/{uid} → UserView
*
* 演示项目首启会 seed 三个频道(weekly-sync / design-review / all-hands)与 5 个用户;
* 若以 NIMEET_SEED_DEMO=false 启动,这里会得到 404 channel_not_found —— 这也是文档化的行为。
*/
export async function run(ctx) {
const api = ctx.api;
const channels = await api('GET', '/v1/channels', { query: { page_size: 100 } });
const names = (channels.json.channels ?? []).map((entry) => entry.channelName);
const channelName = names.includes('weekly-sync') ? 'weekly-sync' : names[0];
if (!channelName) {
return { summary: '当前项目没有活跃频道(NIMEET_SEED_DEMO=false 时属预期),跳过用户列表' };
}
const users = await api('GET', `/v1/channels/${encodeURIComponent(channelName)}/users`);
const list = users.json.users ?? [];
ctx.assert(Array.isArray(list), 'users 应为数组');
ctx.assert(users.json.channel?.name === channelName, '响应应回显频道信息');
let detail = null;
if (list.length > 0) {
const first = list[0];
const one = await api('GET', `/v1/channels/${encodeURIComponent(channelName)}/users/${first.uid}`);
detail = one.json;
ctx.assert(detail.uid === first.uid, '用户详情应与列表中的 uid 一致');
}
// 404 分支:不存在的频道必须报 channel_not_found(而不是空列表)。
const missing = await ctx.client('GET', '/v1/channels/no-such-channel/users');
ctx.assert(missing.status === 404, '未知频道应返回 404');
ctx.assert(missing.json.code === 'channel_not_found', `期望 channel_not_found,实际 ${missing.json?.code}`);
return {
summary: `${channelName}: ${list.length} 个用户${detail ? `,首个用户 uid=${detail.uid} role=${detail.role} publishing.video=${detail.publishing?.video}` : ''};未知频道 → 404 channel_not_found`,
details: { channel: users.json.channel, users: list.map((entry) => ({ uid: entry.uid, role: entry.role, userId: entry.userId })) },
};
}export const meta = {
id: '07-agora-compat-channels',
title: '声网兼容:频道 / 用户 / 用户状态',
safe: true,
needsApi: true,
};
/**
* 三个 `/dev/v1` 接口与平台 API 一一对应:
* GET /dev/v1/channel/{appid} → 频道列表
* GET /dev/v1/channel/user/{appid}/{channelName} → 频道内用户(communication 模式返回 users/total)
* GET /dev/v1/channel/user/property/{appid}/{uid}/{cname} → 用户状态(in_channel / role / platform)
*
* 注意 mode/role 的数值映射:communication=1、LIVE_BROADCASTING=2;role 1=communication 用户、2=主播、3=观众。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channels = await api('GET', `/dev/v1/channel/${appId}`, { query: { page_size: 100 } });
const list = channels.json.data?.channels ?? [];
const channelName = list.find((entry) => entry.channel_name === 'weekly-sync')?.channel_name ?? list[0]?.channel_name;
if (!channelName) {
return { summary: '没有活跃频道(演示 seed 关闭时属预期),跳过声网用户接口' };
}
const users = await api('GET', `/dev/v1/channel/user/${appId}/${encodeURIComponent(channelName)}`);
const data = users.json.data ?? {};
const uids = data.users ?? data.broadcasters ?? [];
ctx.assert(data.channel_exist === true, 'channel_exist 应为 true');
let status = null;
if (uids.length > 0) {
const uid = uids[0];
const property = await api('GET', `/dev/v1/channel/user/property/${appId}/${uid}/${encodeURIComponent(channelName)}`);
status = property.json.data;
ctx.assert(status.in_channel === true, 'in_channel 应为 true');
ctx.assert(status.uid === uid, '应回显 uid');
}
const absent = await api('GET', `/dev/v1/channel/user/${appId}/definitely-no-such-channel`);
ctx.assert(absent.json.data?.channel_exist === false, '不存在的频道应返回 channel_exist:false');
return {
summary:
`${channelName}: mode=${data.mode} 用户数=${uids.length}` +
`${status ? `;uid=${status.uid} role=${status.role} platform=${status.platform} join=${status.join}` : ''};` +
'未知频道 → channel_exist:false',
details: { channelData: data, userProperty: status },
};
}服务端治理全流程(会修改演示频道状态,运行器需加 --all):
import { seedChannel, uniqueChannel } from './_lib.mjs';
export const meta = {
id: '15-moderation',
title: '禁言 / 角色切换 / 封禁规则 / 踢人',
safe: false, // 会修改演示频道内的用户状态
needsApi: true,
};
/**
* 平台接口:
* POST /v1/channels/{c}/users/{uid}/mute | unmute | role
* GET/POST/PATCH/DELETE /v1/ban-rules
* DELETE /v1/channels/{c}/users/{uid} (踢人 + 建 join_channel 规则)
* 声网兼容:POST/GET/PUT/DELETE /dev/v1/kicking-rule
*
* 语义要点:
* - mute 先静音已发布 track,再按 banPublish 建 publish_audio/publish_video 规则阻止重发;
* - join_channel 封禁在“签发 Token 时”即被拒绝(403 banned),并在在线时立刻踢下线(离开原因 3);
* - 踢人默认建 600s 的 join_channel 规则。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channelName = uniqueChannel('docs-mod');
const uid = 94001;
await seedChannel(api, appId, channelName, uid);
// ------------------------------------------------------------------ mute
const mute = await api('POST', `/v1/channels/${channelName}/users/${uid}/mute`, {
body: { kind: 'audio', banPublish: true, durationSeconds: 600 },
});
ctx.assert(mute.json.muted === true, 'mute 应返回 muted:true');
ctx.assert(Array.isArray(mute.json.mutedTracks) && mute.json.mutedTracks.length > 0, '应返回被静音的 track sid');
ctx.assert(Array.isArray(mute.json.banRuleIds) && mute.json.banRuleIds.length > 0, 'banPublish 应创建封禁规则');
const unmute = await api('POST', `/v1/channels/${channelName}/users/${uid}/unmute`, { body: { kind: 'audio' } });
ctx.assert(unmute.json.unmuted === true, 'unmute 应返回 unmuted:true');
// ------------------------------------------------------------ role change
const demoted = await api('POST', `/v1/channels/${channelName}/users/${uid}/role`, { body: { role: 'audience' } });
ctx.assert(demoted.json.role === 'audience', '角色应变为 audience');
const promoted = await api('POST', `/v1/channels/${channelName}/users/${uid}/role`, { body: { role: 'host' } });
ctx.assert(promoted.json.role === 'host', '角色应恢复为 host');
// ----------------------------------------------------------- ban rules
const created = await api('POST', '/v1/ban-rules', {
body: { channelName, uid, privileges: ['join_channel'], durationSeconds: 600 },
});
ctx.assert(created.status === 201, `创建封禁规则应返回 201,实际 ${created.status}`);
const ruleId = created.json.id;
const listed = await api('GET', '/v1/ban-rules', { query: { channelName } });
ctx.assert((listed.json.rules ?? []).some((rule) => rule.id === ruleId), '规则应出现在列表中');
const patched = await api('PATCH', `/v1/ban-rules/${ruleId}`, { body: { durationSeconds: 120 } });
ctx.assert(patched.json.expireAt, '更新时长后应返回 expireAt');
const denied = await ctx.client('POST', '/v1/tokens', { body: { channelName, uid } });
ctx.assert(denied.status === 403 && denied.json.code === 'banned', '被 join_channel 封禁的 uid 无法获得 Token');
const removed = await api('DELETE', `/v1/ban-rules/${ruleId}`);
ctx.assert(removed.status === 204, '删除规则应返回 204');
const allowed = await api('POST', '/v1/tokens', { body: { channelName, uid } });
ctx.assert(allowed.status === 200, '解除封禁后应能重新签发 Token');
// ------------------------------------------------------------------ kick
const kick = await api('DELETE', `/v1/channels/${channelName}/users/${uid}`, {
body: { durationSeconds: 600, reason: 'docs-example' },
});
ctx.assert(kick.json.kicked === true, '踢人应返回 kicked:true');
ctx.assert(typeof kick.json.banRuleId === 'number', '踢人应返回 banRuleId');
const gone = await ctx.client('GET', `/v1/channels/${channelName}/users/${uid}`);
ctx.assert(gone.status === 404 && gone.json.code === 'user_not_found', '被踢用户应不在频道内');
// ------------------------------------------------- 声网兼容 kicking-rule
const agoraCreate = await api('POST', '/dev/v1/kicking-rule', {
body: { appid: appId, cname: channelName, uid, time: 10, privileges: ['publish_video'] },
});
ctx.assert(agoraCreate.json.status === 'success', '声网 kicking-rule 创建应返回 status:success');
const agoraId = agoraCreate.json.id;
const agoraList = await api('GET', '/dev/v1/kicking-rule', { query: { appid: appId } });
ctx.assert((agoraList.json.rules ?? []).some((rule) => rule.id === agoraId), '声网规则列表应包含新规则');
const agoraUpdate = await api('PUT', '/dev/v1/kicking-rule', {
body: { appid: appId, id: agoraId, time_in_seconds: 120 },
});
ctx.assert(agoraUpdate.json.status === 'success', '声网规则更新应成功');
const agoraDelete = await api('DELETE', '/dev/v1/kicking-rule', { body: { appid: appId, id: agoraId } });
ctx.assert(agoraDelete.json.status === 'success', '声网规则删除应成功');
return {
summary:
`${channelName}: mute→unmute 成功;role audience↔host;封禁 uid 时 Token 403 banned,解禁后 200;` +
`踢人得到 banRuleId=${kick.json.banRuleId};声网 kicking-rule 创建/更新/删除闭环`,
details: {
mutedTracks: mute.json.mutedTracks,
kick: kick.json,
agoraRuleId: agoraId,
},
};
}curl 版本:
#!/usr/bin/env bash
# 频道与用户查询:平台 API 与声网兼容 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")
APP_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).appId)" "$CRED_FILE")
# 平台 API:camelCase 字段 + 统一分页
curl -s "$NIMEET_API_BASE/v1/channels?page_no=0&page_size=100" \
-H "Authorization: Basic $CRED"
# 频道内用户(channelName 路径参数)
curl -s "$NIMEET_API_BASE/v1/channels/weekly-sync/users" \
-H "Authorization: Basic $CRED"
# 声网兼容:频道列表(success/data 信封,snake_case 字段)
curl -s "$NIMEET_API_BASE/dev/v1/channel/$APP_ID?page_no=0&page_size=100" \
-H "Authorization: Basic $CRED"
# 声网兼容:频道内用户(communication 模式返回 users/total,LIVE 模式返回 broadcasters/audience)
curl -s "$NIMEET_API_BASE/dev/v1/channel/user/$APP_ID/weekly-sync" \
-H "Authorization: Basic $CRED"
# 声网兼容:用户状态(in_channel / role / platform)
curl -s "$NIMEET_API_BASE/dev/v1/channel/user/property/$APP_ID/10001/weekly-sync" \
-H "Authorization: Basic $CRED"已知限制
ip规则当前只记录不参与判定:join_channel判定按(uid, channelName)进行(签发 Token 时没有客户端 IP 可信来源)。需要按 IP 治理时请在网关层完成。mute/role/kick的目标必须是当前在线的参与者,否则返回404 user_not_found/channel_not_found。GET /v1/ban-rules只返回未过期规则;管理「已过期历史」暂不提供。durationSeconds: 0在实现里落为expireAt: null(永久),与「仅本次踢下线、可立即重进」的常见预期不同,接入时请显式传正数。