feat(auth): JWT 滑动续签——活跃用户临期无感换 token,不再因 7d TTL 掉线(bump 0.58.39)

原状:无状态 HS256 JWT(默 7d TTL)无任何续签路径,过期即 401——platform 靠
PLATFORM_KEY 自行重换,SPA 用户只能重输密码。判断:平台层握长期 PLATFORM_KEY、
login 本就幂等换发,缺的不是 refresh token 而是"无感重换"这层;放鉴权中间件一次
改动同时惠及 platform 层与 SPA,且纯加法契约。

- web/auth.py:AuthConfig 加 refresh_threshold_seconds(env
  ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS,默 TTL 的 1/4,设 0 关闭);verify_token
  拆出 decode_token+_uid_from_payload;require_user/admin 共用 _authenticate,
  注入 Response,临期就地 mint 新 token 放响应头 X-Refreshed-Token /
  X-Token-Expires-At(仅续未过期的,已过期先抛 401)
- web/app.py:CORS 加 expose_headers 放行两头,否则浏览器 fetch 读不到
- web/static/js/api.js:中央 api() 封装 absorbRefreshedToken——任一响应带新
  token 就无感换 state.token+localStorage
- 验证:4 case 全绿(fresh 不续/临期续且 exp 正确延长/已过期仍 401/阈值 0 关闭)

顺带:EMBED.md 收敛为纯 platform iframe 对接文档——删企业微信免登 §9(实质併入
RUN.md 企微段「应用主页免登」的内部链路)、校准 §3 换 token 返回体过期字段、补
滑动续签说明;新 env 进 RUN.md、用户可感知条目进 CHANGELOG。

additive,不改字段语义/URL/DB schema;WS ASR 路径 verify_token 签名不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-18 10:21:35 +08:00
parent 011aee1334
commit f7e5a80ed9
8 changed files with 127 additions and 46 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.58.39 — 2026-07-18
- 登录更省心:只要在持续使用,登录状态会在临近过期时自动续期,不用再每隔几天重新登录一次。长时间没打开才需要重新登录。
## 0.58.38 — 2026-07-17
- 修定时任务/企业微信推送的简报里「产物文件」链接点开报 404 的问题:现在无论是新推送的消息,还是历史已推送的简报,点文件链接都能正常弹出预览(txt/docx 等直接在线看)。

View File

