auth(dev SPA): 邀请码撤回 邮箱+密码 (users.email/password_hash bcrypt; 0005 加 UNIQUE; user add CLI; 登录两 tab)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 13:58:48 +08:00
parent 53f59eb78a
commit 2baed6894b
12 changed files with 289 additions and 156 deletions

View File

@ -14,7 +14,7 @@
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构
- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邀请码登录(`invites` 表,name → uuid5 推导 user_id),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邮箱密码登录(`users.email/password_hash`,bcrypt 校验),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
---
@ -47,9 +47,9 @@ zcbot/
└── main.py # 入口: web / db / probe 三子命令
```
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邀请码登录(`invites` 表)由 name 经 `uuid5(固定 namespace, name)` 推导,platform 登录则由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 `users` 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。
**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_invite`(dev SPA / 同事试用)或 `/v1/auth/login`(platform 机器对机器)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。
**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(dev SPA / 同事试用 — 邮箱密码,bcrypt 校验)或 `/v1/auth/login`(platform 机器对机器,platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。
---
@ -111,7 +111,7 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。
**memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邀请码登录走 uuid5、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
**memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邮箱密码登录从 users 表直读、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
---
@ -198,7 +198,7 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本
| working_dir | `workspace/users/<user_id>/<name>/`(user_id 从 JWT 透传) | `<storage_root>/users/<user_id>/<name>/`(JWT `sub`) |
| Memory | `workspace/users/<user_id>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
| Sandbox | subprocess + env 过滤 | per-task docker exec |
| Auth | 邀请码(`invites` 表,name→uuid5)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邀请码同步下线) |
| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
@ -310,8 +310,13 @@ done {}
### 7.4 存储:Postgres + 本地文件系统
```sql
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
-- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC
users(user_id uuid pk, email null unique, password_hash null, oidc_subject null, plan null, created_at)
-- 0001 schema;0005 给 email 加 UNIQUE(NULL 不冲突,允许 platform_key 入口 user email=NULL 共存)。
-- 行入口三条:① main.py user add(发邮箱密码用户,bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id;email/password_hash 留 NULL)
-- ③ 未来 OIDC(D' 真上线后,改写 login;email/oidc_subject 由 ID token 注入)。
-- 管理:撤用户 DELETE FROM users WHERE email=...(先清该 user 的 tasks,messages CASCADE);
-- 改密 UPDATE users SET password_hash=<bcrypt(...)>;改邮箱 UPDATE users SET email=...(user_id 不动)。
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
@ -331,11 +336,6 @@ messages(message_id uuid pk, task_id fk, idx int not null,
unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops);
-- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步)
invites(token text pk, name text not null unique, created_at)
-- 0005 加;dev SPA 邀请码登录后端。user_id 由 uuid5(固定 NS, name) 推导,不入表。
-- 管理:直接 INSERT/DELETE/UPDATE(后续若需要可加 `python main.py invite ...` 薄包装);
-- 撤销 = DELETE row;换 token 不换身份 = UPDATE token;换 name = 换身份(旧 task 留在旧 user 下)。
```
**0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs`
@ -448,11 +448,11 @@ invites(token text pk, name text not null unique, created_at)
- 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
- 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`)
- auth 走 D' 过渡形态:邀请码(`invites` 表,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接
- auth 走 D' 过渡形态:邮箱密码(`users.email/password_hash` + bcrypt,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页填邀请码 → `/v1/auth/login_invite` 拿 JWT → localStorage 存 → fetch+Bearer。
**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used tab 持久化)→ `/v1/auth/login_password``/v1/auth/login` 拿 JWT → localStorage 存 → fetch+Bearer。
**CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"):
- **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。
@ -461,7 +461,7 @@ invites(token text pk, name text not null unique, created_at)
- `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。
- 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。
- 净减 ~400 行 REPL 逻辑(`_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console` 等 CLI-only 代码),`main.py` ~180 行,`core/agent_builder.py` ~320 行。
- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邀请码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。
- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。
- **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。

File diff suppressed because one or more lines are too long

51
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-19(dev SPA 登录改邀请码;**邀请码后端从 env 升级到 `invites` 表**(0005 migration);新路由 `POST /v1/auth/login_invite`,原 `/v1/auth/login` 留给 platform 机器对机器入口;SENTINEL user 撤)
最后更新:2026-05-19(**dev SPA 登录撤回 邮箱+密码** — 复用 0001 schema 的 `users.email/password_hash`,0005 加 `UNIQUE(email)`;短命 invites 表 + `/v1/auth/login_invite` 撤;新路由 `POST /v1/auth/login_password`;`main.py user add` CLI 建用户;`/v1/auth/login` (platform 机器对机器) 不动;SENTINEL user 撤)
---
@ -19,12 +19,12 @@
# 可选:覆盖默认 7d JWT TTL
# ZCBOT_JWT_TTL_SECONDS=604800
```
> 邀请码不再走 env(05-19 撤),改走 `invites` 表(见下方"邀请码"段)。
> 邀请码方案已撤(05-19,一日游),改回 `users.email/password_hash`(0001 schema 原列 + 0005 加 UNIQUE)。
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv`;含 `bcrypt` 给密码哈希)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。
- **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe` 不验,不要这两个 env 也能跑。
- **邀请码**(`invites` 表,0005):dev SPA 登录后端。schema 极薄 — `token PK / name UNIQUE / created_at`。user_id 由 `uuid5(固定 namespace, name)` 推导,不入表(重启稳定)。空表 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`;撤销:`DELETE FROM invites WHERE name=...`)。同事拿到 token 即可登录,**不接触** `PLATFORM_KEY`。token 生成可用 `python -c "import secrets;print(secrets.token_urlsafe(16))"`。后续可选加 `python main.py invite {add|list|revoke}` 薄包装
- **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe / user` 不验,不要这两个 env 也能跑。
- **邮箱密码**(`users.email/password_hash`,0005 加 UNIQUE):dev SPA 登录后端。一行 = 一个用户,`password_hash` 是 `bcrypt`(cost=12)。发用户走 `.venv/Scripts/python.exe main.py user add --email X --password Y`(详见下方 user 命令段);撤用户直接 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks,messages 通过 FK CASCADE 自动)。同事拿到邮箱密码即可登录,**不接触** `PLATFORM_KEY`。改密 / 改邮箱目前手动 SQL(`UPDATE users SET password_hash=<bcrypt hash> / email=... WHERE ...`)或先 DELETE 再 add
---
@ -62,7 +62,7 @@ python -m venv .venv
.venv/Scripts/python.exe main.py web --reload
```
### 能力探测 / DB 管理
### 能力探测 / DB 管理 / 用户管理
```bash
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销)
@ -72,16 +72,25 @@ python -m venv .venv
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe main.py db current
# 发用户 — dev SPA 邮箱密码登录后端 bootstrap
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com user_id=<uuid>
# 可选把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 撤用户(先清该 user 的 tasks,messages CASCADE)
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com';
```
**Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*``Authorization: Bearer <jwt>`
```bash
# 路径 1:邀请码(dev SPA 给同事 / 自己试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_invite \
# 路径 1:邮箱密码(dev SPA 给同事 / 自己试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
-H "Content-Type: application/json" \
-d '{"token":"<同事的邀请码>"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","name":"alice","ttl_seconds":604800}
-d '{"email":"alice@example.com","password":"atLeast6"}'
# → {"token":"eyJ...","expires_at":"...","user_id":"...","email":"alice@example.com","ttl_seconds":604800}
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
@ -94,10 +103,9 @@ TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
```
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),login 表单填邀请码(需 `invites` 表里有对应行)进入 3 栏(task 列表 / chat / files)。给同事试用的最简发码流程:
1. `python -c "import secrets;print(secrets.token_urlsafe(16))"` 生成 token
2. PG 里 `INSERT INTO invites(token, name) VALUES('<tokenN>', '<nameN>');`(每人一条;name 是显示名,推导固定 user_id 用 — 建议英文 / 拼音 / 短)
3. **不用重启** web(每次 login 都查 DB),把 URL + 各自 token 分别发给同事
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY")进入 3 栏(task 列表 / chat / files);last-used tab 持久化在 localStorage。给同事试用的最简发用户流程:
1. `.venv/Scripts/python.exe main.py user add --email <每人> --password <每人>`
2. **不用重启** web(每次 login 都查 DB),把 URL + 各自邮箱密码分别发给同事
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
@ -108,7 +116,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `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_invite` | dev SPA 邀请码入口;body `{token}``{token,expires_at,user_id,name,ttl_seconds}`;`invites` 表 token 未命中(含空表)→ 403 | 豁免 |
| `POST /v1/auth/login_password` | dev SPA 邮箱密码入口;body `{email, password}``{token,expires_at,user_id,email,ttl_seconds}`;邮箱不存在 / 密码错 / 未设密码统一 403 `invalid email or password` | 豁免 |
| `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}`;`page` 1-based,`page_size` 1100;`working_dir` 末段名;`q` ILIKE name+desc;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
@ -157,26 +165,27 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先点 stop / `POST /v1/tasks/{id}/cancel` 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套(§7.4 no-subtask)。换一个不冲突的 new_name |
| `main.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
| `/v1/auth/login_invite` 返 403 `invalid invite token` | `invites` 表无此 token(从未 INSERT 或已 DELETE)。`SELECT token, name, created_at FROM invites` 核对 |
| INSERT invites 报 `duplicate key value violates unique constraint` | token PK 或 name UNIQUE 撞,改值再试 — 重复 token = 两人同身份漏,重复 name = 两 token 共身份漏,都被 schema 拦 |
| 改了同事的 name 后他登不上 / 数据看不到 | user_id 由 `uuid5(namespace, name)` 推导,改 name 等于换身份(`UPDATE invites SET name=...` = 换 user)。要保留数据:别动 name,只 `UPDATE invites SET token=...`(换 token = 不换身份);真要换身份:告知"这是新账号,旧任务在另一个 user 下" |
| `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱在 users 表里不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 → `main.py user add` 发新;有行无密码 → `UPDATE users SET password_hash=...` 手补 bcrypt(用 `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` 算)或直接 `user add --user-id` 接到现有 user_id |
| `main.py user add``IntegrityError ... uq_users_email` | 邮箱已在 users 表里,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks);允许同邮箱不同 user 是漏 |
| `main.py user add``IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传 `--user-id` 让随机生成 |
| 改了某用户邮箱后他登不上 | 单纯改 `UPDATE users SET email=...` 不影响 user_id(行还是同一行,task 仍归属),用户用新邮箱登即可;若 lowercase 不一致(后端 `lower()` 后查)→ DB 里就该存小写。改密同理 `UPDATE users SET password_hash=<bcrypt>` |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/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 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login |
| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,重新填 platform_key 即可 |
| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,按上次用的 tab(邮箱密码 / UUID+PLATFORM_KEY)重登即可 |
---
## 关键路径与文件
- **入口**:`main.py`(`web / db / probe` 三子命令)→ `core/agent_builder.py::build_agent`(装配 lib)
- **入口**:`main.py`(`web / db / probe / user` 四子命令)→ `core/agent_builder.py::build_agent`(装配 lib)
- **核心**:`core/agent_builder.py`(build_agent / system prompt / validate_task_name 等装配 lib)/ `core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装)
- **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic)
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS)
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`邀请码登录走 uuid5、platform 登录直传):
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`邮箱密码登录从 users 表直读、platform 登录直传):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享

View File

@ -6,8 +6,8 @@
workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享)
workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离)
所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 邀请码登录(`invites` ,
name uuid5)platform 服务端走 platform_key 登录task_id / user_id UUID;
所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 邮箱密码登录
(`users.email/password_hash`,bcrypt)platform 服务端走 platform_key 登录task_id / user_id UUID;
state.json 已删除(元数据全在 PG)
**新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选**

View File

@ -1,11 +1,12 @@
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
4 张表:users / tasks / messages / invites
- users 行在 web 入口(`/v1/auth/login*`)按需 INSERT;user_id invite name uuid5 推导 platform 直传
3 张表:users / tasks / messages
- users 行在 web 入口按需 INSERT(`/v1/auth/login_password` 实际创行 / `/v1/auth/login`
platform_key 入口 ensure_user_row);email UNIQUE(0005) login lookup ,
password_hash bcrypt(`bcrypt.hashpw`),只在邮箱密码登录时有值
- messages.payload jsonb,GIN 索引在 migration 里建
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs );
runs / usage_events 0004 DESIGN §7.4 取舍 / PROGRESS 05-18
- invites 0005 (token PK / name UNIQUE / created_at):dev SPA 邀请码登录后端
"""
from __future__ import annotations
@ -35,7 +36,7 @@ class User(Base):
__tablename__ = "users"
user_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
email: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
email: Mapped[Optional[str]] = mapped_column(Text, nullable=True, unique=True)
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -94,19 +95,3 @@ class Message(Base):
)
class Invite(Base):
"""dev SPA 邀请码登录后端表(0005)。
token lookup 入口(PK);name 是显示名 + uuid5(NS, name) user_id 推导源
(UNIQUE 防同名 = token 共身份);created_at 给审计
管理:目前直接在 DB INSERT/DELETE(`python main.py invite ...` 后续若需要再加)
"""
__tablename__ = "invites"
token: Mapped[str] = mapped_column(Text, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -1,43 +0,0 @@
"""add invites table — dev SPA 邀请码登录改 DB 后端。
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-19
05-19 邀请码登录(`/v1/auth/login_invite`): `ZCBOT_INVITES` env 字符串解析撤,
`invites` 最薄 schema:
- token PK login 入口直接查这列
- name UNIQUE 推导 uuid5 , name = user_id,UNIQUE "两 token 同身份"
- created_at 审计
不存 user_id( uuid5(NS, name) 推导,namespace 固定不动);不存 revoked_at
(撤销直接 DELETE,5 人级别用户不要软删的额外分支)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"invites",
sa.Column("token", sa.Text(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False, unique=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("invites")

View File

@ -0,0 +1,30 @@
"""users.email 加 UNIQUE — dev SPA 登录改 邮箱+密码(复用 0001 schema 的 email/password_hash)。
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-19
dev SPA 登录从一日游的"邀请码"撤回到 0001 schema 早就预留的 email/password_hash
唯一缺的是 email 的唯一性约束 login lookup 入口必须保证一封邮箱对应一个 user_id
PG `UNIQUE` NULL 不冲突,所以 platform_key 入口创的 user(email=NULL)不受影响
:前一个版本号 0005 短命过 invites ,已在本次开发中 downgrade + file 抹除痕迹,
本文件直接占用 0005, 0004
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_unique_constraint("uq_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_constraint("uq_users_email", "users", type_="unique")

50
main.py
View File

@ -138,6 +138,56 @@ def probe(model: str, long_context: bool) -> None:
console.print("\n[ok]全部能力声明与实测一致。[/ok]")
# ─────────────── User 管理(dev SPA 邮箱密码登录后端 bootstrap) ───────────────
@cli.group()
def user() -> None:
"""用户管理:dev SPA 邮箱密码登录 bootstrap。需先 export ZCBOT_DB_URL。"""
@user.command("add")
@click.option("--email", required=True, help="登录邮箱(UNIQUE),登录页填这个")
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
@click.option("--user-id", default=None,
help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
def user_add(email: str, password: str, user_id: str) -> None:
"""新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。
email UNIQUE 报错退出 2;user_id PK 也是撤销直接
`DELETE FROM users WHERE email='...'`(先清该 user tasks,否则 FK )
"""
from uuid import UUID as _UUID, uuid4 as _uuid4
e = email.strip().lower()
if not e or "@" not in e:
click.echo(f"[err] email 不合法: {email!r}", err=True)
sys.exit(2)
if len(password) < 6:
click.echo("[err] password 至少 6 字符", err=True)
sys.exit(2)
if user_id:
try:
uid = _UUID(user_id)
except ValueError:
click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True)
sys.exit(2)
else:
uid = _uuid4()
from core.storage import session_scope
from core.storage.models import User
from web.auth import hash_password
try:
with session_scope() as s:
s.add(User(user_id=uid, email=e, password_hash=hash_password(password)))
except Exception as ex:
# IntegrityError(email UNIQUE / user_id PK 撞)等都走这条
click.echo(f"[err] INSERT 失败: {type(ex).__name__}: {ex}", err=True)
sys.exit(2)
click.echo(f"[ok] user added email={e} user_id={uid}")
# ─────────────── Web 服务 ───────────────
@cli.command()

View File

@ -21,3 +21,4 @@ fastapi>=0.111.0
uvicorn[standard]>=0.30.0
python-multipart>=0.0.9 # files upload multipart 解析
pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
bcrypt>=4.1.0 # /v1/auth/login_password 密码哈希(users.password_hash)

View File

@ -38,7 +38,7 @@ from core.storage import (
from core.storage.models import Message, Task
from core.storage.utils import ensure_local_task_row
from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_invite
from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_user_by_email
from .broker import broker
from .sinks import WebEventSink
@ -267,8 +267,9 @@ class LoginRequest(BaseModel):
platform_key: str
class InviteLoginRequest(BaseModel):
token: str
class PasswordLoginRequest(BaseModel):
email: str
password: str
# ────────────────────── App 工厂 ──────────────────────
@ -346,7 +347,7 @@ def create_app() -> FastAPI:
platform_key 403;user_id UUID 400
user_id 未存在则幂等创建 users (避免下游 FK 失败)
platform 服务端用此入口注入指定 user_id;dev SPA /login_invite
platform 服务端用此入口注入指定 user_id;dev SPA /login_password
"""
if body.platform_key != auth_cfg.platform_key:
raise HTTPException(403, "invalid platform_key")
@ -363,26 +364,26 @@ def create_app() -> FastAPI:
"ttl_seconds": auth_cfg.ttl_seconds,
}
@app.post("/v1/auth/login_invite", tags=["auth"])
def login_invite(body: InviteLoginRequest):
"""邀请码登录(dev SPA 给同事试用)。
@app.post("/v1/auth/login_password", tags=["auth"])
def login_password(body: PasswordLoginRequest):
"""邮箱密码登录(dev SPA 给同事 / 自己试用)。
- token `invites` (0005)未命中 403(包括表为空 / DELETE 的情况)
- 命中 name 推导 user_id (uuid5),幂等建 users , JWT
- 发码:DB `INSERT INTO invites(token, name) VALUES('xxx','alice')`;
撤销:`DELETE FROM invites WHERE name='alice'`
- users.email 未命中 / password_hash 为空 / bcrypt 校验失败 一律 403
(不细分错因,防探测用户存在性)
- 命中 直接用 DB 里现成 user_id JWT( ensure_user_row,行已在 `user add` 时建)
- 发用户:`.venv/Scripts/python.exe main.py user add --email X --password Y`;
撤用户:`DELETE FROM users WHERE email=...`( DELETE user tasks)
"""
hit = resolve_invite(body.token)
hit = resolve_user_by_email(body.email, body.password)
if hit is None:
raise HTTPException(403, "invalid invite token")
name, uid = hit
ensure_user_row(uid)
raise HTTPException(403, "invalid email or password")
uid, email = hit
token, exp = mint_token(auth_cfg, uid)
return {
"token": token,
"expires_at": _dt.fromtimestamp(exp).isoformat(),
"user_id": str(uid),
"name": name,
"email": email,
"ttl_seconds": auth_cfg.ttl_seconds,
}

View File

@ -3,37 +3,38 @@
模型:
- `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥
- `JWT_SECRET` env(必填):HS256 token;泄漏 = 任意伪造, PLATFORM_KEY 同级保护
- **`invites` **(0005)dev SPA 邀请码登录后端:token PK / name UNIQUE / created_at;
user_id `uuid5(_INVITE_NAMESPACE, name)` 推导,重启稳定; name 会换身份(数据看不到)
表空 `/v1/auth/login_invite` 403(发码:`INSERT INTO invites(token, name) VALUES(...)`)
- `POST /v1/auth/login {user_id, platform_key}` JWT(platform 服务端用,自带 user_id 注入)
- `POST /v1/auth/login_invite {token}` JWT(dev SPA ,name user_id 服务端推导)
- `POST /v1/auth/login_password {email, password}` JWT
(dev SPA ,users.email UNIQUE + users.password_hash bcrypt 校验;0005 UNIQUE)
- 后续 `/v1/*`( /healthz/docs/openapi.json//v1/auth/login*) `Authorization: Bearer <jwt>`
- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖, 7
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);invite 路径同期可下线。
发用户:`.venv/Scripts/python.exe main.py user add --email X --password Y`,后台直接
bcrypt + INSERT users;撤用户 `DELETE FROM users WHERE email=...`(messages CASCADE,
tasks 通过 FK ,要先 DELETE user tasks)
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);password 路径
真发布时下线
"""
from __future__ import annotations
import os
import time
from typing import Optional
from uuid import UUID, uuid5
from uuid import UUID
import bcrypt
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Invite, User
from core.storage.models import User
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
# uuid5 命名空间 — 别改,改了所有邀请码用户身份漂移、历史数据全丢
_INVITE_NAMESPACE = UUID("9b5e7a2a-3c8e-5f4d-8c1a-f0e6b9d7c3a1")
class AuthConfig:
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
@ -70,22 +71,43 @@ class AuthConfig:
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
def resolve_invite(token: str) -> Optional[tuple[str, UUID]]:
"""查 invites 表;命中返 `(name, user_id)`,user_id 由 uuid5(NS, name) 推导。
def hash_password(password: str) -> str:
"""bcrypt 哈希(默认 cost=12)。返 ASCII str(bcrypt 标准格式 `$2b$12$...`),直接落 DB。"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
None 表示 token 未命中(空串 / 不存在 / DELETE 都走这条)每次 login 一次
SELECT,5 人级别用户开销可忽略;不缓存避免 DELETE 后还能登的不一致窗口
def verify_password(password: str, stored_hash: str) -> bool:
"""常数时间比对。stored_hash 是 DB 里 users.password_hash 列。"""
try:
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("ascii"))
except (ValueError, TypeError):
# stored_hash 格式坏(手工 INSERT 乱写)/ 不是 ASCII → 视作不匹配,别 500
return False
def resolve_user_by_email(email: str, password: str) -> Optional[tuple[UUID, str]]:
"""email + password → `(user_id, email)`;不匹配返 None(空表 / 邮箱不存在 / 密码错都走这条)。
单次 SELECT + bcrypt verify;不缓存,改密码 / 删账号下次 login 立即生效
bcrypt.checkpw 本身是 constant-time;查不到也要跑一次 dummy hash timing oracle
(5 人级别用户无所谓,但顺手做)
"""
t = (token or "").strip()
if not t:
e = (email or "").strip().lower()
if not e or not password:
return None
with session_scope() as s:
row = s.execute(
select(Invite.name).where(Invite.token == t)
).scalar_one_or_none()
select(User.user_id, User.email, User.password_hash).where(User.email == e)
).first()
if row is None:
# 避免 timing oracle:用户不存在时也跑一次同等开销的 verify
bcrypt.checkpw(b"x", b"$2b$12$" + b"." * 53)
return None
return row, uuid5(_INVITE_NAMESPACE, row)
if not row.password_hash:
return None # 用户存在但没设密码(platform_key 入口建的)
if not verify_password(password, row.password_hash):
return None
return row.user_id, row.email
def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]:
@ -115,9 +137,8 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID:
def ensure_user_row(user_id: UUID) -> None:
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
邀请码登录(uuid5 推导)platform_key 登录(显式传入)未来 OIDC 都走这条
新用户首次登录建行,既有用户复登 no-op真用户 profile(email/oidc_subject )
D' OIDC 阶段再走专门的 register/sync 路径。
platform_key 登录入口用 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行
避免下游 FK 失败邮箱密码登录走 `main.py user add` 已经写好 users ,不走这条
"""
from sqlalchemy.dialects.postgresql import insert
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
@ -157,8 +178,10 @@ def make_require_user(cfg: AuthConfig):
__all__ = [
"AuthConfig",
"ensure_user_row",
"hash_password",
"make_require_user",
"mint_token",
"resolve_invite",
"resolve_user_by_email",
"verify_password",
"verify_token",
]

