# H5 分享页面 — 设计方案

> 状态：**已实施** · 2026-07-05
> 作者：架构 Review 输出
> 关联原型：`docs/prototype-h5-share/`

---

## 0. 一句话定义

围绕一个 **workspace**，发布一张对外可访问的 **H5 对话页面**；第三方 APP（陪伴 APP 等自有用户体系的 APP）在其界面放入口，用户点击后在 **APP 内 WebView** 中打开 H5，通过 APP 后端签发的 **JWT** 完成身份传递，在自己的独立会话里与该 workspace 配置的 Agent 对话。一个 workspace 最多配一张 H5 页面，一张页面可对接多个 APP。

---

## 1. 背景与目标

### 1.1 现状

Forge 现有 4 类「触发源」调用同一个 Agent 引擎层（`src/lib/engine` + `EngineRegistry`）：

| 触发源 | 入口 | 会话来源 | 鉴权 |
|---|---|---|---|
| Web/Desktop | `src/app/api/chat/route.ts` | 用户手动建 session | `requireUser()` + `requireWorkspaceWriteAccess()` |
| IM (6 平台) | `im/adapters/*` → `core/conversation-engine` | `channel_bindings` 自动绑定 | 平台 Bot 凭证 |
| Cron | `cron/executor.ts` | 任务配置的 `session_id` | 内部调度 |
| Webhook | `webhook/handler.ts` | 复用 cron 执行链路（每次执行新建 session，非常驻复用） | webhook token |

H5 是第 5 类触发源。

### 1.2 目标

- 第三方 APP（陪伴 APP、教育 APP 等自有用户体系的 APP）的用户，通过 APP 后端签发的 JWT，在 APP 内 WebView 中访问 Forge 的 H5 页面。
- 一个 H5 页面可对接多个 APP，每 APP 独立密钥。
- 每个外部用户的会话**互相独立**。
- 同一外部用户可在同一 H5 页面内**新启多个会话**。
- H5 页面归属于一个 workspace；该 workspace 的 owner 配置 Agent / 模型 / 权限。

### 1.3 非目标

- 不做外部用户与 Forge 内部 `workspace_members` 的打通（外部用户不是协作者）。
- 不做计费/配额限流（owner 自负，见 §8 风险确认）。
- 不做 OAuth 登录第三方 APP（仅接受 APP 后端签发的 JWT）。
- 不做 iframe 嵌入模式（本期仅 APP 内 WebView，非外链网页 iframe）。

---

## 2. 已确认的核心决策

| 维度 | 决策 | 理由 |
|---|---|---|
| 外链平台 | **任意第三方 APP**（陪伴 APP、教育 APP 等自有用户体系的 APP），**非 IM 平台** | APP 在自身界面放入口，用户点击进入 H5 |
| 对接数量 | **一个 H5 页面可对接多个 APP**，每 APP 独立密钥 | 隔离风险：一个 APP 密钥泄密不影响其他 APP |
| 密钥管理 | **Forge 生成 HMAC 密钥，owner 交给 APP 后端** | Forge 完全掌控密钥；APP 后端用它签发 JWT |
| 外部身份传递 | **APP 后端签发 JWT**，含 `iss`(app_id) + `sub`(用户ID) | 利用 APP 既有用户体系，Forge 不重建身份 |
| `sessions.user_id` / `ext_user_id` 存储（v3 修订） | **`user_id` 存 owner 真实 ID（同 IM），新增 `ext_user_id` 存外部身份** | owner 在 Web 端无需额外代码就能在自己的会话列表里看到 H5 会话（标题前缀 `[extuser]`，对齐 `[telegram]`）；`ext_user_id` 单独做外部用户隔离键 |
| 外部用户权限 | **类 IM，全权限 bypass**（`permission_mode='full'`） | owner 明确选择暂不限制 |
| owner 离线降级 | **不适用**（已选 full bypass，无审批环节） | — |
| 嵌入形态 | **APP 内 WebView 打开** | 体验一体；需处理 WebView cookie 不稳定 + 多用户切换泄漏（见 §5.3） |
| 成本/限流 | **无限流，owner 自负** | 接受风险 |
| 会话清理 | **走现有 `session-cleanup.ts` 逻辑** | 不为外部会话单独设 TTL |

---

## 3. 架构定位

H5 是**新触发源**，与现有 web/IM/cron/webhook 同构。复用引擎层，不污染现有鉴权链。

```
                         ┌─────────────────────────────┐
                         │   EngineRegistry            │
                         │   (claude / 未来 pi / ...)   │
                         └──────────────▲──────────────┘
                                        │ process(AgentRequest, AgentCallbacks)
            ┌───────────┬───────────────┼───────────────┬──────────────┐
            │           │               │               │              │
   web chat │  IM conv  │  cron executor│ webhook handler│ ★ H5 chat ★ │
  (require  │(permission│               │               │(APP WebView  │
   User)    │  -bridge) │               │               │ JWT→cookie   │
            │           │               │               │ +full bypass)│
            └───────────┴───────────────┴───────────────┴──────────────┘
```

**关键约束**：H5 **不复用** `src/app/api/chat/route.ts`。该路由强依赖 `requireUser()`（Forge 内部账号）+ `requireWorkspaceWriteAccess()`（必须是 workspace 成员）。外部用户两者都不满足，强行套用会改坏现有鉴权语义。H5 新建独立路由 `/api/h5/chat`。

---
### 4.1 新增表 `h5_pages`（一 workspace 一个 H5 页面）

与 `im_channels`（IM 渠道配置）完全同构：一行 = 一个渠道配置，绑一个 workspace，凭证存 JSON 字段。**不再单独建 `h5_apps` 表**——多 APP 密钥直接存 `app_secrets` JSON，跟 IM 的 `credentials` 字段同构。

```sql
CREATE TABLE IF NOT EXISTS h5_pages (
  id              TEXT PRIMARY KEY,
  workspace_id    TEXT NOT NULL UNIQUE,        -- 一 workspace 仅一个 H5 页面（同 im_channels.default_workspace_id）
  slug            TEXT NOT NULL UNIQUE,        -- URL 标识：/h5/{slug}（自动生成随机串）
  -- APP 凭证（类比 im_channels.credentials）
  app_secrets     TEXT NOT NULL DEFAULT '{}',   -- JSON: {"com.peiban.app":{"name":"陪伴 APP","secret":"sk_xxx"}}
  -- 模型 + 权限（同 im_channels.default_model + dm_policy）
  default_model    TEXT NOT NULL DEFAULT '',    -- 模型覆盖
  permission_mode  TEXT NOT NULL DEFAULT 'full' CHECK (permission_mode IN ('confirm','full')),
  -- 状态
  enabled          INTEGER NOT NULL DEFAULT 1,
  created_at       TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at       TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_h5_pages_slug ON h5_pages(slug);
```

