# 网页嵌入(Embed Channel)
想给自己的官网、帮助中心加一个「问文档」的客服挂件,用嵌入渠道:在 WeKnora 里建一个渠道并绑定 Agent,拿到一段 `
```
publish token 直接写在页面 HTML 中,任何访客可见;轮换 token 需要同步更新所有部署页面。适合内部站点或低敏感场景。
### 方式二:安全模式(Secure Mode,推荐)
publish token 只保存在站长自己的后端,页面通过 `data-token-endpoint` 指向站长后端的一个换取接口:
```html
```
站长后端实现该 endpoint:服务端持有 `em_` token,调用 `POST /api/v1/embed/{channel_id}/exchange` 换取 `ems_` 短效 token 并返回 `{ "token": "ems_...", "expiresIn": 1800 }`。挂件会在约 80% TTL 时(不早于 30 秒)自动刷新 token(见 `weknora-widget.js` 中的 `scheduleRefresh`)。**publish token 永不到达浏览器。**
其余可选属性:`data-base-url`(默认从 script src 推导)、`data-width` / `data-height`(面板尺寸,默认 400×600)、`data-sandbox`(iframe sandbox 策略;跨域嵌入时自动加 `allow-scripts allow-forms allow-popups allow-modals allow-same-origin`)。
### 方式三:编程式 API
```html
```
### 直接 iframe 接入
也可以不用 loader,直接内嵌 iframe(此时需要通过 URL/postMessage 提供 token,通常建议使用 loader):
```html
```
### postMessage Bridge 协议
宿主页(loader)与 iframe 内嵌入页之间通过 `postMessage` 通信,双方都做严格的 Origin 校验(loader 只向推导出的 `embedOrigin` 发消息,绝不使用 `*`;嵌入页对首个可信消息做 origin 固定 —— 见 `frontend/src/composables/useEmbedBridge.ts`):
- 宿主 → iframe(`source: "weknora-host"`):`provide_token`(下发 token)、`set_context`、`set_locale`、`open_with_query`;
- iframe → 宿主(`source: "weknora-embed"`):`ready`、`bootstrap_request`(请求 token)、`message_sent`、`message_received`。
## 端到端时序
```mermaid
sequenceDiagram
autonumber
participant Visitor as "访客浏览器"
participant Host as "宿主页面 (weknora-widget.js)"
participant Backend as "站长后端 (安全模式可选)"
participant Iframe as "嵌入页 SPA (/embed/:channel_id)"
participant API as "WeKnora API (/api/v1/embed/:channel_id)"
participant Webhook as "站长 Webhook"
Visitor->>Host: 加载页面, script 标签自动初始化
Host->>Iframe: 创建 iframe (悬浮面板)
Iframe-->>Host: postMessage "bootstrap_request"
alt 安全模式 (data-token-endpoint)
Host->>Backend: GET /weknora/embed-token
Backend->>API: POST /exchange (Authorization: Embed em_...)
API-->>Backend: "{ session_token: ems_..., expires_in: 1800 }"
Backend-->>Host: "{ token: ems_... }"
else 静态模式 (data-token)
Note over Host: 直接使用页面内的 em_ token
end
Host-->>Iframe: postMessage "provide_token"
Iframe->>API: GET /config (Authorization: Embed token)
API-->>Iframe: "EmbedChannelPublicConfig (无任何密钥)"
Iframe->>API: POST /sessions
API-->>Iframe: "{ id, sig (HMAC-SHA256) }"
Note over Iframe: session id + sig 存入 localStorage
Visitor->>Iframe: 输入问题
Iframe->>API: POST /agent-chat/:session_id (X-Embed-Session: sig)
API-->>Webhook: 异步 POST message_sent (X-WeKnora-Signature)
API-->>Iframe: SSE 流式回复
API-->>Webhook: 异步 POST message_received
Iframe-->>Host: postMessage "message_received"
```
## 安全要点小结
- **Origin 白名单**:`allowed_origins` 为空时拒绝所有请求;`*` 通配仅开发模式可用;支持 `*.example.com` 子域名通配。
- **双 token 体系**:安全模式下 publish token 不出服务端,浏览器只持有 30 分钟短效 `ems_` token。
- **会话签名**:`X-Embed-Session` HMAC 签名把会话绑定到(渠道、会话、当前 publish token)三元组,轮换 token 即可全量吊销。
- **三层限流**:单 IP/分钟、渠道/分钟(20 倍单 IP、下限 120)、渠道/天,Redis Lua 原子实现。
- **Webhook SSRF 防护**:仅 HTTPS、内网地址拒绝、重定向逐跳校验、5 秒超时。
## 实现参考
想读源码时按下表定位(路径相对仓库根目录):
| 层 | 文件 |
| --- | --- |
| 数据结构 | `internal/types/embed_channel.go` |
| HTTP Handler | `internal/handler/embed_channel.go` |
| 渠道服务 | `internal/application/service/embed_channel.go` |
| 匿名会话/Token | `internal/application/service/embed_session.go` |
| Webhook 分发 | `internal/application/service/embed_webhook.go` |
| 鉴权中间件 | `internal/middleware/embed_auth.go` |
| 路由注册 | `internal/router/router.go`(`RegisterEmbedPublicRoutes` / `RegisterEmbedChannelRoutes`) |
| 挂件加载器(SDK) | `frontend/public/weknora-widget.js` |
| 嵌入页 SPA 入口 | `frontend/src/embed-main.ts`、`frontend/src/composables/useEmbedBridge.ts`、`useEmbedChatSession.ts` |
| 数据库迁移 | `migrations/versioned/000060_embed_channels.up.sql` |