@ -25,7 +25,6 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示
| `embed` | 是 | 固定 `1`,触发 embed 模式 |
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
| `wecom` | 否 | 固定 `1`,企业微信免登变体(见 §9):token 来自 `/v1/wecom/entry` 回调的 URL fragment,**不走 postMessage、不需要 `parent_origin`**。platform iframe 对接用不到这个参数 |
示例:
```
@ -72,14 +71,23 @@ Content-Type: application/json
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-07-25T10:00:00",
"user_id": "...",
"exp": 1740000000
"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)。
- JWT 默认 7 天 TTL(可改 `ZCBOT_JWT_TTL_SECONDS` env)。token 过期前 iframe 收到 401 → 发 `zcbot-401` 给父端 → 父端再走这个换 token 流程。
- `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 后端示例
@ -171,7 +179,7 @@ iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有
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 天。若 platform 希望更短(强制频繁刷新),改 zcbot env `ZCBOT_JWT_TTL_SECONDS` 即可。
6. **TTL + 滑动续签**:JWT 默认 7 天(`ZCBOT_JWT_TTL_SECONDS` 可改)。活跃会话由滑动续签(见 §3)自动无感续期,`zcbot-401` 只在静默超过整个 TTL 后才发生;若 platform 希望强制更频繁地重签,把 TTL 调短即可。
---
@ -232,28 +240,4 @@ curl -X POST http://127.0.0.1:8765/v1/auth/login \
---
## 9. 企业微信免登变体(`?embed=1&wecom=1`)
> 这一节是 embed 模式的**另一条 token 来路**,面向 zcbot 运维 / 企微管理员;platform iframe 对接(§1§4)不涉及。
用途:把 zcbot 配成**企业微信自建应用的「应用主页」**,成员在企微客户端里点开应用即自动登录,无需邮箱密码。
流程:
```
企微客户端点开应用
→ GET /v1/wecom/entry (302 到企微网页授权,snsapi_base 静默无感)
→ GET /v1/wecom/entry/callback (code→userid→反查绑定→签 JWT)
→ 302 /static/dev.html?embed=1&wecom=1#token=...&user_id=...
```
与 iframe embed 的差异:
| | iframe embed | wecom 免登 |
|---|---|---|
| token 来路 | 父页面 postMessage | URL fragment(前端读完立即 `history.replaceState` 清掉) |
| `parent_origin` | 必填 | 不需要 |
| 401 处理 | 发 `zcbot-401` 等父页面重签 | `location.replace("/v1/wecom/entry")` 静默重签 |
| 身份映射 | platform 维护 user_id | `channel_bindings` 里的企微绑定(**未绑定不自动建号**,提示先在控制台绑定) |
企微后台配置 + 可信域名要求见 `RUN.md` 企业微信段「应用主页免登」。外部浏览器(非企微客户端)打开 `/v1/wecom/entry` 会自动退到 wwlogin 扫码,同一 URL 两端可用。
> **企业微信免登**(`?embed=1&wecom=1`)是 embed 模式的另一条 token 来路,面向 zcbot 运维 / 企微管理员,**platform iframe 对接不涉及**,配置见 `RUN.md` 企业微信段「应用主页免登」。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-15(空响应防御:provider 吐空自动重试 + warn 自停不静默 done + 面板留痕,bump 0.58.32)
最后更新:2026-07-18(JWT 滑动续签:活跃用户临期无感换 token,不再因 7d TTL 掉线,bump 0.58.39)
---
@ -23,6 +23,7 @@
### 2026-07
- **07-18 / 0.58.39**:**JWT 滑动续签(sliding refresh)——活跃用户永不因 7d TTL 到期掉线**。原状:token 是无状态 HS256 JWT(默 7d TTL),**无任何续签路径**——过期即 `401 token expired`,platform 服务端得靠 `PLATFORM_KEY` 自行重换、dev SPA 用户只能重输密码登录(`verify_token` 是唯一验签口,过期硬抛 401)。判断:平台层握长期 `PLATFORM_KEY`、`login` 本就是幂等换发,**缺的不是 refresh token 而是"无感重换"这层**;放中间件一次改动同时惠及 platform 层与 SPA、且是纯加法契约,故选此方案(vs 显式 `/v1/auth/refresh` 端点需调用方主动轮询;vs 平台侧自兜对 SPA 无解)。**实现**:`web/auth.py` — `AuthConfig``refresh_threshold_seconds`(env `ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS`,默 TTL 的 1/4,设 0 关闭);`verify_token` 拆出 `decode_token`(返 payload,拿得到 exp)+ `_uid_from_payload`;`require_user`/`require_admin` 共用新 `_authenticate`,注入 FastAPI `Response`,验签成功且 token 剩余寿命 `< 阈值` 时就地 `mint_token` 一张新 token 放响应头 `X-Refreshed-Token` + `X-Token-Expires-At`(仅续"临期但未过期"的,已过期走不到——`decode_token` 先抛 401)。`web/app.py` CORS 加 `expose_headers` 放行两头(否则浏览器 fetch 读不到自定义响应头);`web/static/js/api.js` 中央 `api()` 封装加 `absorbRefreshedToken` —— 任一响应带新 token 就无感换 `state.token`+localStorage。**验证**(实跑非仅单测):4 case 全绿——fresh(寿命>阈值)不续 / 临期续且新 token exp 正确延长 / 已过期仍 401 / 阈值 0 关闭续签;`ast.parse` 双文件 + `node` 侧逻辑。additive,不改既有字段语义/URL/DB schema:平台层与 SPA 只需认新响应头(不认也不影响,退回原 7d 重登行为);WS ASR 路径的 `verify_token` 签名不变。相关:`web/auth.py` §7 D' 过渡 auth。
- **07-17 / 0.58.38**:**修 task 7f904e5f 定时简报的产物文件链接点开 404**(与 0.58.37 的 chip 404 不同根因,勿混)。定时任务/企业微信推送写进 DB 的 assistant 消息里,产物文件写成**完整下载 API 链接** `产物文件:[名](/v1/files/download?path=<rel>)`(`core/wechat/service.py:_build_push_message`),web SPA 的正文链接拦截器(`chat.js` 的 `.msg .body a[href]` click 拦截)注释设定是"模型把 user_root 相对路径写成 `[rel](rel)`",于是把整个 `/v1/files/download?path=...` URL 当相对 rel,`replace(/^\.?\/+/,"")` 去前导 `/``rel="v1/files/download?path=scheduled-…txt"`,再塞进 `openFilePreview` 二次包进 download URL → 请求 `path=v1/files/download?path=…` 不存在 → 404(node 复刻拦截器坐实)。**关键调研(纠了初判)**:一度以为链接得留完整 URL 给企业微信端点下载、改推送格式会 break wecom——**错**:查 `push_wecom`(service.py:266) 企业微信端走 `send_text`(摘要)+`send_file`(**文件附件独立通道**),压根不碰这个 markdown 链接;`_build_push_message` 的链接**只进 DB 给 web 端渲染**。故焊死 `/v1/files/download?path=` 纯属把"具体 API 端点"这个渲染细节泄漏进数据层,还正好踩拦截器坑。**修(A+B 互补,用户提的分层方向)**:**A** 前端拦截器在相对路径回退前先判 `^\/?v1\/files\//`,命中则 `new URL(raw,location.origin).searchParams.get("path")` 抽真正 rel 交 `openFilePreview`(自动解码,txt/docx 复用同 modal)——救 DB 里**已存 20+ 条历史简报**(改不了)+ 健壮兜底;**B** 推送端源头改写 **user_root 相对路径** `[名]({rel})`,与 chip/正文相对链接统一契约、走拦截器已有的相对路径分支,新消息不再泄漏 API 细节。**验证**:node 复刻拦截器——B 新格式(相对 `scheduled-…txt`)与 A 历史(完整 URL)都解析成同一干净 rel;A 另测 5 组(URL 编码含空格#号 path/普通相对/`./` 前缀/外链)全对、外链仍开新标签;`node --check chat.js`+`ast.parse service.py` 通过。web 前端改动需刷新页面生效。additive,不碰对外 API/DB/企业微信推送(send_file)契约,仅 DB 内 assistant 文本的链接写法由绝对 API URL 收敛为相对 rel。相关:0.58.37(chip 粗体 404,另一路径)。
- **07-17 / 0.58.37**:**修 task d9c74a5a 复盘暴露的三处独立缺陷(chip 404 / docx emoji 缺失 / sections 文件夹被迫创建)**。一次"今日新闻 md→word"跑出三个不相干的坑,逐个定层修:**① 前端文件 chip 点开弹框 404**——`web/static/js/media.js::extractArtifactRels` 的路径 tail 字符集 `_TAIL_CLS` 没排除 `*`,助手把路径写成 markdown 粗体 `**<wd>/x.md**` 时,前导 `**` 被 lead 边界吃掉、但**尾部 `**` 被贪婪吞进 rel**(星号既不在 tail 集也不在尾部 strip 集),生成 `x.md**` 这种点开 `/v1/files/download?path=…md**` → 404 的坏 chip(用同款正则复现坐实结尾就是 `**`;反引号包裹的路径因反引号在排除集里天然无恙,偏这次用了粗体)。**修**:`*` 加进 `_TAIL_CLS` 排除集,同当初排除反引号一样在 emphasis 处干净截断;文件名含 `*` 在 Windows 非法、误伤≈0。**② 导出 word 里图标(emoji)缺失**——`rendering/docx_manuscript.py` 正文/标题字体是宋体/黑体/Times New Roman,**这些字体无 emoji 字形**,📰🔥🇨🇳📊 等直接写进宋体 run → Word 渲染成豆腐块/缺字。**修**:`rendering/common.py` 加 `EMOJI_RE`(圈主 emoji 区/区域指示符国旗/杂符+Dingbats/VS16/ZWJ,不含文本箭头等易误伤块)+ `add_fonted_runs`(按 emoji 边界拆 run,emoji 段走 `Segoe UI Emoji`、其余走原中西文字体),接进 `_emit_plain_with_chem`(正文,且不破坏化学式下标)与 `add_inline`(bold/italic/code);标题走 `add_inline` 自动覆盖。**③ 过程中冒出 sections/ 文件夹**——`render.py` docstring/argparse 都承诺 src 可为"目录**或单个 .md**",但 docx 三 profile(brief/paper/report/proposal)的 `render_sections` 全是 `is_dir()`+`glob("*.md")` **只认目录**(只有 pdf 真支持单文件),模型传单 .md → 撞 `sections dir not found` → 被迫 `mkdir sections && cp` 兜底留下垃圾目录。**修**:`common.py` 抽 `collect_md_files(src)`(文件→`[src]`,目录→glob),docx_manuscript + docx_brief 共用,figures 基准取 `src.parent`;兑现 docstring 承诺、模型不再需建 sections/。**验证**(/verify 精神,实跑非仅单测):还原 emoji 满满的今日新闻 md 跑单文件 `--profile report`,退出 0(不再报 sections 缺失),解包 docx 校 run 字体——📰🔥🇨🇳🏙️📊 全 `Segoe UI Emoji`、中文仍宋体、SiO₂/CaCO₃ 下标 `[SUB]` 未被破坏;正则修复用 4 组样本(粗体/反引号+间隔号/裸路径+中文句号/斜体)全绿。三处分属前端 chip 提取 / docx 字体 / render 契约,互不相干可独立修;均 additive,不碰对外 API/DB 契约(render.py 契约本就承诺单文件,现名副其实)。
- **07-17 / 0.58.36**:**run_python 超时返回超时前的部分输出 + 无缓冲(-u),让模型据此续跑不整批重来**(失败面板 #8:`command timed out` 19 次 / 7 task / 6 用户,累计最多)。**定性**(查 19 条超时的原始 tool_call 代码):不是死循环 / chromium 老坑,全是**真慢的批量 I/O**——① 论文检索 `skills.research.paper.search()` 循环搜多个期刊 / 关键词(最多,~11 条,每次打 OpenAlex 外部 API 累计爆超时)② pdfplumber 多 PDF `extract_text` ③ 大文件下载(ffmpeg tar.xz / MDPI PDF)④ pandas 大 Excel。超时机制本身正常,真正浪费在 **`_run_subprocess` 超时分支把已捕获的部分输出全丢了**(kill 后 `communicate()` 已续读超时前 stdout,却只回一句 `[Error] command timed out` → 模型看不到「搜到第 6 个」、整批重搜,同 task 反复重跑)。**修**:① `tools/base.py::format_timeout_result` 把超时前的 stdout/stderr 一并返回 + 续跑提示(据已完成部分续跑 / 每项落盘 / 大操作用 `background=true` / 单次别塞太多),docker(`_run_subprocess`)+ host(`run_python.py` 的 `TimeoutExpired.stdout/stderr`)两执行器共用;② **`-u` 无缓冲**——Python 管道下 stdout 块缓冲,不 flush 的 `print` 超时被 kill 时缓冲区就丢了,故 run_python 的 python 调用全加 `-u`(docker 前台 inline / script_path + host 前台 + 两处后台 `procs.launch_host`),让部分输出真正落管道被捕获(不碰 shell)。这里提示指路(background / 续跑)与语法预检不同、**恰当**:超时就是「你这段太慢」的确定信号,建议对任何超时都可靠,不像语法→渲染器那样分不清场景。**不做**:不抬默认 timeout(120s 合理,抬高只延后同问题 + 多占 loop);不单独改 prompt(提示随超时结果反应式投放更精准)。测试 `tests/test_timeout_partial.py`(format 单测 + host 端到端:print 两项后 sleep,timeout=1 → 结果带两项 + 续跑提示)+ `test_executor_docker` argv 断言更新为 `python -u`,全 34 相邻测试绿。additive(超时结果加信息,不破坏 schema),不碰对外 API/DB 契约。至此失败面板 top 全部处置(② edit streak / ③ 畸形 / ① SyntaxError 预检+渲染器 / #8 超时);char-0 畸形由既有 salvage 覆盖。相关 memory:高轮数烧 token 三根因 / 沙箱 chromium 案(本次确认非 chromium)。

5
RUN.md
View File

@ -55,6 +55,10 @@
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
# 可选:覆盖默认 7d JWT TTL
# ZCBOT_JWT_TTL_SECONDS=604800
# 可选:滑动续签阈值(秒)。token 剩余寿命低于此值时,任一鉴权请求都会就地重签一张
# 新 token,经响应头 X-Refreshed-Token / X-Token-Expires-At 带回(前端 dev SPA 自动换,
# platform SDK 见头即换)→ 活跃用户永不因 TTL 到期掉线。默 TTL 的 1/4;设 0 关闭(退回过期即重登)
# ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS=151200
# 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503)
# ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令>
# 可选:web run 线程池大小(asyncio.to_thread 默认池),默 min(32, cpu+4)。每个活跃
@ -135,6 +139,7 @@
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件 + 语音**(图片/文件走 media/get 下载,落盘进会话目录 inbound/;语音下载后 ffmpeg 解码 → 讯飞 IAT 转写成文本进对话,识别结果先推一条「🎤 已识别:…」回显,需 `XFYUN_*` 三件套 + 系统 ffmpeg,缺哪个回对应提示);视频/位置等暂不处理;未绑定/空消息静默。
- **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
- **内部链路**(排障用):`GET /v1/wecom/entry`(302 → 企微 snsapi_base 网页授权,静默无感)→ `GET /v1/wecom/entry/callback`(code → userid → 反查 `channel_bindings` 企微绑定 → 签 JWT)→ 302 `dev.html?embed=1&wecom=1#token=…&user_id=…`(token 走 **URL fragment**,前端读完立即 `history.replaceState` 清掉,不进 access log)。身份映射走 `channel_bindings` 的企微绑定(**不同于** iframe embed 的 platform 维护 user_id);401 时前端 `location.replace("/v1/wecom/entry")` 静默重签。此变体与 platform iframe 嵌入(见 `EMBED.md`)是**两条独立 token 来路**,互不涉及。
- **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.38"
__version__ = "0.58.39"

View File

@ -60,6 +60,8 @@ from core.storage.utils import ensure_local_task_row
from core.toolfail import alert_provider_critical
from .auth import (
REFRESHED_TOKEN_HEADER,
TOKEN_EXPIRES_HEADER,
AuthConfig,
UserCreateError,
change_password,
@ -1485,6 +1487,8 @@ def create_app() -> FastAPI:
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
# 滑动续签的新 token 走响应头带回;expose 才能让浏览器 fetch 读到(默认不暴露自定义头)
expose_headers=[REFRESHED_TOKEN_HEADER, TOKEN_EXPIRES_HEADER],
)
if _STATIC_DIR.is_dir():

View File

@ -20,12 +20,13 @@ from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Optional
from uuid import UUID
import bcrypt
import jwt
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import func, select
@ -35,6 +36,12 @@ from core.storage.models import User
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
# 滑动续签:验签通过且剩余寿命 < 阈值时,就地重签一张新 token 放响应头,
# 客户端(platform SDK / dev SPA)见头即换本地 token → 活跃用户永不掉线,
# 静默超过 TTL 才需重登。响应头名见 REFRESHED_TOKEN_HEADER / TOKEN_EXPIRES_HEADER。
REFRESHED_TOKEN_HEADER = "X-Refreshed-Token"
TOKEN_EXPIRES_HEADER = "X-Token-Expires-At"
class AuthConfig:
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
@ -44,11 +51,16 @@ class AuthConfig:
platform_key: str,
jwt_secret: str,
ttl_seconds: int,
refresh_threshold_seconds: int,
admin_token: Optional[str] = None,
):
self.platform_key = platform_key
self.jwt_secret = jwt_secret
self.ttl_seconds = ttl_seconds
# 滑动续签阈值:token 剩余寿命低于此值时,require_user/admin 就地重签放响应头。
# 默认 ttl 的 1/4(7d → ~1.75d);ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS 覆盖,
# 设 0 → 关闭滑动续签(退回"过期即重登")。
self.refresh_threshold_seconds = refresh_threshold_seconds
# ZCBOT_ADMIN_TOKEN 未设 → None;此时 /v1/auth/admin/create_user 返 503(功能关闭)。
# 这是个独立的共享口令,跟 PLATFORM_KEY / JWT_SECRET 分开 —— 它是"管理员发用户"
# 的单一钥匙,不参与 platform 机器对机器 / token 签名路径。
@ -78,9 +90,21 @@ class AuthConfig:
)
if ttl <= 0:
raise RuntimeError(f"ZCBOT_JWT_TTL_SECONDS must be > 0, got {ttl}")
refresh_raw = os.environ.get("ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS", "").strip()
try:
refresh = int(refresh_raw) if refresh_raw else ttl // 4
except ValueError:
raise RuntimeError(
f"ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS must be int seconds, got {refresh_raw!r}"
)
if refresh < 0:
raise RuntimeError(
f"ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS must be >= 0, got {refresh}"
)
admin = os.environ.get("ZCBOT_ADMIN_TOKEN", "").strip() or None
return cls(
platform_key=key, jwt_secret=secret, ttl_seconds=ttl, admin_token=admin,
platform_key=key, jwt_secret=secret, ttl_seconds=ttl,
refresh_threshold_seconds=refresh, admin_token=admin,
)
@ -132,14 +156,18 @@ def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
return token, exp
def verify_token(cfg: AuthConfig, token: str) -> UUID:
"""验签 + 取 sub。失败抛 HTTPException 401。"""
def decode_token(cfg: AuthConfig, token: str) -> dict:
"""验签 + 返回 payload dict(含 sub / iat / exp)。失败抛 HTTPException 401。"""
try:
payload = jwt.decode(token, cfg.jwt_secret, algorithms=["HS256"])
return jwt.decode(token, cfg.jwt_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token expired")
except jwt.InvalidTokenError as e:
raise HTTPException(401, f"invalid token: {e}")
def _uid_from_payload(payload: dict) -> UUID:
"""从 payload 取 sub → UUID。坏值抛 401。"""
sub = payload.get("sub", "")
try:
return UUID(sub)
@ -147,6 +175,33 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID:
raise HTTPException(401, f"invalid sub in token: {sub!r}")
def verify_token(cfg: AuthConfig, token: str) -> UUID:
"""验签 + 取 sub。失败抛 HTTPException 401。"""
return _uid_from_payload(decode_token(cfg, token))
def _maybe_slide_token(
cfg: AuthConfig, payload: dict, uid: UUID, response: Response
) -> None:
"""滑动续签:token 剩余寿命 < 阈值时重签一张,放响应头带回。
- 阈值 0 关闭,直接返回
- exp 缺失 / 非数值(理论不会,mint_token 恒写) 不续签,静默返回
- 已过期的 token 走不到这里(decode_token 先抛 401);这里只续"临期但仍有效"
响应头 CORS 放行见 create_app expose_headers
"""
if cfg.refresh_threshold_seconds <= 0:
return
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return
remaining = exp - int(time.time())
if 0 < remaining < cfg.refresh_threshold_seconds:
new_token, new_exp = mint_token(cfg, uid)
response.headers[REFRESHED_TOKEN_HEADER] = new_token
response.headers[TOKEN_EXPIRES_HEADER] = datetime.fromtimestamp(new_exp).isoformat()
class UserCreateError(Exception):
"""create_user 失败:`code` 是 HTTP-style 语义码('invalid_email' / 'weak_password' /
'email_taken' / 'db_error'),`message` 是面向操作者的简短描述
@ -310,6 +365,22 @@ def ensure_user_row(
_bearer = HTTPBearer(auto_error=False)
def _authenticate(
cfg: AuthConfig,
creds: Optional[HTTPAuthorizationCredentials],
response: Response,
) -> UUID:
"""验 Bearer → uid,顺带滑动续签(临期 token 重签放响应头)。require_user/admin 共用。"""
if creds is None or not creds.credentials:
raise HTTPException(401, "missing Authorization: Bearer <token>")
if creds.scheme.lower() != "bearer":
raise HTTPException(401, f"unsupported auth scheme: {creds.scheme!r}")
payload = decode_token(cfg, creds.credentials)
uid = _uid_from_payload(payload)
_maybe_slide_token(cfg, payload, uid, response)
return uid
def make_require_user(cfg: AuthConfig):
"""工厂:返回一个 Depends 函数,闭包持有 cfg(避免 app 启动后改 env)。
@ -319,15 +390,13 @@ def make_require_user(cfg: AuthConfig):
def route(user_id: UUID = Depends(require_user)):
...
实际使用建议直接 `user_id: UUID = Depends(require_user)`,既验签又拿到 user_id
验签成功且 token 临期时,通过响应头 X-Refreshed-Token 无感续签( _maybe_slide_token)
"""
async def require_user(
response: Response,
creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> UUID:
if creds is None or not creds.credentials:
raise HTTPException(401, "missing Authorization: Bearer <token>")
if creds.scheme.lower() != "bearer":
raise HTTPException(401, f"unsupported auth scheme: {creds.scheme!r}")
return verify_token(cfg, creds.credentials)
return _authenticate(cfg, creds, response)
return require_user
@ -340,13 +409,13 @@ def make_require_admin(cfg: AuthConfig):
@app.get("/v1/admin/...", )
def route(user_id: UUID = Depends(require_admin)): ...
admin 403(token 有效但无权限);DB role(不放进 JWT), role 立即生效
滑动续签同 require_user admin 端点也会在临期时带回 X-Refreshed-Token
"""
_require_user = make_require_user(cfg)
async def require_admin(
response: Response,
creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> UUID:
user_id = await _require_user(creds)
user_id = _authenticate(cfg, creds, response)
if get_user_role(user_id) != "admin":
raise HTTPException(403, "admin role required")
return user_id
@ -356,9 +425,12 @@ def make_require_admin(cfg: AuthConfig):
__all__ = [
"AuthConfig",
"REFRESHED_TOKEN_HEADER",
"TOKEN_EXPIRES_HEADER",
"UserCreateError",
"change_password",
"create_user",
"decode_token",
"ensure_user_row",
"get_user_profile",
"get_user_role",

View File

@ -1,6 +1,16 @@
// 统一 JSON fetch 封装:注入 Bearer token,解析 JSON/文本,非 2xx 抛带 status 的 Error。
// (login / create_user / SSE / 文件下载 因 header / 流式 / blob 特殊,仍各自手写 fetch)
import { state } from "./state.js";
import { state, LS_TOKEN } from "./state.js";
// 滑动续签:后端在临期 token 的响应里带 X-Refreshed-Token(见 web/auth.py),
// 这里无感换掉本地 token → 活跃用户永不因 TTL 到期被踢回登录页。
export function absorbRefreshedToken(r) {
const fresh = r.headers.get("X-Refreshed-Token");
if (fresh && fresh !== state.token) {
state.token = fresh;
localStorage.setItem(LS_TOKEN, fresh);
}
}
export async function api(method, path, body) {
const opts = { method, headers: {} };
@ -10,6 +20,7 @@ export async function api(method, path, body) {
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
absorbRefreshedToken(r);
let data = null;
const ct = r.headers.get("content-type") || "";
if (ct.includes("application/json")) {