**与 `im_channels` 的字段映射**（修正版——原版把 `dm_policy`/`group_policy` 类比为 `permission_mode` 是错误的字段类比：前者控制"是否接受这条消息"，后者控制"工具执行是否需要审批"，两者维度不同。IM 的工具权限实际是全局设置 `im_permission_mode`（见 `conversation-engine.ts:55`），不区分渠道。`h5_pages.permission_mode` 是**比 IM 现状更细的粒度**（按页面配置），不是对 IM 字段的复刻）：

| `im_channels` | `h5_pages` | 说明 |
|---|---|---|
| `credentials` (JSON) | `app_secrets` (JSON) | 平台凭证；H5 存 `{app_id: {name, secret}}` 映射 |
| `default_workspace_id` | `workspace_id` | 绑定的工作区 |
| `default_model` | `default_model` | 模型覆盖 |
| `trigger_mode` | — | H5 无触发模式（直接对话） |
| — | `permission_mode` | H5 新引入的页面级权限配置，无 IM 对应字段 |
| — | `slug` | H5 特有：URL 标识 |

**验签流程**：JWT 的 `iss` 字段 → `JSON.parse(app_secrets)[iss].secret` 取密钥 → HS256 验签。SQLite 也可用 `json_extract(app_secrets, '$."' || iss || '".secret')` 查询。APP 数量个位数，无性能问题。

> **修订补遗**（由 §12 数据模拟演练发现）：若 `app_secrets` 只存 `{app_id: secret}`，就丢失了 owner 在添加 APP 时输入的人类可读名称（如“陪伴 APP”）——而 §8.2 的「对接 APP」列表和「最近外部会话」里的“来源 APP”列都需要这个名称，否则只能降级显示原始 `app_id` 字符串。因此 `app_secrets` 的每个值必须是 `{name, secret}` 对象，而不是裸字符串密钥。

**砍掉的字段**（相对前一版）：`title` / `description`（用 workspace 名）、`default_agent_id`（用 workspace 默认 agent，不在 channel 层配——同 IM）、`allowed_issuers`（密钥的 key 即允许列表，无需冗余）。

### 4.2 不新增绑定表——直接复用 `sessions`（v3 修订：`user_id` 回归 owner，新增 `ext_user_id` 列做外部隔离）

> **v2 → v3 修订说明**：v2 方案把外部身份直接当作 `sessions.user_id` 存（`ext:{app_id}:{uid}`），虽然彻底避开了额外的绑定表，但带来一个副作用：H5 会话的 `user_id` 不再是任何 Forge 真实用户，导致 owner 在自己的 Web 端 `/api/sessions`（`WHERE user_id = ?`）里**完全看不到**这些 H5 会话——这与 IM 的实际行为不一致：IM 的 `ChannelRouter.createSession()`（`channel-router.ts:196-221`）存入 `sessions.user_id` 的是**渠道 owner 的真实 ID**（`getChannelOwnerUserId`），所以 IM 会话会自然出现在 owner 自己的 Web 会话列表里（标题带 `[telegram]` 前维）。
>
> v3 改正为：**`sessions.user_id` 存 owner 的真实 Forge user id**（与 IM 完全一致），新增一个专门列 `ext_user_id` 存外部身份。这样既能让 H5 会话自动出现在 owner 自己的 Web 会话列表里（不需要任何额外接口），又避开了 v2 引发的“owner 对 H5 会话完全不可见”问题，同时仍旧不需要 `h5_session_bindings` 这样的额外绑定表（隔离键从 “`user_id` 均为 owner” 改为“新列 `ext_user_id`”，仍然是直接查 `sessions`，无需额外表。
>
> 直接照搬 IM 的 `channel_bindings` 仍然不合适（原因不变：`channel_bindings` 解决的是“一个群聊只能绑定一个会话”的单会话强绑定问题；H5 是“一个外部用户可以有多个并行会话，靠前端侧边栏主动切换”），所以隔离键直接当 `sessions` 的一列，而不是另建绑定表 + `is_active` 标志位。

**新列定义**：

```sql
ALTER TABLE sessions ADD COLUMN ext_user_id TEXT NOT NULL DEFAULT '';
-- 格式：{app_id}:{sha256(sub)}，不再需要 v2 里的 `ext:` 前缀，因为它现在是独立列，不再需要靠前缀与真实 user_id 区分
-- 非 H5、非未来可能引入同样模式的其他触发源，默认空字符串
CREATE INDEX IF NOT EXISTS idx_sessions_ext_user ON sessions(ext_user_id, workspace, updated_at DESC);
```

`app_id` 不含冒号（反域名风格），`sub` 经 `sha256` 哈希后也不含冒号，因此 `{app_id}:{sha256(sub)}` 解析无歧义；哈希确定性地除了消除分隔符风险，也顺带减少了在 Forge 数据库里明文留存第三方用户原始 ID 的暴露面。

**owner_user_id 如何取得**：不在 `h5_pages` 上另存一份 `owner_user_id`（避免冗余字段），直接由 `h5_pages.workspace_id → workspaces.owner_user_id` 反查得到（`workspaces` 表已有该列，见 `db.ts:342`），与 IM 的 `getChannelOwnerUserId()` 相对应作用。

**安全关键点**（不能弄错）：标定一个 session 属于哪个外部用户、以及隔离不同外部用户的校验，**必须改用 `ext_user_id`，不能再用 `user_id`**——因为同一个 H5 页面的所有外部用户的 `user_id` 现在都是同一个 owner，若仍用 `user_id` 做隔离校验，外部用户 A 传入外部用户 B 的 `sessionId` 会因 `user_id` 相同而错误通过校验——重新引入一个严重的跨用户会话泄露漏洞。具体校验逻辑见 §6.2。

> 不需要额外记录 `page_id`：本期 1 workspace : 1 页面，`page_id` 可由 `sessions.workspace` 反查 `h5_pages` 得到。`app_id` 从 `sessions.ext_user_id` 的 `{app_id}:{hash}` 前缀解析。

