控制台嵌入客户后台
控制台嵌入(K29)让客户在自己的运营后台里以 iframe 只读视图展示当前应用的概览、统计、用户与群组,而不必把管理员密钥交给宿主页面。链路是:管理员在控制台「嵌入代码」页签发一次性 token → 宿主把 token 拼进 /embed?token=… → 嵌入视图用公开端点兑换 token 并渲染作用域内数据 → 通过 postMessage 上报就绪、高度与事件。
概念
| 对象 | 说明 |
|---|---|
嵌入视图 /embed | 无侧边栏、无写操作的精简页面;只读展示 token 作用域内的数据,不携带 X-Admin-Secret。 |
| 一次性 token | 64 位 hex;首次兑换成功后记录 usedAt,重复兑换 401;过期兑换时移除;按应用最多 200 枚存活。 |
作用域 scopes | overview(应用概览)、stats(近 7 天趋势)、users(最近 20 个用户)、groups(最近 20 个群);缺省 overview + stats。 |
有效期 ttlSec | 30–86400 秒,缺省 900 秒(15 分钟)。 |
| 兑换端点 | 公开 GET /api/embed/session?token=…;缺失/非法/已用/过期统一 401(客户端按 1002 语义处理)。 |
| 宿主通信 | im-embed:ready / im-embed:resize / im-embed:event 三类 postMessage。 |
当前实现由嵌入视图向 window.parent 发送只读展示数据时使用 targetOrigin="*":宿主来源在服务端未知。生产接入必须在宿主侧校验 event.origin,并建议通过固定域名反向代理 + CSP frame-ancestors 限定可嵌入方。
token 是短期一次性的展示凭据,不是管理员凭据:不要写入日志、URL 记录或分享链接;预览一次即消耗。
token 原文保存在服务端状态中(随快照持久化,未做摘要化存储);未过期 token 在重启后仍可兑换。对安全要求更高的部署应改为哈希索引。
前置条件
- 服务端已启动且 Admin API 可达:
curl -sS http://127.0.0.1:8080/health返回{"ok":true}。 - 已在控制台创建应用,并持有可签发 token 的管理员凭据(
X-Admin-Secret,或具备admin及以上角色的子账号)。 - 宿主页面与嵌入视图同源或已配置允许的 iframe 嵌入策略;嵌入视图只读,不需要客户端 SDK。
- 如需把 iframe 放进 HTTPS 页面,嵌入视图也必须通过 HTTPS 提供,避免混合内容拦截。
步骤
- 签发一次性嵌入 token
调用
POST /api/admin/apps/{appkey}/embed/token,按需传scopes与ttlSec。响应中的data.embedUrl已带 token,data.sessionPath是对应的兑换路径。控制台入口:
/console/#embed,勾选作用域后点「生成 token」;页面会给出 iframe 片段与「预览」(预览会消耗 token)。 - 把 token 放进 iframe 地址
把
data.embedUrl拼到服务端 origin 后作为src;不要用srcdoc,也不要经过会记录 URL 的第三方跳转。 - 宿主监听 postMessage
宿主监听
message事件,按event.data.type分发;先校验event.origin是否等于嵌入视图的 origin,再读取 payload。收到
im-embed:resize时更新 iframe 高度;收到im-embed:ready时隐藏宿主骨架屏;收到im-embed:event的error时展示可重试提示。 - 处理过期与一次性
token 过期或已被预览消耗后,兑换返回 401;宿主应重新调用签发接口换新 token,而不是复用旧 URL。
建议每次打开嵌入页都新签一枚 token,TTL 只覆盖本次查看时长。
- 收窄权限并上线
只勾选业务真正需要的 scope;例如客服工作台只需要
users,不要默认签发overview + stats之外的敏感面。在反向代理层限制
/embed与/api/embed/session的可访问来源,并在宿主侧校验 origin。
参数表
签发 token
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
scopes | string[] | 否 | 子集:overview / stats / users / groups;缺省 overview+stats;非法值 4xx(1001)。 |
ttlSec | uint32 | 否 | 30–86400,缺省 900;越界 4xx(1001)。 |
响应字段
| 字段 | 说明 |
|---|---|
token | 64 位 hex,仅本次返回。 |
scopes / ttlSec | 实际生效的作用域与有效期。 |
createdAt / expiresAt | 毫秒时间戳。 |
oneTime / used | 恒为 true / 签发时为 false。 |
sessionPath | /api/embed/session?token=…。 |
embedUrl | {origin}/embed?token=…。 |
securityNotes | 服务端返回的安全提示数组(一次性、短期、作用域、origin 校验)。 |
postMessage 消息
| type | 载荷 | 触发时机 |
|---|---|---|
im-embed:ready | { appkey, scopes } | 兑换成功且首屏渲染完成。 |
im-embed:resize | { height } | 初始化、内容渲染、窗口 resize 后。 |
im-embed:event | { name, detail } | ready(附 appkey/scopes)或 error(附 message)。 |
示例
签发 token(管理员)
curl -sS -X POST "http://127.0.0.1:8080/api/admin/apps/demo/embed/token" \
-H 'X-Admin-Secret: dev-admin' \
-H 'content-type: application/json' \
-d '{"scopes":["overview","stats","users"],"ttlSec":900}'
# 响应(节选)
# {"code":0,"data":{"token":"<64hex>","scopes":["overview","stats","users"],
# "sessionPath":"/api/embed/session?token=<64hex>",
# "embedUrl":"http://127.0.0.1:8080/embed?token=<64hex>",
# "expiresAt":1750000000000,"oneTime":true,"used":false}}兑换 token(公开端点,不需要管理密钥)
curl -sS "http://127.0.0.1:8080/api/embed/session?token=<64hex>"
# 首次兑换:200 + data.view
# 再次兑换 / 过期 / 非法:401(code=1002)
curl -sS -i "http://127.0.0.1:8080/api/embed/session?token=<64hex>" | head -n 1宿主页面:iframe + postMessage
<!-- 1) 服务端签发 token 后,把 embedUrl 填进 src -->
<iframe
id="im-embed"
src="http://127.0.0.1:8080/embed?token=<64hex>"
width="100%"
height="640"
style="border:1px solid #e5e7eb;border-radius:12px"
title="IM 控制台嵌入视图"
referrerpolicy="no-referrer"
></iframe>
<script>
const frame = document.getElementById('im-embed');
// 只接受嵌入视图 origin 的消息(生产必须校验,不要用 '*' 判断)
const EMBED_ORIGIN = 'http://127.0.0.1:8080';
window.addEventListener('message', (event) => {
if (event.origin !== EMBED_ORIGIN) return;
const data = event.data || {};
switch (data.type) {
case 'im-embed:ready':
console.log('embed ready', data.appkey, data.scopes);
break;
case 'im-embed:resize':
frame.style.height = Math.max(320, Number(data.height) || 0) + 'px';
break;
case 'im-embed:event':
if (data.name === 'error') console.warn('embed error', data.detail);
break;
default:
break;
}
});
</script>主动刷新:重新签发并替换 src
async function refreshEmbed(appkey, adminSecret) {
const res = await fetch('/api/admin/apps/' + encodeURIComponent(appkey) + '/embed/token', {
method: 'POST',
headers: {
'content-type': 'application/json',
'X-Admin-Secret': adminSecret,
},
body: JSON.stringify({ scopes: ['overview', 'stats'], ttlSec: 600 }),
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.msg || 'token 签发失败');
document.getElementById('im-embed').src = json.data.embedUrl;
}上例的 fetch 演示的是「由应用服务器代签发」的形态。浏览器端不应持有 X-Admin-Secret;生产环境应把签发请求放在应用服务器,只把短 TTL、窄 scope 的 token 下发给宿主页面。
常见问题
预览一次后再打开就 401?
token 是一次性的,预览已经兑换并消耗。请重新签发;不要把同一 URL 同时用于预览和正式嵌入。
iframe 空白但接口 200?
检查宿主是否因 X-Frame-Options / CSP frame-ancestors 拒绝嵌入;再检查 HTTPS 页面是否嵌入了 HTTP 视图(混合内容)。
能不能嵌到任意第三方站点?
技术上 iframe 不受限,但必须由宿主校验 event.origin,并在网关侧限制可访问来源;当前视图使用 targetOrigin="*" 发送只读数据,生产化收窄已登记为后续项。
token 能当长期凭据用吗?
不能。最长 86400 秒、兑换即失效、只读且受 scope 限制;它不是管理员密钥,也不能调用任何写接口。
为什么 users/groups 只有 20 条?
嵌入视图定位为概览展示,按字典序取前 20 条;完整列表请在控制台「用户管理 / 群组管理」中查看。
应用被删除/停用后嵌入页还能用吗?
不能。兑换时校验应用存在且未停用;已签发的 token 会返回 401。