View File

@ -64,6 +64,14 @@
#login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); }
#login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
#login .actions { margin-top: 14px; display: flex; gap: 8px; }
#login .tabs { display: flex; border-bottom: 1px solid var(--border); margin: 0 0 12px; }
#login .tabs button {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 6px 12px; font-size: 13px; color: var(--muted); cursor: pointer;
}
#login .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
#login .tab-body { display: none; }
#login .tab-body.active { display: block; }
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
@ -332,15 +340,37 @@
<div id="login">
<div class="card">
<h2>zcbot 登录</h2>
<label for="li-token">邀请码</label>
<input id="li-token" type="password" autocomplete="off" placeholder="请输入管理员分配的邀请码" />
<div class="tabs">
<button data-tab="pw" class="active" id="tab-pw">邮箱密码</button>
<button data-tab="key" id="tab-key">UUID + PLATFORM_KEY</button>
</div>
<!-- tab 1: 邮箱 + 密码(默认) -->
<div class="tab-body active" id="body-pw">
<label for="li-email">邮箱</label>
<input id="li-email" type="email" autocomplete="username" placeholder="you@example.com" />
<label for="li-password">密码</label>
<input id="li-password" type="password" autocomplete="current-password" placeholder="密码" />
<div class="small muted" style="margin-top: 10px;">
管理员发用户:<code>python main.py user add --email X --password Y</code>
</div>
</div>
<!-- tab 2: UUID + PLATFORM_KEY(platform 服务端 / 调试用) -->
<div class="tab-body" id="body-key">
<label for="li-uid">user_id (UUID)</label>
<input id="li-uid" type="text" autocomplete="off" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
<label for="li-pkey">PLATFORM_KEY</label>
<input id="li-pkey" type="password" autocomplete="off" placeholder="$PLATFORM_KEY env 值" />
<div class="small muted" style="margin-top: 10px;">
平台服务端机器对机器入口;手动登录用于本地调试 / 接管已有 user_id。
</div>
</div>
<div class="err" id="li-err"></div>
<div class="actions">
<button class="primary" id="li-go">登录</button>
</div>
<div class="small muted" style="margin-top: 12px;">
邀请码由管理员在服务端 <code>invites</code> 表里分配,每人一码,丢失找管理员重发。
</div>
</div>
</div>
@ -610,20 +640,62 @@ function highlightIn(container) {
}
// ───── login ─────
let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个
const LS_TAB = "zcbot_login_tab";
function switchLoginTab(name) {
loginTab = name;
document.querySelectorAll("#login .tabs button").forEach(b => {
b.classList.toggle("active", b.dataset.tab === name);
});
document.querySelectorAll("#login .tab-body").forEach(b => {
b.classList.toggle("active", b.id === "body-" + name);
});
localStorage.setItem(LS_TAB, name);
$("li-err").textContent = "";
// 自动 focus 第一个空 input,Enter 直接登
const firstInput = document.querySelector("#body-" + name + " input");
if (firstInput) firstInput.focus();
}
document.querySelectorAll("#login .tabs button").forEach(b => {
b.addEventListener("click", () => switchLoginTab(b.dataset.tab));
});
const savedTab = localStorage.getItem(LS_TAB);
if (savedTab === "key") switchLoginTab("key");
$("li-go").onclick = doLogin;
$("li-token").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
// 任意 input 上回车都触发登录
document.querySelectorAll("#login input").forEach(i => {
i.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
});
async function doLogin() {
const token = $("li-token").value.trim();
$("li-err").textContent = "";
if (!token) {
$("li-err").textContent = "请填邀请码";
return;
let url, body, displayLabel;
if (loginTab === "pw") {
const email = $("li-email").value.trim();
const password = $("li-password").value;
if (!email || !password) {
$("li-err").textContent = "请填邮箱和密码";
return;
}
url = "/v1/auth/login_password";
body = { email, password };
displayLabel = "email";
} else {
const uid = $("li-uid").value.trim();
const pkey = $("li-pkey").value;
if (!uid || !pkey) {
$("li-err").textContent = "请填 user_id 和 PLATFORM_KEY";
return;
}
url = "/v1/auth/login";
body = { user_id: uid, platform_key: pkey };
displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位
}
try {
const r = await fetch("/v1/auth/login_invite", {
const r = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
@ -632,10 +704,14 @@ async function doLogin() {
const data = await r.json();
state.token = data.token;
state.userId = data.user_id;
state.userName = data.name || "";
state.userName = displayLabel ? (data[displayLabel] || "") : "";
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
if (state.userName) localStorage.setItem(LS_NAME, state.userName);
if (state.userName) {
localStorage.setItem(LS_NAME, state.userName);
} else {
localStorage.removeItem(LS_NAME);
}
enterApp();
} catch (e) {
$("li-err").textContent = e.message;