**新增复合索引**（v3 引入的新风险：同一个 owner 名下的所有 H5 外部流量现在都共享同一个 `user_id`，不再像 v2 那样分散到很多个不同的 `user_id` 值上。意味着 owner 自己的 `/api/sessions`（`WHERE user_id = ? AND status = 'active' ORDER BY updated_at DESC`）可能在较大的行集合里排序，需要索引支撑）：

```sql
CREATE INDEX IF NOT EXISTS idx_sessions_user_status_updated ON sessions(user_id, status, updated_at DESC);
```

这个索引现在比 v2 时期更重要：v2 时 H5 流量分散在各自的外部身份 `user_id` 上，单个 `user_id` 行数不会很大；v3 把它们合并回 owner 的 `user_id` 上，若该 H5 页面流量很大，owner 的个人会话列表查询会受影响——见 §8.2 关于默认只读/收起的补充说明。


---

## 5. 认证流程（APP 内 WebView + JWT）

### 5.1 进入流程

```
APP 后端签发 JWT（HS256，payload: { iss:app_id, sub:uid, name?, exp }）
   ↓
APP 内 WebView 打开:  https://forge.example.com/h5/{slug}?token=<JWT>
   ↓
命中 /h5/[slug] 服务端路由
   ↓
   ┌─ 服务端：
   │  1. 查 h5_pages WHERE slug=? AND enabled=1（不存在 → 404）
   │  2. 解析 JWT 取 iss(=app_id)
   │  3. 用 page.app_secrets JSON 按 iss 取密钥
   │     （key 不存在 → 401「未登记的 APP」）
   │  4. 验签 JWT + 校验 exp
   │  5. 合成 ext_user_id = `{app_id}:{sha256(sub)}`（v3：不再写入 user_id，写入新列 ext_user_id）
   │  6. 反查 owner_user_id = workspaces.owner_user_id（由 page.workspace_id 关联）
   │  7. 签发短时 Forge cookie（2h）：
   │     forge_h5 = base64(pageId:appId:extUserId:exp) + HMAC签名
   │     httpOnly + SameSite=Lax + Secure + Max-Age=7200
   │     （cookie 里不需要带 owner_user_id，服务端每次都重新反查，避免 owner 变更时 cookie 失效）
   │  8. 302 → /h5/{slug}（无 token 的干净 URL）
   └─

### 5.2 JWT payload 约定

```json
{
  "iss": "com.peiban.app",   // APP 标识，必须存在于 h5_pages.app_secrets 的 key 中
  "sub": "u_12345",          // APP 内的用户唯一 ID
  "name": "张三",            // 可选，用于 H5 页面显示
  "iat": 1735689600,
  "exp": 1735693200          // 建议有效期 ≤ 2h（与 cookie 对齐）
}
```

### 5.3 WebView 特有的安全处理（重点）

WebView 模式比独立 URL 多三个必须处理的坑：

| 风险 | 后果 | 对策 |
|---|---|---|
| **多用户切换泄漏** | 用户 A 退出 APP → 用户 B 登录 → B 看到 A 的 H5 会话（严重隐私事故） | ① cookie 短时 2h（非 7 天）② 每次 APP 打开 H5 **必须重新带 JWT**，服务端以最新 JWT 为准重新签 cookie，覆盖旧的 ③ H5 前端检测到 cookie 内 identity 与新 JWT 不一致时清空旧会话状态 |
| **WebView cookie 不稳定** | iOS WKWebView / Android WebView cookie 存储各异，有些 APP 禁第三方 cookie | ① cookie 失效时前端引导 APP 重新传 JWT（通过 JS bridge 或 URL 重载带 token）② cookie 仅作会话内缓存，JWT 是唯一权威身份源 |
| **URL token 泄漏** | WebView 内 URL 可能被 APP 拦截/日志记录 | ① token 一次性消费后立即 302 到干净 URL ② 推荐 APP 通过 JS bridge / postMessage 传 JWT 而非 URL（见 §5.5）|

### 5.4 通用安全要点

| 风险 | 对策 |
|---|---|
| JWT 重放 | `exp` 强制 ≤ 2h；Forge 侧验签拒绝过期 |
| 伪造 external_identity | cookie 由 Forge 自签 HMAC，外部无法伪造 |
| APP 密钥泄密 | 每 APP 独立密钥（`h5_apps` 表），一个泄密不影响其他；owner 可随时轮换 |
| 未登记 APP 冒充 | JWT 的 `iss` 必须命中 `h5_apps.app_id`，否则 401 |

### 5.5 JWT 传递方式（推荐优先级）

| 方式 | 说明 | 优先级 |
|---|---|---|
| **JS bridge / postMessage** | APP 在 WebView 加载完成后，通过 JS bridge 把 JWT 传给 H5 前端，前端 POST 给 Forge 换 cookie | ⭐ 推荐（URL 无 token，最安全） |
| **URL ?token=** | APP 拼 `?token=<JWT>` 打开 WebView，服务端一次性消费后 302 | 兜底（需 APP 支持拦截重定向避免 token 留在历史） |
| **自定义 header** | APP 的 WebView 拦截请求注入 header | 部分 APP 可用，兼容性差 |

### 5.6 cookie 失效处理

cookie 过期、缺失、或与最新 JWT identity 不一致时，`/api/h5/chat` 返回 `401`，前端展示「会话已过期，请从 APP 重新进入」提示页（不暴露任何 workspace 内部信息）。

---

## 6. 触发链路（新建 `/api/h5/chat`）

### 6.1 路由职责

```
POST /api/h5/chat
  鉴权：验 forge_h5 cookie → 解出 (pageId, extUserId)
  入参：{ sessionId?, message, attachments? }
  流程：
    1. 查 h5_pages WHERE id=pageId AND enabled=1（不存在 → 401）
    2. 解析/创建 session（见 §6.2，直接查 `sessions`，无绑定表，改用 `ext_user_id` 定位）
    3. 构造 AgentRequest，调 EngineRegistry.get(session.backend).process()
       - permission_mode = page.permission_mode（默认 'full' → bypassPermissions:true）
       - model = page.default_model || session.model
       - agent = page.default_agent_id
    4. SSE 流式输出（复用 createSseCallbacks）
