feat(web,docs): App 套壳 embed app 变体(relogin_url + fragment 注入)+ APP.md 对接文档(bump 0.58.53)
移动 App 方案:原生壳(WebView)+ 原生登录页,登录走 platform 自有接口 (/api/login/token → /api/login/external-login,实测打通,后者服务端换 zcbot JWT)。 顶层 WebView 无父窗口,iframe 的 postMessage 协议失效,泛化企微免登为 app 变体: ?embed=1&relogin_url=<绝对地址>#token=..&user_id=.. —— fragment 注入读完即清, 401/logout 时 location.replace(relogin_url),原生壳拦自定义 scheme 换新 token 重进。 - state.js: EMBED_RELOGIN_URL 解析+消毒(拦 javascript:/data: 等可执行 scheme) - embed.js: 抽共用 readFragmentToken/gotoInitialTask,加 embedAppInit/embedAppRelogin - auth.js: logout 分支序 wecom → app → iframe - APP.md 新增(进入契约 + platform 登录链路 + 原生壳杂活清单 + H5 备选) - EMBED.md 精简 243→约 120 行,指向 APP.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fb214de960
commit
c1508d0df0
|
|
@ -0,0 +1,74 @@
|
|||
# zcbot 移动 App 套壳对接
|
||||
|
||||
> 给 **platform 工程**:把 zcbot 控制台打包成移动 App。
|
||||
> 架构:**原生壳(WebView)+ 原生登录页**。登录调 platform 自家接口换出 zcbot JWT,WebView 加载 zcbot 控制台时把 token 放 URL fragment 带入;token 失效时 zcbot 跳回 `relogin_url`,原生壳拦截后静默换新 token 重进。zcbot 前端不打包进 App,前端迭代不发版。
|
||||
|
||||
---
|
||||
|
||||
## 1. 登录链路(platform 自有接口,2026-07-21 实测可用)
|
||||
|
||||
```
|
||||
GET /api/login/check_captcha_required/<username> → {require_captcha: bool}
|
||||
GET /api/login/captcha?key=<随机串> → 验证码图(仅 require_captcha 时)
|
||||
POST /api/login/token → platform access_token
|
||||
form: username, password, key(必填,即使无验证码), grant_type=password [, captcha_text]
|
||||
POST /api/login/external-login → zcbot JWT
|
||||
header: Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
`external-login` 返回的 `data` 字段:
|
||||
|
||||
```json
|
||||
{"token":"<zcbot JWT>", "user_id":"<uuid>", "name":"...", "user_name":"...",
|
||||
"expires_at":"...", "ttl_seconds":604800}
|
||||
```
|
||||
|
||||
其中 `token` / `user_id` 就是下一步进入 zcbot 所需的全部。
|
||||
|
||||
---
|
||||
|
||||
## 2. zcbot 进入契约
|
||||
|
||||
拿到 JWT 后,WebView 加载:
|
||||
|
||||
```
|
||||
https://<zcbot 域名>/static/dev.html?embed=1&relogin_url=<回登录入口的地址>#token=<JWT>&user_id=<UUID>
|
||||
```
|
||||
|
||||
- **fragment**:`token` / `user_id` 两个字段都必填;zcbot 读完立即清掉,不留在地址栏和历史。
|
||||
- **`relogin_url`**:必须是带 scheme 的绝对地址。原生壳推荐自定义 scheme(如 `zcbotapp://relogin`);`javascript:` 等可执行 scheme 会被拒。
|
||||
- **401 行为**:zcbot JWT 失效时页面 `location.replace(relogin_url)`。原生壳在 `shouldOverrideUrlLoading`(Android)/ `WKNavigationDelegate`(iOS)拦截该跳转 → 用缓存的 platform token 重调 `external-login` → 带新 fragment 重新 loadUrl。
|
||||
- **token 生命周期**:zcbot JWT 7 天 TTL + 滑动续签(活跃期间自动续,页面内已处理)。App 每次冷启动都换一张新 JWT,实际只有 platform token 过期(约 30 天)才需要用户重输密码。
|
||||
- 可选 `&task_id=<uuid>`:进入后自动定位到该 task。
|
||||
|
||||
**冷启动静默登录**:壳启动 → Keystore/Keychain 里有 platform token → 后台调 `external-login` → 直接 loadUrl 进控制台,用户无感;没有或已失效 → 显示原生登录页。
|
||||
|
||||
---
|
||||
|
||||
## 3. 原生壳杂活清单
|
||||
|
||||
| 项 | Android | iOS |
|
||||
|---|---|---|
|
||||
| 文件上传 | `WebChromeClient.onShowFileChooser` | 自带 |
|
||||
| 文件下载 | `DownloadListener` → `DownloadManager` | `WKDownloadDelegate`(iOS 14.5+) |
|
||||
| 返回键 | `canGoBack() ? goBack() : 退出确认` | 手势自带 |
|
||||
| DOM storage | `setDomStorageEnabled(true)`(必须) | 自带 |
|
||||
| 键盘顶起 | `windowSoftInputMode="adjustResize"` | 自带 |
|
||||
| 外链 | 非 platform/zcbot 域交给系统浏览器 | 同左 |
|
||||
|
||||
SSE 流式输出两端 WebView 原生支持,无需处理。iOS 上架提醒:纯套壳有 App Store 4.2(最小功能)被拒风险;企业分发 / TestFlight / 仅 Android 无此问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 备选:H5 登录页
|
||||
|
||||
不想写原生登录 UI 的话,可在 platform 域上放一个 `app.html`(登录表单 + 调自家接口,同源无 CORS),换到 JWT 后 `location.replace` 按 §2 契约跳进 zcbot,`relogin_url` 填 `app.html` 自己的地址(401 跳回来时若 platform token 还在缓存,静默重签再跳回,用户无感)。契约完全相同,只是登录层从原生换成网页。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全要点
|
||||
|
||||
1. **`PLATFORM_KEY` 只在 platform 后端**(`external-login` 内部用),App / 前端代码里绝不出现。
|
||||
2. **不存明文账密**。"记住登录"= 把 platform token 存 Keystore / Keychain,靠 token 生命周期免登,不缓存密码重放。
|
||||
3. **建议 platform 侧修**:`POST /api/login/token` 响应的 `user` 对象把 `hashed_password` 原样下发了,应在序列化时剔除。
|
||||
4. WebView 域名白名单:只放行 platform 与 zcbot 两个域 + 自定义 scheme,其余交系统浏览器。
|
||||
225
EMBED.md
225
EMBED.md
|
|
@ -1,10 +1,8 @@
|
|||
# zcbot 控制台 iframe 嵌入对接
|
||||
|
||||
> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 主页,需要做的事。
|
||||
> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 网页。移动 App 套壳**不用 iframe**,另见 `APP.md`。
|
||||
>
|
||||
> 假设:platform 和 zcbot **不同源**(eg. `https://portal.example.com` 嵌 `https://zcbot.example.com`)。同源直接共享 localStorage 不需要这套握手。
|
||||
|
||||
---
|
||||
> 假设两边不同源;同源直接共享 localStorage,不需要这套握手。
|
||||
|
||||
## 一句话总结
|
||||
|
||||
|
|
@ -12,9 +10,9 @@
|
|||
<iframe src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"></iframe>
|
||||
```
|
||||
|
||||
iframe 加载完会发 `postMessage({type:"zcbot-ready"})` 给 platform。platform 后端拿 `PLATFORM_KEY` 调 `POST /v1/auth/login` 换出 JWT,通过 `postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。
|
||||
iframe 加载完发 `postMessage({type:"zcbot-ready"})`;platform 后端拿 `PLATFORM_KEY` 调 `POST /v1/auth/login` 换 JWT,`postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。
|
||||
|
||||
embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 · "+ 新建任务"挪到任务面板。
|
||||
embed 模式下:brand / 退出登录 / 登录页隐藏,"+ 新建任务"挪到任务面板。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -22,222 +20,91 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示
|
|||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `embed` | 是 | 固定 `1`,触发 embed 模式 |
|
||||
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
|
||||
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
|
||||
|
||||
示例:
|
||||
```
|
||||
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com
|
||||
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com&task_id=01HXXXXX-...
|
||||
```
|
||||
|
||||
---
|
||||
| `embed` | 是 | 固定 `1` |
|
||||
| `parent_origin` | 是 | platform 页面 origin,postMessage 白名单;缺失 / 不匹配 → iframe 显错误、消息全丢 |
|
||||
| `task_id` | 否 | 首次签发 token 后自动定位到该 task(仅初次进入生效) |
|
||||
|
||||
## 2. postMessage 协议
|
||||
|
||||
所有消息 `data` 都是 plain object,`type` 字段标识种类。两端都必须校验 `event.origin`。
|
||||
两端都必须校验 `event.origin`。
|
||||
|
||||
### iframe → platform(子发父)
|
||||
iframe → platform:
|
||||
|
||||
| `type` | 时机 | 字段 | platform 应做 |
|
||||
|---|---|---|---|
|
||||
| `zcbot-ready` | iframe 加载完成 / 已有缓存 token 但仍发一次 | (无) | 检查当前用户 → 调后端换 JWT → 回推 `zcbot-token` |
|
||||
| `zcbot-401` | 用户操作触发 401(token 过期 / 被吊销) | (无) | 重新换 JWT(可能要求用户重新登录 platform)→ 回推 `zcbot-token`;或决定关闭 iframe |
|
||||
|
||||
### platform → iframe(父发子)
|
||||
|
||||
| `type` | 字段 | iframe 行为 |
|
||||
| `type` | 时机 | platform 应做 |
|
||||
|---|---|---|
|
||||
| `zcbot-token` | `token` (string, JWT) <br> `user_id` (string, UUID) <br> `user_name` (string, 可选) | 写 localStorage + 进入主界面;若已在主界面(401 重签场景)只重载任务列表 |
|
||||
| `zcbot-ready` | 加载完成 | 后端换 JWT → 回推 `zcbot-token` |
|
||||
| `zcbot-401` | token 过期 / 被吊销 | 重新换 JWT 回推,或关闭 iframe |
|
||||
|
||||
---
|
||||
platform → iframe:
|
||||
|
||||
## 3. platform 后端:换 JWT
|
||||
| `type` | 字段 |
|
||||
|---|---|
|
||||
| `zcbot-token` | `token`(JWT)、`user_id`(UUID)、`user_name`(可选) |
|
||||
|
||||
zcbot 已经有 SSO 入口,**platform 后端用共享的 `PLATFORM_KEY` 代任意用户换 JWT**:
|
||||
## 3. platform 后端换 JWT
|
||||
|
||||
```
|
||||
POST https://zcbot.example.com/v1/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"user_id": "<UUID,platform 决定>",
|
||||
"platform_key": "<跟 zcbot env PLATFORM_KEY 同串>"
|
||||
}
|
||||
{"user_id": "<UUID,platform 决定>", "platform_key": "<env PLATFORM_KEY>"}
|
||||
→ {"token": "...", "expires_at": "...", "user_id": "...", "role": "user", "ttl_seconds": 604800}
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"expires_at": "2026-07-25T10:00:00",
|
||||
"user_id": "...",
|
||||
"name": null,
|
||||
"user_name": null,
|
||||
"role": "user",
|
||||
"ttl_seconds": 604800
|
||||
}
|
||||
```
|
||||
|
||||
- `PLATFORM_KEY`:platform 和 zcbot **后端共享的密钥**,**绝不能下放到浏览器**。换 token 必须 platform 后端代理。
|
||||
- `user_id`:任意 UUID;首次出现 zcbot 会自动建 `users` 行(占位,无 email/密码),后续 task / message 都用这个 user_id 作为分区键。**platform 用户 ↔ zcbot user_id 映射由 platform 维护**(建议 1:1)。可选在 body 里带 `name` / `user_name`,zcbot 每次登录用 `COALESCE` upsert(平台侧改名自动同步),用于控制台顶栏显示。
|
||||
- `expires_at`:该 token 到期时刻(ISO 8601);`ttl_seconds`:TTL 秒数(默 7 天,zcbot 端 `ZCBOT_JWT_TTL_SECONDS` env 可改)。
|
||||
- **滑动续签(重要,可少管过期)**:token 临近过期时,**任一** 带 `Authorization: Bearer` 的请求(iframe 内的调用、或 platform 后端 server-to-server 调 zcbot API)响应里都会带回一张新 token:
|
||||
- `X-Refreshed-Token`:新 JWT
|
||||
- `X-Token-Expires-At`:新到期时刻(ISO 8601)
|
||||
|
||||
见到就替换本地/缓存的 token 即可——控制台 iframe 已自动处理;platform 后端若长期缓存 token 复用,建议也读这两个响应头刷新缓存。**只要在持续调用,token 不会到期**,`zcbot-401` 只在静默超过整个 TTL 后才发生。触发阈值默认 TTL 的 1/4(zcbot 端 `ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS` 可改,设 0 关闭)。
|
||||
|
||||
### Node.js 后端示例
|
||||
|
||||
```js
|
||||
// POST /api/zcbot-token (platform 自己的 API,由前端登录态保护)
|
||||
app.post("/api/zcbot-token", async (req, res) => {
|
||||
const userId = req.session.zcbotUserId; // platform user → zcbot user_id 映射
|
||||
const r = await fetch("https://zcbot.example.com/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
platform_key: process.env.ZCBOT_PLATFORM_KEY,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) return res.status(502).json({ error: "zcbot login failed" });
|
||||
const { token } = await r.json();
|
||||
res.json({ token, user_id: userId, user_name: req.session.userName });
|
||||
});
|
||||
```
|
||||
|
||||
### Python (FastAPI) 后端示例
|
||||
- `PLATFORM_KEY` 是后端间共享密钥,**绝不能出现在浏览器**;换 token 必须 platform 后端代理。
|
||||
- `user_id` 任意 UUID,首次出现自动建 users 行;platform 用户 ↔ zcbot user_id 映射由 platform 维护(建议 1:1)。可选带 `name` / `user_name`,每次登录 upsert(平台侧改名自动同步)。
|
||||
- **滑动续签**:token 临期时,任一带 Bearer 的响应会带 `X-Refreshed-Token` / `X-Token-Expires-At` 头,见到就替换本地缓存(iframe 内已自动处理)。持续使用不掉线,`zcbot-401` 只在静默超过整个 TTL(默认 7 天)后发生。
|
||||
|
||||
```python
|
||||
import os, httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
@app.post("/api/zcbot-token")
|
||||
@app.post("/api/zcbot-token") # platform 自己的 API,由前端登录态保护
|
||||
async def issue_zcbot_token(user_id: str = Depends(current_user_id)):
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
r = await c.post(
|
||||
"https://zcbot.example.com/v1/auth/login",
|
||||
json={"user_id": user_id, "platform_key": os.environ["ZCBOT_PLATFORM_KEY"]},
|
||||
)
|
||||
r = await c.post("https://zcbot.example.com/v1/auth/login",
|
||||
json={"user_id": user_id, "platform_key": os.environ["ZCBOT_PLATFORM_KEY"]})
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(502, "zcbot login failed")
|
||||
data = r.json()
|
||||
return {"token": data["token"], "user_id": user_id}
|
||||
return {"token": r.json()["token"], "user_id": user_id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. platform 前端:iframe + postMessage
|
||||
## 4. platform 前端
|
||||
|
||||
```html
|
||||
<iframe
|
||||
id="zcbot-frame"
|
||||
<iframe id="zcbot-frame" style="width:100%;height:100vh;border:0"
|
||||
src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"
|
||||
style="width:100%; height:100vh; border:0;"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
></iframe>
|
||||
|
||||
allow="clipboard-read; clipboard-write"></iframe>
|
||||
<script>
|
||||
const ZCBOT_ORIGIN = "https://zcbot.example.com";
|
||||
const frame = document.getElementById("zcbot-frame");
|
||||
|
||||
async function pushToken() {
|
||||
// 调 platform 后端拿 zcbot JWT(后端用 PLATFORM_KEY 换)
|
||||
const r = await fetch("/api/zcbot-token", { method: "POST", credentials: "include" });
|
||||
if (!r.ok) {
|
||||
console.error("zcbot token fetch failed");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
const { token, user_id, user_name } = await r.json();
|
||||
frame.contentWindow.postMessage(
|
||||
{ type: "zcbot-token", token, user_id, user_name },
|
||||
ZCBOT_ORIGIN
|
||||
);
|
||||
frame.contentWindow.postMessage({ type: "zcbot-token", token, user_id, user_name }, ZCBOT_ORIGIN);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
if (e.origin !== ZCBOT_ORIGIN) return;
|
||||
const t = e.data && e.data.type;
|
||||
if (t === "zcbot-ready" || t === "zcbot-401") {
|
||||
pushToken();
|
||||
}
|
||||
if (t === "zcbot-ready" || t === "zcbot-401") pushToken();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有多个全屏 `position:fixed` modal,iframe 太小会挤瘪)。
|
||||
|
||||
---
|
||||
iframe 高度必须足够(推荐 `100vh`,zcbot 内部有全屏 fixed modal,太矮会挤瘪)。
|
||||
|
||||
## 5. 安全要点
|
||||
|
||||
1. **`parent_origin` URL 参数必填** —— 缺失 / 非法 iframe 不工作。这是 postMessage 的 origin 白名单。
|
||||
2. **`PLATFORM_KEY` 只在 platform 后端** —— 等同 JWT 签名权限,泄漏 = 任意伪造任意用户。前端代码 / Git 仓库 / 客户端日志都不能出现。
|
||||
3. **iframe 收 message 强制校验 `event.origin === parent_origin`** —— 已在 dev.html 实现,父端也应对称校验 `event.origin === ZCBOT_ORIGIN`,防恶意页面伪造消息。
|
||||
4. **CSP `frame-ancestors`** —— 真发布时 zcbot 这边建议加响应头限制只允许 platform 域嵌入,目前 zcbot 未默认设置(本地宽松,见 §6)。
|
||||
5. **HTTPS 必须** —— `parent_origin` 是 `https://`,生产部署不要走 http(postMessage 在 http 不发警告,但中间人可改 iframe src 注入恶意 token)。
|
||||
6. **TTL + 滑动续签**:JWT 默认 7 天(`ZCBOT_JWT_TTL_SECONDS` 可改)。活跃会话由滑动续签(见 §3)自动无感续期,`zcbot-401` 只在静默超过整个 TTL 后才发生;若 platform 希望强制更频繁地重签,把 TTL 调短即可。
|
||||
1. 生产必须 HTTPS;`parent_origin` 与实际 origin 严格一致。
|
||||
2. 两端 message 处理都校验 `event.origin`,防伪造。
|
||||
3. zcbot 部署侧:CORS `allow_origins` 收紧到 platform 域(`web/app.py`,目前 `["*"]`);建议加 CSP `frame-ancestors https://portal.example.com` 响应头。
|
||||
4. env `PLATFORM_KEY` / `JWT_SECRET` 必填(启动 fail-fast 校验)。
|
||||
|
||||
---
|
||||
## 6. 调试与故障
|
||||
|
||||
## 6. zcbot 部署侧需要做的事
|
||||
本地:zcbot 跑 `8765`,开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=<iframe 自己的 origin>`,在 DevTools 里 `window.postMessage({type:"zcbot-token", token:"<curl 换的 JWT>", user_id:"<UUID>"}, location.origin)` 自发自收。
|
||||
|
||||
> 这一节是 **zcbot 运维 / 我自己** 要做的,platform 工程不需要操心,列出来供参考。
|
||||
| 现象 | 处置 |
|
||||
|---|---|
|
||||
| 永远"等待登录…" | platform 没收 / 没回 `zcbot-ready`;查 `event.origin` 是否匹配 |
|
||||
| 收到 token 仍不进主界面 | origin 不匹配或 `token`/`user_id` 字段缺失(静默丢弃),看 console |
|
||||
| 一动作就跳回等待页 | JWT 过期 / `JWT_SECRET` 变过,重新换 token 回推 |
|
||||
| modal 被挤瘪 | iframe 高度不够,改 `100vh` |
|
||||
|
||||
1. **CORS `allow_origins`** 收紧到 platform 域名(目前 `["*"]`,见 `web/app.py:516`)
|
||||
```python
|
||||
allow_origins=["https://portal.example.com"],
|
||||
allow_credentials=False,
|
||||
```
|
||||
2. **CSP `frame-ancestors`** 加响应头中间件,只放行 platform 域:
|
||||
```python
|
||||
@app.middleware("http")
|
||||
async def csp_headers(req, call_next):
|
||||
resp = await call_next(req)
|
||||
resp.headers["Content-Security-Policy"] = (
|
||||
"frame-ancestors https://portal.example.com"
|
||||
)
|
||||
return resp
|
||||
```
|
||||
(FastAPI 默认不发 `X-Frame-Options`,iframe 加载本身不会被浏览器拦;`frame-ancestors` 是 CSP 替代品,更细粒度。)
|
||||
3. **env**:`PLATFORM_KEY` / `JWT_SECRET` 必填(已是启动 fail-fast 校验,见 `web/auth.py:67`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 调试
|
||||
|
||||
**本地试 iframe(同机两端口)**:
|
||||
- zcbot 跑 `8765` → 浏览器开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=http://127.0.0.1:3000`
|
||||
- 单独不带 platform 时 iframe 会显示"等待登录…",DevTools console 看 `postMessage` 收发。
|
||||
- 手动模拟 platform 推 token(在 iframe DevTools 里):
|
||||
```js
|
||||
window.postMessage({type:"zcbot-token", token:"<手工 curl 换的 JWT>", user_id:"<UUID>"}, location.origin)
|
||||
```
|
||||
注意 origin 必须 match `parent_origin` 参数,本机调试时把 `parent_origin` 设成 iframe 自己的 origin 即可(自发自收)。
|
||||
|
||||
**curl 试后端换 token**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8765/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id":"00000000-0000-0000-0000-000000000001","platform_key":"<env PLATFORM_KEY>"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 故障兜底
|
||||
|
||||
| 现象 | 原因 | 处置 |
|
||||
|---|---|---|
|
||||
| iframe 显示"embed 模式缺少 parent_origin 参数" | URL 里没传 `parent_origin` | 加上 `&parent_origin=https://...` |
|
||||
| iframe 永远显"等待登录…" | platform 没收 / 没回 `zcbot-ready` | DevTools console 看 message;校验 `event.origin` 是否匹配 |
|
||||
| iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin` 或 `token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 |
|
||||
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
|
||||
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height` 改 `100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |
|
||||
| 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message |
|
||||
|
||||
---
|
||||
|
||||
> **企业微信免登**(`?embed=1&wecom=1`)是 embed 模式的另一条 token 来路,面向 zcbot 运维 / 企微管理员,**platform iframe 对接不涉及**,配置见 `RUN.md` 企业微信段「应用主页免登」。
|
||||
> 企业微信免登(`?embed=1&wecom=1`)是另一条 token 来路,与 platform iframe 对接无关,配置见 `RUN.md`。
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-07
|
||||
|
||||
- **07-21 / 0.58.53 / App 套壳进入契约(embed app 变体 relogin_url)+ 对接文档**:移动 App 方案定型——原生壳(WebView)+ 原生登录页,登录走 platform 自有接口(`/api/login/token` → `/api/login/external-login`,已实测打通,后者服务端用 PLATFORM_KEY 换 zcbot JWT,user_id=platform user.uuid)。顶层 WebView 无父窗口,iframe 的 postMessage 协议失效(`zcbot-401` 经 `window.parent` 发不出去),故把企微免登模式泛化为 **app 变体**:`?embed=1&relogin_url=<绝对地址>#token=..&user_id=..` —— fragment 注入(读完即清,同 wecom)+ 401/logout 时 `location.replace(relogin_url)`(原生壳拦自定义 scheme 如 `zcbotapp://relogin` → 静默换新 token 重进;H5 登录页同契约)。改动:`state.js` 加 `EMBED_RELOGIN_URL` 解析+消毒(必须带 scheme 的绝对地址,拦 `javascript:`/`data:` 等可执行 scheme——该值喂 `location.replace`,不消毒是 XSS 口子);`embed.js` 抽共用 `readFragmentToken`/`gotoInitialTask`,加 `embedAppInit`/`embedAppRelogin`;`auth.js` logout 分支序 wecom→app→iframe。新增 `APP.md`(进入契约 + platform 登录链路实测 + 原生壳杂活清单 + H5 备选),`EMBED.md` 精简 243→约 120 行。ESM 语法检查过,浏览器级实测待跑(逻辑与线上企微免登同构)。顺带:实测发现 platform `/api/login/token` 响应把 `hashed_password` 下发到客户端,修复建议已写进 APP.md §5 转交对方。
|
||||
- **07-21 / 0.58.52 / 窗口体量估算实测校准(50%压缩/85%折叠/前端占用环共用)**:diag 复盘窗口约束现状(`scripts/diag_context_pressure.py` 留仓):机制有效(折叠上线后唯一越线 task 9a863424 下个 run 起点即回落 400k→137k)、零撞硬上限(6 条终态错误无一 context 超限),但量出静态 `CHARS_PER_TOKEN=2.5` 对中文密集窗口**低估近一倍**——名义 85% 折叠线实际 ~155% reliable 才触发(该 task 实测 40.7 万 tokens);代码密集反向虚高(fe2d8b73 估 1.2M 实际 616k)。**修(信号校准,不加新机制)**:`context.py` 加 `estimate_window_tokens`(provider 实报 tokens_in/out 覆盖窗口主体,仅实测点后尾巴按 2.5 估)+ `calibrated_chars_per_token`(比值夹 [1.0,4.0] 带宽);`Session.last_measured_usage()` 从 messages 表取窗口内最后一条实报 usage(best-effort 绝不抛,idx→内存 pos 映射校验 role);`maybe_fold` 触发判定换 token 实测口径;loop 的压缩门槛与 `context_limit_chars`(前端环)用校准比值,每轮成功调用后以 (sent_chars/prompt_tokens) 刷新。已知残余:折叠后 run 若在首次 chat 完成前崩,下个 run 起点读到旧实测会多折一次(后果=摘要偏保守,原文全在 DB,不为此加持久化状态)。run 中途折叠/超限自愈按 §5 无信号不实施继续搁置。真实生产 task 验证映射与校准(9a863424 drift 0.95x、fe2d8b73 0.51x、74696048 0.86x),286 测试全绿。
|
||||
- **07-21 / 0.58.50 / salvage 成功提示降噪(黄→灰)**:用户反馈"工具调用参数损坏但已就地抢救"黄字吓人、问是否影响后续对话——核实**零影响**(salvage 只就地改写 arguments,当轮照常执行,对话历史与正常轮无异;warn 是纯前端 SSE 展示事件,不入 messages 不回灌 LLM,刷新即消失)。处置:不全静默(上游返脏数据的事实要可见、可与 `tool_salvaged` DB 留痕对上),改**分级降噪**——`loop.py` 该 emit 加 `level:"info"` + 措辞软化「已自动修复工具调用参数…继续执行」;`chat.js` warn 渲染按 level 分流,info 走灰色 muted 无 ⚠,黄色 ⚠ 只留给真打断本轮的路径(丢弃重试/熔断等)。
|
||||
- **07-21 / 0.58.51 / 系统提示护栏:大段中文正文别内联进 .py(run_python 语法预检失败治本)**:失败面板 `run_python/error` 语法预检簇(近7天31次/12task)根行为=模型手写 python-docx/pptx 把大段中文正文硬拼进 .py 源码,ASCII 引号/全角标点/缩进崩成 SyntaxError。定位:①现有正规路径充分(所有出 docx 的 skill 都指向 `rendering/render.py` md→docx、且写"别自己手搓"),但 DB 显示 16 个涉事 task 中 **7 个没 load 任何 skill**→够不着 SKILL 指引;②precheck(pysyntax)已 host 侧拦截+回根治 tip+喂 RepeatGuard,浪费已 bound(每任务~2.6次自愈、无失控循环)。故走**最高覆盖、最低改动**:base 系统提示 `prompts/system/general_v1.md`(所有任务加载)的 run_python 段加一句**原则级**护栏——生成/改 docx·pptx 时大段中文正文别内联进 .py、先 write 进 .md/.txt 再 read、有 skill 优先走其渲染路径(守 recipe-ban 不给可 copy 配方)。纯提示词引导、无法端到端 verify,ROI 本就低(precheck 已兜),只求少发。**遗留 Option 4**:改现有 .docx 无平台路径(render.py 只从 md 新生成),模型只能手写 python-docx→内联中文,需另设计(docx→md 拆/模板填充)。`prompts/` 每次 build 实时读、`git pull` 即生效免重启。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.58.52"
|
||||
__version__ = "0.58.53"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
|
||||
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
|
||||
// (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。
|
||||
import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, EMBED_WECOM, setIdentity } from "./state.js";
|
||||
import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, EMBED_WECOM, EMBED_RELOGIN_URL, setIdentity } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { api } from "./api.js";
|
||||
import { enterApp } from "./main.js";
|
||||
import { embedPostToParent, embedShowWaiting, embedWecomRelogin } from "./embed.js";
|
||||
import { embedPostToParent, embedShowWaiting, embedWecomRelogin, embedAppRelogin } from "./embed.js";
|
||||
import { message } from "./dialog.js";
|
||||
|
||||
// ───── login ─────
|
||||
|
|
@ -106,6 +106,11 @@ export function logout() {
|
|||
embedWecomRelogin();
|
||||
return;
|
||||
}
|
||||
if (EMBED_RELOGIN_URL) {
|
||||
// App 套壳:401 跳回登录入口(原生壳拦自定义 scheme / H5 登录页静默重签)
|
||||
embedAppRelogin();
|
||||
return;
|
||||
}
|
||||
if (EMBED) {
|
||||
embedPostToParent({ type: "zcbot-401" });
|
||||
embedShowWaiting("登录已失效,等待父页面重新签发…", false);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
|
||||
// wecom 变体(?embed=1&wecom=1):token 来自 /v1/wecom/entry 回调的 URL fragment,
|
||||
// 401 时 embedWecomRelogin 重定向回 entry 静默重签(企微客户端内无感)。
|
||||
// app 变体(?embed=1&relogin_url=...):token 同样走 fragment,401 时跳 relogin_url
|
||||
// (原生壳拦自定义 scheme 换新 token 重进,或 H5 登录页静默重签)。
|
||||
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
|
||||
// embedPostToParent / embedShowWaiting / embedWecomRelogin(auth 的 logout 用)。
|
||||
import { state, LS_TOKEN, EMBED_WECOM, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
|
||||
// embedPostToParent / embedShowWaiting / embedWecomRelogin / embedAppRelogin(auth 的 logout 用)。
|
||||
import { state, LS_TOKEN, EMBED_WECOM, EMBED_PARENT_ORIGIN, EMBED_RELOGIN_URL, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { enterApp } from "./main.js";
|
||||
import { loadTaskList, selectTask } from "./chat.js";
|
||||
|
|
@ -44,13 +46,29 @@ function embedHandleMessage(e) {
|
|||
} else {
|
||||
enterApp();
|
||||
// 首次签发:若 URL 带 task_id,定位到该 task(loadMessages 由 selectTask 触发)
|
||||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||||
_embedInitialTaskHandled = true;
|
||||
selectTask(EMBED_INITIAL_TASK_ID);
|
||||
}
|
||||
gotoInitialTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
// ───── fragment 注入(wecom / app 变体共用)─────
|
||||
// fragment 里的 token 优先于 localStorage 缓存(每次从登录入口进来都是新签的);
|
||||
// 读完立即清 hash,token 不留在地址栏 / 浏览历史。name/email 由 enterApp → loadRole 拉 /v1/me 补齐。
|
||||
function readFragmentToken() {
|
||||
const h = new URLSearchParams(location.hash.replace(/^#/, ""));
|
||||
const tok = h.get("token"), uid = h.get("user_id");
|
||||
if (!tok || !uid) return;
|
||||
state.token = tok;
|
||||
localStorage.setItem(LS_TOKEN, tok);
|
||||
setIdentity({ user_id: uid });
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
function gotoInitialTask() {
|
||||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||||
_embedInitialTaskHandled = true;
|
||||
selectTask(EMBED_INITIAL_TASK_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── wecom 免登变体 ─────
|
||||
export function embedWecomRelogin() {
|
||||
// 回 entry 重走 OAuth(snsapi_base 静默,企微客户端内用户无感);replace 不留历史,
|
||||
|
|
@ -59,40 +77,39 @@ export function embedWecomRelogin() {
|
|||
}
|
||||
function embedWecomInit() {
|
||||
document.body.classList.add("embed-mode");
|
||||
// fragment 里的 token 优先于 localStorage 缓存(每次从 entry 进来都是新签的)
|
||||
const h = new URLSearchParams(location.hash.replace(/^#/, ""));
|
||||
const tok = h.get("token"), uid = h.get("user_id");
|
||||
if (tok && uid) {
|
||||
state.token = tok;
|
||||
localStorage.setItem(LS_TOKEN, tok);
|
||||
// name/email 由 enterApp → loadRole 拉 /v1/me 补齐
|
||||
setIdentity({ user_id: uid });
|
||||
// 读完立即清 hash,token 不留在地址栏 / 浏览历史
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
readFragmentToken();
|
||||
if (!state.token) { embedWecomRelogin(); return; }
|
||||
enterApp();
|
||||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||||
_embedInitialTaskHandled = true;
|
||||
selectTask(EMBED_INITIAL_TASK_ID);
|
||||
}
|
||||
gotoInitialTask();
|
||||
}
|
||||
|
||||
// ───── app 套壳变体 ─────
|
||||
export function embedAppRelogin() {
|
||||
// 回登录入口重签:原生壳在 shouldOverrideUrlLoading / WKNavigationDelegate 拦自定义
|
||||
// scheme(如 zcbotapp://relogin)→ 换新 token 重新 loadUrl;H5 登录页则静默重签后跳回
|
||||
location.replace(EMBED_RELOGIN_URL);
|
||||
}
|
||||
function embedAppInit() {
|
||||
document.body.classList.add("embed-mode");
|
||||
readFragmentToken();
|
||||
if (!state.token) { embedAppRelogin(); return; }
|
||||
enterApp();
|
||||
gotoInitialTask();
|
||||
}
|
||||
|
||||
export function embedInit() {
|
||||
if (EMBED_WECOM) { embedWecomInit(); return; }
|
||||
if (EMBED_RELOGIN_URL) { embedAppInit(); return; }
|
||||
if (!EMBED_PARENT_ORIGIN) {
|
||||
document.body.classList.add("embed-mode", "embed-waiting");
|
||||
embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true);
|
||||
embedShowWaiting("embed 模式缺少 parent_origin 或 relogin_url 参数 (iframe 嵌入用 ?embed=1&parent_origin=https://your-portal.com,App 套壳用 ?embed=1&relogin_url=<登录入口地址>)", true);
|
||||
return;
|
||||
}
|
||||
document.body.classList.add("embed-mode");
|
||||
window.addEventListener("message", embedHandleMessage);
|
||||
if (state.token) {
|
||||
enterApp();
|
||||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||||
_embedInitialTaskHandled = true;
|
||||
selectTask(EMBED_INITIAL_TASK_ID);
|
||||
}
|
||||
gotoInitialTask();
|
||||
} else {
|
||||
document.body.classList.add("embed-waiting");
|
||||
embedShowWaiting("等待登录…", false);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左
|
|||
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
|
||||
// ?embed=1&wecom=1 → 企业微信免登模式;token 由 /v1/wecom/entry 回调经 URL fragment 带入,
|
||||
// 401 时重定向回 entry 静默重签(不走 postMessage,不需要 parent_origin)
|
||||
// ?embed=1&relogin_url=<绝对地址> → App 套壳模式;token 同样经 URL fragment 带入,
|
||||
// 401 时 location.replace(relogin_url) 回登录入口重签(原生壳拦自定义 scheme,或 H5 登录页)
|
||||
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
|
||||
const _embedQS = new URLSearchParams(location.search);
|
||||
export const EMBED = _embedQS.get("embed") === "1";
|
||||
|
|
@ -22,6 +24,17 @@ export const EMBED_WECOM = EMBED && _embedQS.get("wecom") === "1";
|
|||
export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
|
||||
export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
|
||||
|
||||
// relogin_url 消毒:必须带 scheme 的绝对地址(https:// 或 App 自定义 scheme 如 zcbotapp://),
|
||||
// 拦掉 javascript:/data: 等可执行 scheme —— 这个值会喂给 location.replace,不消毒就是 XSS 口子
|
||||
function _sanitizeReloginUrl(raw) {
|
||||
const u = (raw || "").trim();
|
||||
const m = u.match(/^([a-z][a-z0-9+.-]*):/i);
|
||||
if (!m) return "";
|
||||
if (["javascript", "data", "vbscript", "blob", "file"].includes(m[1].toLowerCase())) return "";
|
||||
return u;
|
||||
}
|
||||
export const EMBED_RELOGIN_URL = EMBED ? _sanitizeReloginUrl(_embedQS.get("relogin_url")) : "";
|
||||
|
||||
export const state = {
|
||||
token: localStorage.getItem(LS_TOKEN) || "",
|
||||
userId: localStorage.getItem(LS_UID) || "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue