# 网页嵌入(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` |