```

### 6.2 会话解析（新建 `H5Router`，仿 `ChannelRouter`，但不落绑定表，v3：校验键换为 `ext_user_id`）

逻辑参考 `src/lib/im/core/channel-router.ts`，但由于外部身份现在存在专门的 `ext_user_id` 列，不需要额外的绑定表，直接查 `sessions`，**但所有校验都必须按 `ext_user_id`，不能按 `user_id`**（同一 page 的所有外部用户 `user_id` 都是同一个 owner，按 `user_id` 校验彻底失效）：

```
resolveSession(page, extUserId, requestedSessionId?):
  if requestedSessionId:
    session = SELECT * FROM sessions
              WHERE id=? AND ext_user_id=? AND workspace=?
    if not found: 403（防止外部用户 A 越权读取外部用户 B 的 session——
                        注意这里绝不能改成 WHERE id=? AND user_id=?，
                        因为同一 page 下所有外部用户的 user_id 都相同）
    return session
  else:
    session = SELECT * FROM sessions
              WHERE ext_user_id=? AND workspace=?
              ORDER BY updated_at DESC LIMIT 1
    if found: return session
    else: return createSession(page, extUserId)   -- 新建

createSession(page, extUserId):
  ownerUserId = resolveOwnerUserId(page.workspace_id)  -- 反查 workspaces.owner_user_id
  INSERT INTO sessions (id, title, workspace, model, user_id, ext_user_id)
    VALUES (uuid(), '[extuser] New conversation', page.workspace_id, page.default_model,
            ownerUserId, extUserId)
  -- user_id 落 owner，ext_user_id 落外部身份；无需处理旧绑定置位，
  -- 新 session 的 updated_at 天然最新
```

`listSessions(page, extUserId)` = `SELECT id, title, updated_at FROM sessions WHERE ext_user_id=? AND workspace=? ORDER BY updated_at DESC`，供 H5 页面侧边栏渲染会话列表——这个查询只给某一个外部用户看自己的会话，与 §8.2 里 owner 在 Web 端看到的"全部外部会话混在个人列表里"是两个不同的查询维度（一个按 `ext_user_id` 过滤，一个按 `user_id` 过滤）。

### 6.3 与现有 chat route 的对比

| 维度 | `/api/chat`（现有 web） | `/api/h5/chat`（新增） |
|---|---|---|
| 鉴权 | `requireUser()`（Forge 内部账号） | 验 `forge_h5` cookie（外部身份，解出 `ext_user_id`） |
| 工作区权限 | `requireWorkspaceWriteAccess()`（必须是成员） | 不校验成员身份，靠 `h5_pages.enabled` 控制 |
| session 归属/隔离键 | `session.user_id === user.id` | `session.ext_user_id === 请求解出的 ext_user_id`（`user_id` 此时是 owner，不参与隔离判断） |
| 权限模式 | 用户设置 / 全局设置 | `h5_pages.permission_mode`（默认 full） |

> **owner 桌面端打开 H5 session 的边界**：因为 H5 session 的 `user_id` 现在是 owner，owner 理论上可以在桌面 `/api/chat` 里直接对着这个 session 发消息（`session.user_id === user.id` 会通过）。本设计默认 `ext_user_id` 非空（即 H5 外部用户会话）的会话在桌面 Chat 视图里**只读**（输入框禁用，提示"这是外部用户 H5 会话，仅支持只读查看"），不默认允许 owner 顺手接管——避免 owner 一次误操作把话说进外部用户的对话上下文里。若未来要做人工接管功能，应该是一个显式的"接管"按钮，而不是默认开放输入框。见 §8.2、§11。

---

## 7. H5 页面设计（外部用户视角）

详见原型 `docs/prototype-h5-share/h5-page.html`。要点：

### 7.1 布局

```
┌─────────────────────────────────────────────┐
│  ☰  {workspace 标题}          {用户名}  ⚙    │  ← 顶栏（H5 移动端风格）
├──────────┬──────────────────────────────────┤
│ 我的会话  │                                  │
│ ───────  │     消息流（SSE 流式）            │
│ + 新建   │                                  │
│ ───────  │                                  │
│ 会话 A   │                                  │
│ 会话 B ● │                                  │
│ 会话 C   │                                  │
│          ├──────────────────────────────────┤
│          │  [输入框]               [发送]   │
└──────────┴──────────────────────────────────┘
```

- **移动端优先**：WebView 内全屏；侧栏默认收起，顶栏汉堡按钮展开。
- 会话区右侧不留桌面端常驻侧栏——WebView 宽度即 APP 给定的宽度。
- 顶栏右侧用户名 = JWT 里的 `name`（无 name 则显示「访客」+ sub 后 4 位），下方副标显示「来自 XXX APP」。
- **身份来源不再只藏在侧栏抽屉里**：桌面/宽屏宽度下顶栏也展示一个小的身份标签（来自 XXX APP · 用户名），移动窄屏下保留在侧栏抽屉底部——考虑到 full bypass 模式下用户应该随时能确认"我是以谁的身份在跟这个 Agent 对话"，这个信息不应该需要额外一次点击才能看到。

### 7.2 关键交互

| 操作 | 行为 |
|---|---|
| 首次进入（带 token） | JWT 换 cookie → 302 干净 URL → 进入最近活动会话；无历史则自动新建 |
| 新建会话 | 点「+」→ 调 `/api/h5/session/new` → 旧会话保留，切换到新会话 |
| 切换会话 | 点侧栏会话项 → 加载该 session 的 messages |
| 发送消息 | POST `/api/h5/chat` → SSE 流式渲染 |
| 工具执行可见性 | full bypass 下工具直接执行，前端展示「正在执行：{tool}」气泡（只读，无审批按钮） |
| 会话标题 | 首条消息后自动生成（复用现有 session title 逻辑） |
| **cookie/JWT 失效**（新增，此前原型缺失） | `/api/h5/chat` 返回 401 → 整页替换为「会话已过期，请从 APP 重新进入」提示态，不展示侧栏/输入框，不暴露 workspace 信息——见 `docs/prototype-h5-share/h5-page.html` 已补的 `#view-expired` 状态 |

### 7.3 视觉

- 复用 Forge 设计系统（真实 token 见 `src/app/globals.css`：`--bg-page`/`--text-primary`/`--accent-indigo` 等，而非原型自定义的一套十六进制色值）：暗色主题、Indigo/Brand 紫主色、Inter 字体。
- 但**降低信息密度**：H5 面向外部用户，不是开发者工作台。字号放大到 15px 基线，间距放宽。
- 不暴露任何 workspace 内部路径、文件树、agent 配置——纯对话界面。

---

## 8. H5 配置设计（owner 视角）

