diff --git a/DESIGN.md b/DESIGN.md index e47b212..548e8a4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -252,6 +252,9 @@ Auth POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用) bcrypt 校验 users.password_hash(0005 加 UNIQUE(email)); 错邮箱 / 错密码 / 未设密码统一 403 防探测 + POST /v1/auth/change_password {old_password, new_password} → {ok}(dev SPA 顶栏自助改密) + 需 Bearer(user_id 取自 JWT);验旧密码 + 新密码 ≥6 bcrypt 重哈希; + 旧密码错 / platform_key 建的无密码行 → 403,弱密码 → 400 Files(user-rooted,workspace/users// 为根) GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current}; @@ -298,6 +301,7 @@ done {} **当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): - `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入) - `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户 +- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403) 后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 diff --git a/PROGRESS.md b/PROGRESS.md index d32c152..d1f2735 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-06-05 +- **dev 页加"改密码"功能 + 文件面板"选入"按钮文字改图标(防换行)**:① 自助改密码——`web/auth.py::change_password(user_id, old, new)`(验旧密码 → 新密码 ≥6 → bcrypt 重哈希写回,错误归一到现成 `UserCreateError` code 体系 `wrong_password/no_password/weak_password/user_not_found`,不为此新开异常类),`POST /v1/auth/change_password` 挂 `Depends(require_user)`(user_id 取自 JWT 不信前端,旧密码错/无密码→403、弱→400、行没了→401)。前端顶栏「退出登录」左侧加「改密码」按钮(`#hd-chpw`,并入 embed 隐藏规则——embed 模式不显示)+ 一个复用 `.modal` 骨架的弹框(旧/新/确认三项,前端先验长度+两次一致再提交,成功 `alert` 提示不登出,401 走 `logout()`)。否决"点用户名展开菜单"(多写菜单逻辑不划算)。② `#btn-src-pick` 的文字 `选入…` 改单字符图标 `⊕`(和旁边 `⬆ ↻ ›` 同款单色字形,`title` 保留"选入"语义)——原中文文字在窄面板偶发换行。 - **记账给 DeepSeek 前缀缓存命中折价(修虚高 ~2-3x)+ 前端体现缓存命中/真实成本**:排查"rust 优势→PPT"那 task(flash,34 轮)发现 `tokens_in` 累计 69.9 万里 **88.6% 是缓存命中**,但 `usage.py::_fallback_chat_cost_cny` 把命中段也按 `input` 全价(1.0)算 → 记 ¥0.84,真实(命中按 0.1x)只 ~¥0.28,**越大的 task 虚高越多**(文献采集 53% 命中:¥33→~¥16)。修:① `ModelCapabilities` 加 `cache_hit_cny_per_mtoken`(deepseek flash 0.1 / pro 0.2;0=不区分按全价兜底,绝不少记);② 成本公式拆三段「命中×缓存价 + (input−命中)×input价 + output×output价」,`loop.py` 把 `cache_hit_tokens` + 缓存单价透传进 `record_chat_usage`;③ 前端不加 DB 列——`web/app.py` 加 `_usage_aggregates`(单查询 GROUP BY `usage_events`,复用列表 `msg_counts` 同款批量范式,无 N+1)on-the-fly 算每 task 真实成本 + chat token + 缓存命中,`_task_dict` 带出;列表行**不内联花费**、只显 tok 数,花费/缓存命中率藏 hover tooltip(`taskUsageTooltip`,多行:输入/输出拆分 · 命中 + 命中率 · ¥真实花费),顶栏额外内联简版。**折价只对新 chat 事件生效**,历史走 backfill 脚本(`scripts/backfill_chat_cost_cache_discount.py`,默认 dry-run,`--apply` 落库;`--assume-cache-hit-rate RATE` 给无 `cache_hit_tokens` 字段的老事件按估算命中率折价——DeepSeek 当时缓存了只是没记,全价偏高;实测过的事件用真实值不受影响)。**坑修**:命中率分母原误用 `tasks.tokens_prompt`,但该列会被「清空对话」重置而 `usage_events` 不重置 → 跨源相除算出 822% 怪值;改为 `_task_dict` 的 token 总量也优先取 usage_events 聚合(与 cache_hit 同源,命中率恒 ≤100%)。**注**:真正压低 token 体量的杠杆是减少轮数(高成本 task 全是 100+ 轮的逐步 write/run_python 循环),非本次范围。 ### 2026-06-04 diff --git a/RUN.md b/RUN.md index db7e231..6f9bd0c 100644 --- a/RUN.md +++ b/RUN.md @@ -45,7 +45,7 @@ - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 -- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。改密 / 改邮箱手动 SQL 或先 DELETE 再 add。 +- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 --- @@ -144,6 +144,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `GET /static/*` | dev.html 等静态文件 | 豁免 | | `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}` → `{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 | +| `POST /v1/auth/change_password` | dev SPA 顶栏「改密码」;body `{old_password, new_password}`(user_id 取自 JWT)→ `{ok:true}`;新密码 <6→400、旧密码错 / 无密码(platform_key 建的行)→403 | 必填 | | `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 | | `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | @@ -696,7 +697,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt | `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 | | 登录页"+ 管理员添加用户"提交后 503 `admin create_user disabled` | `ZCBOT_ADMIN_TOKEN` env 未设,功能默关。设了 env 重启 web 即可;或临时回退 `main.py user add` | | 登录页"+ 管理员添加用户"返 403 `invalid admin_token` | 弹窗里管理员口令栏填错或没复制完整。跟 `.env` 里 `ZCBOT_ADMIN_TOKEN` 比对(注意末尾空格 / 引号) | -| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=` 同理 | +| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=` 同理。**用户知道旧密码时优先让他用顶栏「改密码」自助**,只有忘了旧密码 / 改邮箱才手动 SQL | +| 顶栏「改密码」返 403 `该账号未设置密码` | 该 user 是 platform_key(UUID+PLATFORM_KEY)入口建的占位行,`password_hash` 为空,无旧密码可验。先手动 `UPDATE users SET password_hash=` 设一个,再让他用密码登 + 自助改 | | `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` | | `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env | | dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login | diff --git a/web/app.py b/web/app.py index b292da8..b5578ea 100644 --- a/web/app.py +++ b/web/app.py @@ -41,6 +41,7 @@ from core.storage.utils import ensure_local_task_row from .auth import ( AuthConfig, UserCreateError, + change_password, create_user, ensure_user_row, make_require_user, @@ -525,6 +526,11 @@ class AdminCreateUserRequest(BaseModel): admin_token: str +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + # ────────────────────── App 工厂 ────────────────────── # web/static 目录路径 — /static 静态挂载用,dev.html 也放这 @@ -849,6 +855,29 @@ def create_app() -> FastAPI: "ttl_seconds": auth_cfg.ttl_seconds, } + @app.post("/v1/auth/change_password", tags=["auth"]) + def change_password_route( + body: ChangePasswordRequest, user_id: UUID = Depends(require_user) + ): + """改密码(dev SPA 顶栏入口)。user_id 取自 JWT,不信任前端传值。 + + - 新密码 < 6 → 400 + - 旧密码错 / 该账号无密码(platform_key 建的)→ 403(不细分,防探测) + - 用户不存在(JWT 有效但行没了)→ 401 + 成功 → `{"ok": true}`,前端提示并清空表单。 + """ + try: + change_password(user_id, body.old_password, body.new_password) + except UserCreateError as ex: + if ex.code == "weak_password": + raise HTTPException(400, ex.message) + if ex.code in ("wrong_password", "no_password"): + raise HTTPException(403, ex.message) + if ex.code == "user_not_found": + raise HTTPException(401, ex.message) + raise HTTPException(500, f"change_password failed: {ex.message}") + return {"ok": True} + # ───────────── Tasks CRUD ───────────── @app.post("/v1/tasks", status_code=201, tags=["tasks"]) diff --git a/web/auth.py b/web/auth.py index 978048b..fccfd0b 100644 --- a/web/auth.py +++ b/web/auth.py @@ -191,6 +191,29 @@ def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tu return uid, e +def change_password(user_id: UUID, old_password: str, new_password: str) -> None: + """改密码:验旧密码 → 校验新密码 → bcrypt 重哈希写回 users.password_hash。 + + 错误归一到 `UserCreateError.code`(复用同一份 code 载体,web 路由映射成 HTTP): + - 'user_not_found' — user_id 无对应行(JWT 有效却查不到,极少见) + - 'no_password' — 该用户从没设过密码(platform_key 入口建的占位行),无从校验旧密码 + - 'wrong_password' — 旧密码不匹配 + - 'weak_password' — 新密码 < 6 字符 + 成功返 None;写在 session_scope 内,退出时一次 commit,下次 login 立即生效。 + """ + if not new_password or len(new_password) < 6: + raise UserCreateError("weak_password", "新密码至少 6 字符") + with session_scope() as s: + user = s.execute(select(User).where(User.user_id == user_id)).scalar_one_or_none() + if user is None: + raise UserCreateError("user_not_found", "用户不存在") + if not user.password_hash: + raise UserCreateError("no_password", "该账号未设置密码,无法修改") + if not verify_password(old_password, user.password_hash): + raise UserCreateError("wrong_password", "旧密码不正确") + user.password_hash = hash_password(new_password) + + def ensure_user_row(user_id: UUID) -> None: """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 @@ -235,6 +258,7 @@ def make_require_user(cfg: AuthConfig): __all__ = [ "AuthConfig", "UserCreateError", + "change_password", "create_user", "ensure_user_row", "hash_password", diff --git a/web/static/dev.html b/web/static/dev.html index e118bd3..5be6a15 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -731,7 +731,7 @@ /* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */ .mobile-tabs { display: flex; order: 99; flex-basis: 100%; } .mobile-tabs button { flex: 1; } - #hd-logout { padding: 4px 8px; font-size: 12px; } + #hd-chpw, #hd-logout { padding: 4px 8px; font-size: 12px; } /* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */ textarea, @@ -777,6 +777,7 @@ body.embed-mode #login { display: none !important; } body.embed-mode header .brand, body.embed-mode header #hd-who, + body.embed-mode header #hd-chpw, body.embed-mode header #hd-logout { display: none; } @media (min-width: 641px) { body.embed-mode header { display: none; } @@ -876,6 +877,24 @@ + + +
@@ -892,6 +911,7 @@
+
@@ -976,7 +996,7 @@ 文件 - + @@ -1496,6 +1516,57 @@ async function doAdminAdd() { } $("ad-go").onclick = doAdminAdd; +// ───── 改密码(顶栏入口,需已登录)───── +// 旧/新/确认三项;user_id 不传,后端从 JWT 取。成功后关弹窗,提示一下(不登出)。 +function openChpwModal() { + $("cp-old").value = ""; + $("cp-new").value = ""; + $("cp-new2").value = ""; + $("cp-err").textContent = ""; + $("chpw-modal").classList.add("show"); + $("cp-old").focus(); +} +function closeChpwModal() { + $("chpw-modal").classList.remove("show"); +} +$("hd-chpw").onclick = openChpwModal; +$("cp-cancel").onclick = closeChpwModal; +$("chpw-modal").addEventListener("click", (e) => { + if (e.target.id === "chpw-modal") closeChpwModal(); // 点遮罩关闭 +}); +document.querySelectorAll("#chpw-modal input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doChangePassword(); }); +}); + +async function doChangePassword() { + $("cp-err").textContent = ""; + const oldPw = $("cp-old").value; + const newPw = $("cp-new").value; + const newPw2 = $("cp-new2").value; + if (!oldPw || !newPw || !newPw2) { + $("cp-err").textContent = "请填旧密码和新密码"; + return; + } + if (newPw.length < 6) { + $("cp-err").textContent = "新密码至少 6 字符"; + return; + } + if (newPw !== newPw2) { + $("cp-err").textContent = "两次输入的新密码不一致"; + return; + } + try { + await api("POST", "/v1/auth/change_password", { old_password: oldPw, new_password: newPw }); + closeChpwModal(); + // 不登出:同一会话 JWT 仍有效,下次登录用新密码即可 + alert("密码已修改,下次登录请用新密码"); + } catch (e) { + if (e.status === 401) { closeChpwModal(); logout(); return; } + $("cp-err").textContent = e.message; + } +} +$("cp-go").onclick = doChangePassword; + // ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ───── const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } }; function clampPaneWidth(side, value) { @@ -3641,6 +3712,7 @@ $("mini-preview-modal").addEventListener("click", (e) => { document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) + if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }