Appearance
模式 B 薄壳 SaaS 接入指南(浏览器前端直连)
读者:要新起一个自营 / 孵化 SaaS(自有前端、无自建用户体系)的工程师或 AI coding agent。 形态:自有前端 + OIDC 胶水;浏览器持平台 JWT 直连
/api/v1/app/conversations*(ADR-0052 双鉴权的 JWT 通道);用户管理、充值、租户后台零代码。 通道仲裁(ADR-0058):API + 模式 B 是自营 SaaS 唯一使能路线;代码级垂直通道已冻结。服务端集成视角(模式 A,API-Key + 影子用户)见saas-integration-cookbook.md——那是外部二开方的通道,自营站点不要走。 交付物坐标(SM2-1):模板包frontend/packages/web-saas-template/· bootstrap 脚本frontend/scripts/new-saas-app.mjs· 部署片段frontend/packages/web-saas-template/deploy/compose-snippet.yml。 契约事实源:openapi.yaml;本文冲突时以 openapi 为准。
§0 一分钟定位:你需要写什么、不写什么
| 你写 | 你不写 |
|---|---|
| 前端页面(从模板派生:导航 / 文案 / 品牌定制) | 登录 / 注册 / 忘记密码(Keycloak 原生托管) |
| (仅当有自有领域实体时)最小自有后端 + HTTP Tool | 用户表 / 钱包 / 充值 / 租户后台(平台全部现成) |
| Keycloak client 与 env 配置 | 影子用户同步 / API-Key(那是模式 A) |
§1 前置:Keycloak SPA client
共享 realm(ADR-0003,realm 名 agenthub)下为你的 SaaS建一个 public SPA client:
- 建 client:Client ID 建议
agenthub-<你的saas名>;Type = public(SPA,PKCE);Root/Redirect URI =https://<你的域名>/callback;Web Origins = 同域。 - 四个 protocol mapper(与平台既有 client 同款;
scripts/seed-testbed.sh的 mapper 工厂是形状参照):mapper 类型 说明 aud-<client_id>audience 把 client id 加进 token aud(中间件受众校验需要)tenant_iduser attribute → claim 活跃运营域(单值,SM1-2 切换端点改写) user_iduser attribute → claim 平台侧该域内的本地用户 ID permissionsuser attribute → claim(多值) 平台 scopes(JIT/claim sync 自动维护) - scope 约定:
saas_user角色自带conversation:read/create/end等;模板的页面门禁用conversation:read。若你的前端有额外平台操作,经 Builder 的角色配置追加,不要在 SaaS 侧造平台 scope。
§2 bootstrap:从模板派生
bash
cd frontend
node scripts/new-saas-app.mjs --name edu-saas --target ../edu-saas脚本复制模板并替换占位;随后二选一处理 workspace 依赖(@agent-hub/shared / @agent-hub/ui): ① 源码内联(完全独立仓)或 ② 私有 registry 版本依赖(多 SaaS 共享升级)。诚实边界:脚本不做这一步(见模板 README)。
env 表(.env 或构建参数):
| 变量 | 必填 | 说明 |
|---|---|---|
VITE_KEYCLOAK_CLIENT_ID | ✅ | §1 建的 client |
VITE_KEYCLOAK_URL / VITE_KEYCLOAK_REALM | ✅ | 对外可达的 Keycloak 地址 / agenthub |
VITE_API_BASE_URL | 建议空 | 同源经 nginx 反代 /api → 平台后端 |
VITE_PROJECT_ID | ✅ | 运营给你建的项目 ID(归因维度;域内余额共享) |
§3 身份接线:OIDC 三页 + JIT + 多归属切换
模板已内置(派生后无需改动,理解语义即可):
- OIDC 三页:
LoginPage(PKCE 授权跳转)/CallbackPage(换 token → 入 store → 静默续期 → JITGET /api/v1/me)/ProtectedRoute(未登录回登录页)。首登 JIT 会自动建平台侧users行 + 个人租户 + 回写 Keycloak 属性;token_refresh_required=true时 Callback 会再刷一次 token 拿全 scopes。 - 多归属切换(SM1-2 交付,SM2-1 消费):顶栏
TenantSwitcher(单归属自动隐藏)——GET /api/v1/me/memberships:列出当前凭据的全部域归属(is_active标记活跃域);- 切换 =
POST /api/v1/me/active-tenant(403 反枚举:未归属 / 域不存在 / 域停用返回同一响应,前端不得区分提示)→ 响应恒token_refresh_required: true→ 客户端必须强制 refresh token(旧 token 剩余 TTL 内仍按旧域服务,窗口期最后写入者赢)→ 清空本地会话历史与查询缓存 → 回首页。 - 每域独立:users 行 / 用户空间 / 钱包(跨域 RLS 硬隔离,域内余额共享)。
§4 会话接线(平台 JWT 直连)
与 cookbook 同一组端点、另一条凭据通道(浏览器 JWT,仅本人;X-AgentHub-Api-Key 只属服务端):
- 目录:
GET /api/v1/app/agents?agent_type=conversation(五字段,响应键一律 snake_case:id/name/description/points_per_turn/idle_timeout_seconds——camelCase 是前端类型层的别名,不是 API 形状); - 开会话(幂等):
POST /api/v1/app/conversations带client_conversation_key; - 轮次流:
POST /api/v1/app/conversations/{id}/turns→ SSE(turn_started/turn_delta/turn_card/turn_file/turn_completed;模板用streamSse因为 EventSource 不支持 POST); - 断连重拉:
GET .../turns/{turn_id}(不重复扣点);历史分页GET .../messages;结束POST ...:end。 - 语音 / 附件 / 文生图按需启用(cookbook §7~§11 的端点同通道可用)。
§5 充值与扣点
- 扣点:每轮按发布单价 freeze/settle(402 引导充值);域内所有 SaaS 共享同一钱包余额(SM1-1 域内合一)。
- 充值(当前形态):平台充值链路已完成代码收口(SM3-1:微信 JSAPI/Native 全链 +
GET /api/v1/billing/recharge-plans服务端权威价目);投产开闸(真实商户凭据联调 + 真栈冒烟,ADR-0032 闸门)仍为运维外部前置。开闸前模板的积分页 = 余额展示 + 手动入账指引:运营执行tenant_grant(或平台侧admin_grant),入账即生效、流水按项目归因。注意tenant_grant请求体expires_at必填(长效积分到期时间,ISO-8601)——完整示例见 hello-agent 教程 §5.5.1。 - 对账:SaaS 侧若需自有订单 ↔ 平台积分对账,用
operation_id幂等键(ADR-0044 双信任模型):你侧生成并记录 UUID,入账/消费流水可凭它对齐。
§6 部署
把 deploy/compose-snippet.yml 的 service 块加进 compose 编排(每个薄壳一个块,一个端口);nginx 已配 SSE 必需的 proxy_buffering off 与 3600s 读超时,勿删。TLS / 域名由 gateway 层统一。
§7 「从零到扣点」15 步 checklist
| # | 步骤 | 完成判据 |
|---|---|---|
| 1 | Keycloak 建 SPA client(public + PKCE) | client 出现在 realm clients 列表 |
| 2 | 加 4 个 mapper(aud / tenant_id / user_id / permissions) | 试发 token 含四 claim |
| 3 | 配 Root/Redirect URI + Web Origins | /callback 可回跳 |
| 4 | 运营建租户(= 你的运营域) | 拿到 tenant id |
| 5 | 运营在 Builder 建项目(挂该租户) | 拿到 project id(给 VITE_PROJECT_ID) |
| 6 | 运营建会话 Agent(人设/模型/单价/护栏) | Builder 里可见 |
| 7 | 发布 Agent | 版本状态 published |
| 8 | node scripts/new-saas-app.mjs --name <x> --target <dir> 派生 | 产物目录可 pnpm install |
| 9 | 处理 workspace 依赖(内联或 registry)+ 填 env | pnpm dev 起服务 |
| 10 | VITE_USE_MOCKS=true 离线冒烟 | 模板 e2e 三场景绿 |
| 11 | 切真 env(Keycloak / API 地址) | 登录页可达 |
| 12 | 注册首个用户 → 登录 | 顶栏出现用户名;GET /api/v1/me 200 |
| 13 | 运营 tenant_grant 手动入账首批积分 | 运营侧 ledger 可见 manual_adjust 入账行(终端积分页 403 是已知 residual SM5-1,勿作判据) |
| 14 | 选 Agent 开会话、发一轮 | 收到流式回复;余额按单价减少 |
| 15 | compose 部署上线(§6) | 生产域名全链可走 |
全程没有一步需要写用户管理 / 充值 / 租户后台代码——需要时回看 §0 的边界表。