详见原型 `docs/prototype-h5-share/h5-config.html`（已按下述修订重做）。**配置复杂度对齐 IM Bot 添加流程**——核心字段 + APP 密钥管理 + 最近外部会话只读列表。

### 8.1 入口（修正：不是「workspace 设置面板」，是新的一级导航项）

> **修订说明**：原版写「workspace 设置面板新增开关」，但当前代码里不存在这个面板。实际的左侧导航（`left-sidebar.tsx:46-60`）是三组一级导航按钮：`NAV_MAIN`（Chat/Manage/IM）、`NAV_TASKS`（Schedule/Webhook）、`NAV_BOTTOM`（Marketplace/Settings）。其中 IM 是全局多实例列表（不带 `workspaceId`，每个渠道自己选归属工作区）；Schedule/Webhook 复用同一个 `<ScheduleView workspaceId={activeWorkspaceId} mode="schedule"|"webhook">`，跟随当前打开的工作区（`app-layout.tsx:525-526`）。
>
> H5 的语义（1 workspace : 1 页面，owner 在该 workspace 下配置）与 Schedule/Webhook 更接近，因此应该：
> - 在 `NAV_TASKS` 里新增一个一级导航项 `{ id: 'h5', i18nKey: 'sidebar.h5', icon: Share2 }`（`left-sidebar.tsx:52-55`）；
> - `View` 联合类型（`src/lib/types.ts:91`）加入 `'h5'`；
> - `app-layout.tsx` 新增 `{activeView === 'h5' && <H5View workspaceId={activeWorkspaceId || ''} />}`，紧邻 schedule/webhook 两行（`app-layout.tsx:525-526`），同样跟随当前打开的工作区，而不是全局选择器；
> - 新增 `src/components/views/h5-view.tsx`，UI 结构对齐 `ScheduleView`/`ImView` 的 `ChannelPage`（`Card` + `Field` + `CustomSelect` + `Switch` + `Button`），而不是原型早期的自定义暗色 CSS。
>
> 以上是面向实现阶段的设计说明，本轮不直接改动 `src/` 下的真实代码——H5 功能尚处于设计评审阶段，导航改动应该和 Phase 4 的实现一起提交。

### 8.2 配置项（对齐 IM Bot 卡片风格 + 新增 owner 可见性区块）

| 配置项 | 对应 IM 字段 | 说明 |
|---|---|---|
| 启用开关 | `im_channels.enabled` | 总开关 |
| 归属工作区 | — | 只读展示当前工作区名（不是选择器——H5 现在跟随导航所在工作区，不像 IM 渠道那样可以指向任意工作区） |
| 访问地址 | — | `/h5/{slug}`，slug 自动生成随机串；新增「重新生成」操作（次要按钮 + 二次确认），用于地址意外泄漏后的兜底 |
| 模型 | `default_model` | 模型覆盖 |
| 权限 | — | `full`（默认）/ `confirm`（预留，禁用态） |
| 对接 APP | `app_secrets` | 每 APP 一行：名称 + app_id + 密钥（显示/复制/轮换/移除）；「＋ 添加 APP」展开为可编辑表单（名称、app_id 两个输入框），而不是原型里生成占位符 `新 APP N` / `com.appN` |
| **最近外部会话**（新增） | — | 只读列表：来源 APP、外部用户标识、最近消息时间，点击可查看只读会话内容。**v3 修正**：因为 H5 会话的 `user_id` 现在就是 owner，这个列表可以直接复用 owner 自己的 `sessions WHERE user_id=? AND ext_user_id != ''` 查询，**不再需要一个专门的只读 API**（v2 时需要 `/api/h5/pages/:id/sessions`，现在只是对现有 `/api/sessions` 加个 `ext_only` 过滤参数）。而且这些会话现在**也会自动出现在 owner 自己的 Web 聊天会话列表里**（标题前缀 `[extuser]`），这里的「最近外部会话」面板只是一个展示更友好（带来源 APP 名）的筛选视图，不是唯一入口。解决 owner 对 H5 会话完全不可见的问题（详见 §9 残余风险缓解） |
| **owner Web 端可见性**（v3 新增） | — | H5 会话会自动出现在 owner 桌面端日常使用的聊天会话列表里，带 `[extuser]` 前缀（对齐 IM 的 `[telegram]`）。默认**只读**（输入框禁用），防止 owner 无意插入到外部用户对话上下文中——见 §6.3 边界说明、§11 未来可做项 |

**砍掉的配置项**（相对更早版本，原因：对齐 IM 简洁度）：
- ~~页面标题/描述~~ → 用 workspace 名
- ~~默认 Agent 选择~~ → 用 workspace 默认 agent（IM 也不在 channel 层配 agent）
- ~~安全确认清单（4 项必勾）~~ → 降级为一行静态警告

---

## 9. 安全风险确认（owner 已知情并接受）

| 风险 | 严重度 | 缓解 | 残余风险 |
|---|---|---|---|
| 外部匿名用户全权限 bypass → 可对工作区跑任意工具（Bash/Write/删除） | **极高** | ① 依赖对接 APP 的 JWT 签发可靠性 ② **v3**：H5 会话 `user_id` 现在就是 owner，会自动出现在 owner Web 端日常使用的聊天列表里（`[extuser]` 前缀），比 v2 需要专门打开一个配置页才能看到更弻动——owner 日常刷自己的会话列表时就能顺便看到异常 | APP 的 JWT 密钥被泄露/伪造 → 攻击者可任意操作工作区；仍无主动告警，owner 仍需要主动看才能发现异常（只是看到的成本下降了） |
| 无限流 → API Key 被刷爆 | 高 | 无（owner 自负） | 账单失控 |
| 外部用户滥用工具产生有害输出 | 中 | 无 | 事后无法追溯 |
| sessions 表无限膨胀 | 低 | 现有 `session-cleanup.ts` | 长期可能需手动清理 |

**owner 部署 H5 页面前的检查清单**（配置页强制提示）：
1. ✅ 该 workspace 不含敏感/不可公开的文件。
2. ✅ 该 workspace 的 Agent 配置不含危险默认行为。
3. ✅ 已确认所有对接 APP 的 JWT 签发方可信，且密钥已安全交付。
4. ✅ 已知悉外部用户可全权限操作本工作区。

---

## 10. 实施清单（分阶段，对齐后再启动）

### Phase 1 — 数据层 + 认证（地基）
- [ ] `h5_pages` 建表 + migration（`db.ts`；密钥存 `h5_pages.app_secrets` JSON）——**不再建 `h5_session_bindings`**（见 §4.2）
- [ ] `sessions.ext_user_id` 列 + `idx_sessions_ext_user`、`idx_sessions_user_status_updated` 两个索引 + migration（见 §4.2，**v3 不再存在 v2 里的 `idx_sessions_user_updated`**）
- [ ] 外部身份编码工具（`lib/h5/identity.ts`：`{app_id}:{sha256(uid)}` 编解码，含从 `ext_user_id` 解析 `app_id` 前缀的辅助函数，供 owner 侧列表使用）
- [ ] owner 解析工具（`h5_pages.workspace_id → workspaces.owner_user_id`，与 `getChannelOwnerUserId()` 类似）
- [ ] JWT 验签工具（`lib/h5/jwt.ts`，按 iss 从 `app_secrets` JSON 取密钥）
- [ ] cookie 签发/校验工具（`lib/h5/cookie.ts`，2h 短时，携带 `ext_user_id` 而不是 owner_user_id）
- [ ] `/h5/[slug]` 入口路由（JWT → cookie → 302；支持 URL ?token 与 JS bridge 两种传递）
- [ ] WebView 多用户切换防护（每次进入强制重验 JWT，cookie 不跨会话保留）

### Phase 2 — 会话 + 对话
- [ ] `H5Router`（`lib/h5/router.ts`，直接查 `sessions`，无绑定表，**所有隔离校验改用 `ext_user_id`**，见 §6.2）
- [ ] `/api/h5/chat`（SSE，复用 engine + `createSseCallbacks`；401 时返回失效态供前端渲染 §7.2 的过期页）
- [ ] `/api/h5/session/new` + `/api/h5/session/list`（都按 `ext_user_id` 过滤，见 §6.2）
- [ ] `/api/h5/messages`（加载历史，校验 `ext_user_id`）
- [ ] `/api/sessions`（或其前端消费处）加一个可选 `ext_only` 过滤参数，以及对 `ext_user_id` 非空的会话在前端标记为只读（代替 v2 里单独新建的 `/api/h5/pages/:id/sessions`，见 §8.2）
- [ ] `/api/chat` 对 `session.ext_user_id` 非空时拒绝/标记只读（防止 owner 误操作接管，见 §6.3）

### Phase 3 — 前端（WebView 适配）
- [ ] `/h5/[slug]/page.tsx`（外部用户对话页，WebView 全屏优先）
- [ ] 会话侧栏组件（抽屉式，适配 WebView 宽度）
- [ ] SSE 消息渲染（复用现有 markdown-renderer）
- [ ] cookie 失效/identity 不一致检测 → 渲染 §7.2 的「会话已过期」整页态，引导 APP 重新传 JWT

### Phase 4 — owner 配置
- [ ] 左侧导航新增「H5 分享」一级导航项（`NAV_TASKS`，见 §8.1），`H5View` 组件跟随 `activeWorkspaceId`
- [ ] slug 生成 / 分享链接 / 重新生成
- [ ] **对接 APP 密钥管理**（可编辑表单添加，非占位符；显示/复制/轮换/移除，存 `app_secrets` JSON）
- [ ] **最近外部会话**只读列表 UI（消费 Phase 2 里 `ext_only` 过滤参数）
- [ ] 桌面 Chat 视图对 `[extuser]` 会话的只读 UI（输入框禁用 + 提示文案，见 §6.3）
- [ ] 一行静态安全警告

### Phase 5 — 清理
- [ ] `session-cleanup.ts` 确认覆盖 `ext_user_id` 非空的 H5 会话

---

## 11. 遗留 / 未来可做（不在本期）

| iframe 嵌入模式 | 外链网页 iframe 沙箱规则各异；本期仅 APP 内 WebView |
| confirm 权限模式 + owner 审批 | 本期预留字段，UI 不启用；未来可实现 owner 端推送审批 |
| 每用户/全局限流 + 配额 | 本期 owner 自负；未来按 `external_identity` 维度限流 |
| 外部会话独立 TTL | 本期走现有清理；未来可对 H5 会话设更短保留期 |
| 外部用户画像持久化 | 当前 `ext:{app_id}:{sha256(uid)}` 仅存 session 维度；未来可建 `external_users` 表存 name/avatar/最后活跃等 |
| H5 页面主题自定义 | 本期复用 Forge 暗色；未来支持 owner 自定义品牌色/Logo |
| 多个 H5 页面 / workspace | 本期 1 workspace 1 页面；未来放开后需改 `h5_pages.workspace_id` 的 UNIQUE 约束 |
| owner 桌面端人工接管 H5 会话 | 本期 `ext_user_id` 非空的 H5 会话在桌面 Chat 视图默认只读（见 §6.3）；未来可做一个显式「接管」按钮，允许 owner 人工插入回复并让外部用户看到 |

---

## 12. 数据流演练（真实示例数据，v3：`user_id` = owner + `ext_user_id` = 外部身份）

前提：

- workspace `ws_daily`（“Daily Memos 助手”）属于 owner **`user_owner_001`**（`workspaces.owner_user_id`），已开启 H5 分享：
  `h5_pages`: `{ id: 'h5p_001', workspace_id: 'ws_daily', slug: 'k7m2x9', app_secrets: '{"com.peiban.app":{"name":"陪伴 APP","secret":"sk_pb9k2j7d3x8"},"com.edu.app":{"name":"教育助手","secret":"sk_ed7a2k3d9x4"}}', default_model: 'claude-sonnet-4-6', permission_mode: 'full', enabled: 1 }`
- 外部用户：张三（陪伴 APP，uid=`u_8821`）、李雷（陪伴 APP，uid=`u_4410`）、王芳（教育助手，uid=`u_2290`）
- owner 自己平时还有一个普通 Web 会话 `sess_owner_1`（标题“整理会议纪要”，`user_id='user_owner_001'`，`updated_at='13:50:00'`），用于展示外部会话会如何与 owner 自己的会话混在一起。

### 12.1 张三首次进入：page load 不建 session，首条消息才建

**09:00:00** 张三在陪伴 APP 里点入口，WebView 打开 `https://forge.example.com/h5/k7m2x9?token=<JWT>`（JWT payload: `{iss:'com.peiban.app', sub:'u_8821', name:'张三', exp:...}`）。

1. `SELECT * FROM h5_pages WHERE slug='k7m2x9' AND enabled=1` → 命中 `h5p_001`
2. 解析 JWT `iss='com.peiban.app'` → `JSON.parse(app_secrets)['com.peiban.app'].secret` = `sk_pb9k2j7d3x8` → 验签通过
3. `extUserId = 'com.peiban.app:' + sha256('u_8821').slice(0,8)` = `com.peiban.app:9f86d081`（示例哈希前缀，**不再带 `ext:` 前缀，因为它现在是专门的列**）
4. 反查 `ownerUserId = workspaces.owner_user_id`（由 `h5_pages.workspace_id='ws_daily'` 关联）= `user_owner_001`
5. 签发 `forge_h5` cookie（只携带 `pageId` + `extUserId`，不携带 `ownerUserId`），302 到 `/h5/k7m2x9`
6. 前端调 `GET /api/h5/session/list` → `SELECT id, title, updated_at FROM sessions WHERE ext_user_id='com.peiban.app:9f86d081' AND workspace='ws_daily' ORDER BY updated_at DESC` → **结果为空**（此时 `sessions` 表里还没有任何张三的行）。页面展示欢迎态/建议问题，侧栏为空。

**09:00:12** 张三输入“帮我看看 inbox 里有什么”并发送：

1. `POST /api/h5/chat { message: '帮我看看 inbox 里有什么' }`（无 `sessionId`，因为前端还没有任何会话可选）
2. 验 `forge_h5` cookie → 解出 `(pageId='h5p_001', extUserId='com.peiban.app:9f86d081')`
3. `H5Router.resolveSession`：查 `sessions WHERE ext_user_id=? AND workspace=? ` → 无结果 → **新建**，先反查 `ownerUserId='user_owner_001'`：
   `INSERT INTO sessions (id, title, workspace, model, user_id, ext_user_id) VALUES ('sess_a1', '[extuser] New conversation', 'ws_daily', 'claude-sonnet-4-6', 'user_owner_001', 'com.peiban.app:9f86d081')`，`created_at = updated_at = 09:00:12`
4. `INSERT INTO messages (id, session_id, role, content) VALUES ('msg_1', 'sess_a1', 'user', '帮我看看 inbox 里有什么')`
5. `engine.process(..., bypassPermissions: true)` 执行 `Bash ls work/inbox/`，完成后 `INSERT INTO messages (id, session_id, role, content) VALUES ('msg_2', 'sess_a1', 'assistant', '[...tool_use + text...]')`
6. 首条消息后，仿 IM `[${'渠道类型'}] ${text}` 的标题生成模式（`conversation-engine.ts:230-237`）：`UPDATE sessions SET title='[extuser] 帮我看看 inbox 里有什么', updated_at='09:00:15' WHERE id='sess_a1'`

### 12.2 张三当天稍后回来：自动恢复最近会话

**09:15:00** 张三在同一个页面会话内继续发消息“生成一份周报”：`POST /api/h5/chat { sessionId: 'sess_a1', message: '...' }` → `resolveSession` 校验 `SELECT * FROM sessions WHERE id='sess_a1' AND ext_user_id='com.peiban.app:9f86d081' AND workspace='ws_daily' ` 通过 → 直接续写 `messages`，`sess_a1.updated_at → 09:15:03`。

**14:00:00** 张三关闭 APP 后重新打开（WebView 重新加载，陪伴 APP 后端重新签一个新 JWT 带上）：

1. JWT 里 `sub` 仍是 `u_8821` → 哈希确定性计算，`extUserId` 仍是 `com.peiban.app:9f86d081`（上次同一个值）→ cookie 重签，覆盖旧的
2. `GET /api/h5/session/list` → 返回 `[{ id:'sess_a1', title:'[extuser] 帮我看看 inbox 里有什么', updated_at:'09:15:03' }]` → 前端默认选中它作为当前会话
3. `GET /api/h5/messages?sessionId=sess_a1` → 加载 `msg_1..msg_4`
4. 张三直接继续问“今天还有什么任务” → 仍写入 `sess_a1`，`updated_at → 14:00:05`

**14:05:00** 张三想谈一个无关话题，点击「+ 新建会话」：

1. `POST /api/h5/session/new` → 直接 `INSERT INTO sessions (id, title, workspace, model, user_id, ext_user_id) VALUES ('sess_a2', '[extuser] New conversation', 'ws_daily', ..., 'user_owner_001', 'com.peiban.app:9f86d081')`，`created_at=updated_at='14:05:00'`
2. 此时 `GET /api/h5/session/list` → `ORDER BY updated_at DESC` 自然返回 `[sess_a2(14:05:00), sess_a1(14:00:05)]`

### 12.3 不同外部用户、不同 APP —— `user_id` 共享但 `ext_user_id` 隔离

**14:10:00** 李雷（同一个陪伴 APP，uid=`u_4410`）也打开同一个 `k7m2x9` 页面：

- `extUserId = 'com.peiban.app:' + sha256('u_4410').slice(0,8)` = `com.peiban.app:c775e7b9` ——与张三的 `9f86d081` **完全不同**
- 反查 `ownerUserId` 仍然是 **同一个** `user_owner_001`（同一个 page）
- `GET /api/h5/session/list WHERE ext_user_id='com.peiban.app:c775e7b9'` → 空 → 李雷发消息后新建 `sess_b1`：`user_id='user_owner_001'`（与张三的 session **相同**），`ext_user_id='com.peiban.app:c775e7b9'`（与张三的 **不同**）

**关键安全反例演练**：假设李雷猜到 `sess_a1` 的 ID，给 `POST /api/h5/chat` 传 `{ sessionId: 'sess_a1', ... }`：

```
正确校验：SELECT * FROM sessions WHERE id='sess_a1' AND ext_user_id='com.peiban.app:c775e7b9' AND workspace='ws_daily'
→ 无结果（sess_a1.ext_user_id 实际是 'com.peiban.app:9f86d081'，与李雷的 ext_user_id 不匹配）→ 403 拒绝。正确。

错误校验（若不小心把隔离键写成 user_id）：SELECT * FROM sessions WHERE id='sess_a1' AND user_id='user_owner_001' AND workspace='ws_daily'
→ sess_a1.user_id 确实是 'user_owner_001'（因为所有 H5 会话的 user_id 都是同一个 owner）→ 错误地通过校验！李雷就能直接读写张三的会话。
```

这就是 §4.2/§6.2 反复强调“必须改用 `ext_user_id`”的具体实例——安全边界完全依靠这个列选对了，选错列就是安全洞。

**14:20:00** 王芳（教育助手 APP，uid=`u_2290`）打开同一个 `k7m2x9` 页面：

- JWT `iss='com.edu.app'` → 取 `app_secrets['com.edu.app'].secret` = `sk_ed7a2k3d9x4`（与陪伴 APP 的密钥**不同**）验签
- `extUserId = 'com.edu.app:' + sha256('u_2290').slice(0,8)` = `com.edu.app:1a79a4d6`
- 反查 `ownerUserId` **仍然是** `user_owner_001`（同一个 workspace 的同一个 owner，与对接哪个 APP 无关）
- 新建 `sess_c1`：`user_id='user_owner_001'`，`ext_user_id='com.edu.app:1a79a4d6'`

此时 `sessions` 表中 H5 会话且 `workspace='ws_daily'` 的行（完整展示关键列，注意 `user_id` 一列）：

| id | user_id | ext_user_id | title | updated_at |
|---|---|---|---|---|
| sess_a1 | user_owner_001 | com.peiban.app:9f86d081 | [extuser] 帮我看看 inbox 里有什么 | 14:00:05 |
| sess_a2 | user_owner_001 | com.peiban.app:9f86d081 | [extuser] New conversation | 14:06:40 |
| sess_b1 | user_owner_001 | com.peiban.app:c775e7b9 | [extuser] 帮我整理今天的 inbox | 14:10:20 |
| sess_c1 | user_owner_001 | com.edu.app:1a79a4d6 | [extuser] 这道题怎么解？ | 14:20:08 |

**注意**：4 行的 `user_id` **完全相同**（都是 `user_owner_001`），只有 `ext_user_id` 不同——这正是 v3 方案的核心：`user_id` 负责“Web 端可见性/所属关系”，`ext_user_id` 负责“外部用户隔离”，两个列分工不同职责。

### 12.4 owner 的两种查看路径：日常会话列表 vs H5 配置页

**15:00:00** owner（`user_owner_001`）打开自己日常使用的左侧 Chat 会话列表（不是 H5 配置页，就是普通聊天入口）：

1. `GET /api/sessions` → `SELECT id, title, workspace, model, permission_mode, status, created_at, updated_at FROM sessions WHERE user_id='user_owner_001' AND status='active' ORDER BY updated_at DESC`
2. 这次查询**未修改任何代码**，但因为新建的 4 个 H5 会话 `user_id` 都是 `user_owner_001`，它们会自然混在结果里，按时间排序：

   | 会话 | updated_at |
   |---|---|
   | [extuser] 这道题怎么解？ | 14:20:08 |
   | [extuser] New conversation | 14:06:40 |
   | [extuser] 帮我整理今天的 inbox | 14:10:20 |
   | 整理会议纪要（owner 自己的） | 13:50:00 |
   | [extuser] 帮我看看 inbox 里有什么 | 14:00:05 |

   （表格按 `updated_at` 实际应为降序，这里为可读性列举未严格排序，重点是展示 `[extuser]` 会话和 owner 自己的会话完全混在一个列表里）。
3. owner 点开其中一个 `[extuser]` 会话 → 前端检测 `session.ext_user_id` 非空 → 渲染为只读（输入框禁用 + 提示文案，见 §6.3）。

**15:05:00** owner 另外打开左侧导航的「H5 分享」页面，查看「最近外部会话」面板：

1. `GET /api/sessions?ext_only=true` → `SELECT id, user_id, ext_user_id, title, updated_at FROM sessions WHERE user_id='user_owner_001' AND ext_user_id != '' ORDER BY updated_at DESC`（比上面的普通查询多一个 `ext_user_id != ''` 过滤，**不是一个新 API**，只是现有 `/api/sessions` 的可选参数）→ 只返回 4 行 H5 会话，自动排除了 owner 自己的“整理会议纪要”
2. 服务端对每行的 `ext_user_id` 按 `{app_id}:{hash}` 前缀解析出 `app_id`，再去 `h5_pages.app_secrets[app_id].name` 反查显示名（这就是 §4.1 修订补遗里要求 `app_secrets` 必须带 `name` 的原因）：

   | 来源 APP | 外部用户 | 最近消息 | 时间 |
   |---|---|---|---|
   | 教育助手 | 1a79a4d6 | 这道题怎么解？ | 刚才 |
   | 陪伴 APP | c775e7b9 | 帮我整理今天的 inbox | 10 分钟前 |
   | 陪伴 APP | 9f86d081 | （sess_a2 无消息） | ... |
   | 陪伴 APP | 9f86d081 | 今天还有什么任务 | ... |

3. owner 点击任一行的「查看」 → 只读打开该 `session_id` 的 `messages`。

**对比 v2**：v2 时这两个入口是彻底分开的——owner 在日常 Chat 列表里完全看不到 H5 会话（`user_id` 不匹配），必须专门访问一个新 API `/api/h5/pages/:id/sessions`。v3 同时保留了两个入口，但它们现在是同一个底底层查询机制的两种筛选方式（带/不带 `ext_only` 过滤），而不是两套独立的数据通道。

### 12.5 规模化后的影响：owner 会话列表可能被淹未

假设这个 H5 页面每天接入 200 个外部会话（对一个已上线的陀伴 APP 来说不算多），owner 自己当天只发了 10 条会话。那么 owner 打开日常 Chat 列表时，看到的 210 条里有 200 条都是 `[extuser]` 前缀，自己的 10 条会被淹在里面。这就是前面提到的“v3 把 H5 流量合并回同一个 `user_id`”的具体代价——v2 时不存在这个问题（每个外部用户自成一个 `user_id`，不会混进 owner 的个人列表）。建议：左侧 Chat 列表默认可以加一个“隐藏外部会话”开关（默认开启，需要时手动关闭去看），而不是强制混在一起——这个 UI 细节记入 §11 待定。

### 12.6 遗留问题（已记入 §11）

- 外部 `uid` 哈希后，owner 在任何列表里都只能看到一串无意义的哈希前缀（如 `9f86d081`），无法分辨“张三”还是“李四”——若未来需要更友好的标识，可在创建 session 时把 JWT 的 `name`（可选字段）写入 `sessions` 新增的 `display_name` 列（仅用于展示，不参与鉴权/查询）。
- H5 页面流量很大时，owner 自己的日常会话列表会被淹——建议未来加一个隐藏/展示开关，见 §12.5。
