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:
parent
53f59eb78a
commit
2baed6894b
30
DESIGN.md
30
DESIGN.md
|
|
@ -14,7 +14,7 @@
|
||||||
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
|
- 模型自由: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 三子命令
|
└── 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 由人填(也允许 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`) |
|
| 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) |
|
| Memory | `workspace/users/<user_id>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
|
||||||
| Sandbox | subprocess + env 过滤 | per-task docker exec |
|
| 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 形态。
|
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
|
||||||
|
|
||||||
|
|
@ -310,8 +310,13 @@ done {}
|
||||||
### 7.4 存储:Postgres + 本地文件系统
|
### 7.4 存储:Postgres + 本地文件系统
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
|
users(user_id uuid pk, email null unique, password_hash null, oidc_subject null, plan null, created_at)
|
||||||
-- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC
|
-- 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,
|
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,
|
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));
|
unique (task_id, idx));
|
||||||
create index on messages using gin (payload jsonb_path_ops);
|
create index on messages using gin (payload jsonb_path_ops);
|
||||||
-- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步)
|
-- 全文搜按需加 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` 表
|
**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 依赖
|
- 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖
|
||||||
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
|
- SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠
|
||||||
- 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`)
|
- 路由统一 `/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 域名收紧
|
- CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧
|
||||||
- **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连
|
- **沉淀**: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 REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"):
|
||||||
- **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。
|
- **原计划**:`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)。
|
- `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。
|
||||||
- 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。
|
- 删 `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 行。
|
- 净减 ~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,跟之前一样,不靠"全栈零依赖"幻觉。
|
- **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。
|
||||||
|
|
||||||
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
|
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
51
RUN.md
51
RUN.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
> 怎么把 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
|
# 可选:覆盖默认 7d JWT TTL
|
||||||
# ZCBOT_JWT_TTL_SECONDS=604800
|
# 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=...`。
|
> 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)。
|
- **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 也能跑。
|
- **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 也能跑。
|
||||||
- **邀请码**(`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}` 薄包装。
|
- **邮箱密码**(`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
|
.venv/Scripts/python.exe main.py web --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
### 能力探测 / DB 管理
|
### 能力探测 / DB 管理 / 用户管理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实测对账模型 yaml 声称的能力(费 token,有 API 开销)
|
# 实测对账模型 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 upgrade head
|
||||||
.venv/Scripts/python.exe main.py db downgrade -1
|
.venv/Scripts/python.exe main.py db downgrade -1
|
||||||
.venv/Scripts/python.exe main.py db current
|
.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>`。
|
**Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*` 需 `Authorization: Bearer <jwt>`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 路径 1:邀请码(dev SPA 给同事 / 自己试用 — 推荐)
|
# 路径 1:邮箱密码(dev SPA 给同事 / 自己试用 — 推荐)
|
||||||
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_invite \
|
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"token":"<同事的邀请码>"}'
|
-d '{"email":"alice@example.com","password":"atLeast6"}'
|
||||||
# → {"token":"eyJ...","expires_at":"...","user_id":"...","name":"alice","ttl_seconds":604800}
|
# → {"token":"eyJ...","expires_at":"...","user_id":"...","email":"alice@example.com","ttl_seconds":604800}
|
||||||
|
|
||||||
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口)
|
# 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口)
|
||||||
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
|
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
|
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)。给同事试用的最简发码流程:
|
**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. `python -c "import secrets;print(secrets.token_urlsafe(16))"` 生成 token
|
1. `.venv/Scripts/python.exe main.py user add --email <每人> --password <每人>`
|
||||||
2. PG 里 `INSERT INTO invites(token, name) VALUES('<tokenN>', '<nameN>');`(每人一条;name 是显示名,推导固定 user_id 用 — 建议英文 / 拼音 / 短)
|
2. **不用重启** web(每次 login 都查 DB),把 URL + 各自邮箱密码分别发给同事
|
||||||
3. **不用重启** web(每次 login 都查 DB),把 URL + 各自 token 分别发给同事
|
|
||||||
|
|
||||||
**路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`):
|
**路由表**(全 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 /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
|
||||||
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
|
| `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` | 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?}` | 必填 |
|
| `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` 1–100;`working_dir` 末段名;`q` ILIKE name+desc;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
|
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`page` 1-based,`page_size` 1–100;`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 | 必填 |
|
| `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 `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 |
|
| `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` 重起 |
|
| `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` 核对 |
|
| `/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 |
|
||||||
| INSERT invites 报 `duplicate key value violates unique constraint` | token PK 或 name UNIQUE 撞,改值再试 — 重复 token = 两人同身份漏,重复 name = 两 token 共身份漏,都被 schema 拦 |
|
| `main.py user add` 报 `IntegrityError ... uq_users_email` | 邮箱已在 users 表里,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks);允许同邮箱不同 user 是漏 |
|
||||||
| 改了同事的 name 后他登不上 / 数据看不到 | user_id 由 `uuid5(namespace, name)` 推导,改 name 等于换身份(`UPDATE invites SET name=...` = 换 user)。要保留数据:别动 name,只 `UPDATE invites SET token=...`(换 token = 不换身份);真要换身份:告知"这是新账号,旧任务在另一个 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 `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 |
|
| `/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 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 封装)
|
- **核心**:`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`
|
- **工具**:`tools/{fs,shell,run_python,skill_tool}.py`
|
||||||
- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic)
|
- **存储**:`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)
|
- **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)
|
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
|
||||||
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
|
- **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>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离
|
||||||
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享
|
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
workspace/users/<user_id>/<working_dir>/ ← 工作目录(用户起名,可多 task 共享)
|
workspace/users/<user_id>/<working_dir>/ ← 工作目录(用户起名,可多 task 共享)
|
||||||
workspace/users/<user_id>/.memory/{core.md, extended/} ← per-user 记忆(dotfile 隔离)
|
workspace/users/<user_id>/.memory/{core.md, extended/} ← per-user 记忆(dotfile 隔离)
|
||||||
|
|
||||||
所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邀请码登录(`invites` 表,
|
所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邮箱密码登录
|
||||||
name → uuid5)、platform 服务端走 platform_key 登录。task_id / user_id 全 UUID;
|
(`users.email/password_hash`,bcrypt)、platform 服务端走 platform_key 登录。task_id / user_id 全 UUID;
|
||||||
state.json 已删除(元数据全在 PG)。
|
state.json 已删除(元数据全在 PG)。
|
||||||
|
|
||||||
**新建 task 必须给 `name`**(任务显示名,DB 列 NOT NULL);**`working_dir` 可选**
|
**新建 task 必须给 `name`**(任务显示名,DB 列 NOT NULL);**`working_dir` 可选**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
|
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
|
||||||
|
|
||||||
4 张表:users / tasks / messages / invites。
|
3 张表:users / tasks / messages。
|
||||||
- users 行在 web 入口(`/v1/auth/login*`)按需 INSERT;user_id 由 invite name uuid5 推导 或 platform 直传
|
- 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 里建
|
- messages.payload 用 jsonb,GIN 索引在 migration 里建
|
||||||
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs 表);
|
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs 表);
|
||||||
原 runs / usage_events 表 0004 删 — 详 DESIGN §7.4 取舍 / PROGRESS 05-18
|
原 runs / usage_events 表 0004 删 — 详 DESIGN §7.4 取舍 / PROGRESS 05-18
|
||||||
- invites 0005 加(token PK / name UNIQUE / created_at):dev SPA 邀请码登录后端
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@ class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
user_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
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)
|
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
password_hash: 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)
|
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
50
main.py
|
|
@ -138,6 +138,56 @@ def probe(model: str, long_context: bool) -> None:
|
||||||
console.print("\n[ok]全部能力声明与实测一致。[/ok]")
|
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 服务 ───────────────
|
# ─────────────── Web 服务 ───────────────
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ fastapi>=0.111.0
|
||||||
uvicorn[standard]>=0.30.0
|
uvicorn[standard]>=0.30.0
|
||||||
python-multipart>=0.0.9 # files upload multipart 解析
|
python-multipart>=0.0.9 # files upload multipart 解析
|
||||||
pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
|
pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
|
||||||
|
bcrypt>=4.1.0 # /v1/auth/login_password 密码哈希(users.password_hash)
|
||||||
|
|
|
||||||
33
web/app.py
33
web/app.py
|
|
@ -38,7 +38,7 @@ from core.storage import (
|
||||||
from core.storage.models import Message, Task
|
from core.storage.models import Message, Task
|
||||||
from core.storage.utils import ensure_local_task_row
|
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 .broker import broker
|
||||||
from .sinks import WebEventSink
|
from .sinks import WebEventSink
|
||||||
|
|
||||||
|
|
@ -267,8 +267,9 @@ class LoginRequest(BaseModel):
|
||||||
platform_key: str
|
platform_key: str
|
||||||
|
|
||||||
|
|
||||||
class InviteLoginRequest(BaseModel):
|
class PasswordLoginRequest(BaseModel):
|
||||||
token: str
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── App 工厂 ──────────────────────
|
# ────────────────────── App 工厂 ──────────────────────
|
||||||
|
|
@ -346,7 +347,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
platform_key 错 → 403;user_id 非 UUID → 400。
|
platform_key 错 → 403;user_id 非 UUID → 400。
|
||||||
user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。
|
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:
|
if body.platform_key != auth_cfg.platform_key:
|
||||||
raise HTTPException(403, "invalid platform_key")
|
raise HTTPException(403, "invalid platform_key")
|
||||||
|
|
@ -363,26 +364,26 @@ def create_app() -> FastAPI:
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/v1/auth/login_invite", tags=["auth"])
|
@app.post("/v1/auth/login_password", tags=["auth"])
|
||||||
def login_invite(body: InviteLoginRequest):
|
def login_password(body: PasswordLoginRequest):
|
||||||
"""邀请码登录(dev SPA 给同事试用)。
|
"""邮箱密码登录(dev SPA 给同事 / 自己试用)。
|
||||||
|
|
||||||
- token 在 `invites` 表(0005)未命中 → 403(包括表为空 / 已 DELETE 的情况)
|
- users.email 未命中 / password_hash 为空 / bcrypt 校验失败 → 一律 403
|
||||||
- 命中 → 由 name 推导 user_id (uuid5),幂等建 users 行,签 JWT
|
(不细分错因,防探测用户存在性)
|
||||||
- 发码:DB 里 `INSERT INTO invites(token, name) VALUES('xxx','alice')`;
|
- 命中 → 直接用 DB 里现成 user_id 签 JWT(不 ensure_user_row,行已在 `user add` 时建)
|
||||||
撤销:`DELETE FROM invites WHERE name='alice'`
|
- 发用户:`.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:
|
if hit is None:
|
||||||
raise HTTPException(403, "invalid invite token")
|
raise HTTPException(403, "invalid email or password")
|
||||||
name, uid = hit
|
uid, email = hit
|
||||||
ensure_user_row(uid)
|
|
||||||
token, exp = mint_token(auth_cfg, uid)
|
token, exp = mint_token(auth_cfg, uid)
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
||||||
"user_id": str(uid),
|
"user_id": str(uid),
|
||||||
"name": name,
|
"email": email,
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
69
web/auth.py
69
web/auth.py
|
|
@ -3,37 +3,38 @@
|
||||||
模型:
|
模型:
|
||||||
- `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥
|
- `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥
|
||||||
- `JWT_SECRET` env(必填):HS256 签 token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护
|
- `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 {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>`
|
- 后续 `/v1/*`(除 /healthz、/docs、/openapi.json、/、/v1/auth/login*)走 `Authorization: Bearer <jwt>`
|
||||||
- Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖,默 7 天
|
- 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid5
|
from uuid import UUID
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from core.storage import session_scope
|
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
|
_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
|
||||||
|
|
||||||
# uuid5 命名空间 — 别改,改了所有邀请码用户身份漂移、历史数据全丢
|
|
||||||
_INVITE_NAMESPACE = UUID("9b5e7a2a-3c8e-5f4d-8c1a-f0e6b9d7c3a1")
|
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig:
|
class AuthConfig:
|
||||||
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
|
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
|
||||||
|
|
@ -70,22 +71,43 @@ class AuthConfig:
|
||||||
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
|
return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl)
|
||||||
|
|
||||||
|
|
||||||
def resolve_invite(token: str) -> Optional[tuple[str, UUID]]:
|
def hash_password(password: str) -> str:
|
||||||
"""查 invites 表;命中返 `(name, user_id)`,user_id 由 uuid5(NS, name) 推导。
|
"""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()
|
e = (email or "").strip().lower()
|
||||||
if not t:
|
if not e or not password:
|
||||||
return None
|
return None
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
row = s.execute(
|
row = s.execute(
|
||||||
select(Invite.name).where(Invite.token == t)
|
select(User.user_id, User.email, User.password_hash).where(User.email == e)
|
||||||
).scalar_one_or_none()
|
).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
|
# 避免 timing oracle:用户不存在时也跑一次同等开销的 verify
|
||||||
|
bcrypt.checkpw(b"x", b"$2b$12$" + b"." * 53)
|
||||||
return None
|
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]:
|
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:
|
def ensure_user_row(user_id: UUID) -> None:
|
||||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
||||||
|
|
||||||
邀请码登录(uuid5 推导)、platform_key 登录(显式传入)、未来 OIDC 都走这条 —
|
platform_key 登录入口用 — 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行
|
||||||
新用户首次登录建行,既有用户复登 no-op。真用户 profile(email/oidc_subject 等)
|
避免下游 FK 失败。邮箱密码登录走 `main.py user add` 已经写好 users 行,不走这条。
|
||||||
在 D' OIDC 阶段再走专门的 register/sync 路径。
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
|
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
|
||||||
|
|
@ -157,8 +178,10 @@ def make_require_user(cfg: AuthConfig):
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthConfig",
|
"AuthConfig",
|
||||||
"ensure_user_row",
|
"ensure_user_row",
|
||||||
|
"hash_password",
|
||||||
"make_require_user",
|
"make_require_user",
|
||||||
"mint_token",
|
"mint_token",
|
||||||
"resolve_invite",
|
"resolve_user_by_email",
|
||||||
|
"verify_password",
|
||||||
"verify_token",
|
"verify_token",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@
|
||||||
#login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); }
|
#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 .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; }
|
||||||
#login .actions { margin-top: 14px; display: flex; gap: 8px; }
|
#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 ───── */
|
/* ───── 3-pane layout ───── */
|
||||||
#app { display: none; height: 100vh; }
|
#app { display: none; height: 100vh; }
|
||||||
|
|
@ -332,15 +340,37 @@
|
||||||
<div id="login">
|
<div id="login">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>zcbot 登录</h2>
|
<h2>zcbot 登录</h2>
|
||||||
<label for="li-token">邀请码</label>
|
<div class="tabs">
|
||||||
<input id="li-token" type="password" autocomplete="off" placeholder="请输入管理员分配的邀请码" />
|
<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="err" id="li-err"></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" id="li-go">登录</button>
|
<button class="primary" id="li-go">登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="small muted" style="margin-top: 12px;">
|
|
||||||
邀请码由管理员在服务端 <code>invites</code> 表里分配,每人一码,丢失找管理员重发。
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -610,20 +640,62 @@ function highlightIn(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── login ─────
|
// ───── 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-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() {
|
async function doLogin() {
|
||||||
const token = $("li-token").value.trim();
|
|
||||||
$("li-err").textContent = "";
|
$("li-err").textContent = "";
|
||||||
if (!token) {
|
let url, body, displayLabel;
|
||||||
$("li-err").textContent = "请填邀请码";
|
if (loginTab === "pw") {
|
||||||
|
const email = $("li-email").value.trim();
|
||||||
|
const password = $("li-password").value;
|
||||||
|
if (!email || !password) {
|
||||||
|
$("li-err").textContent = "请填邮箱和密码";
|
||||||
return;
|
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 {
|
try {
|
||||||
const r = await fetch("/v1/auth/login_invite", {
|
const r = await fetch(url, {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
|
|
@ -632,10 +704,14 @@ async function doLogin() {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
state.token = data.token;
|
state.token = data.token;
|
||||||
state.userId = data.user_id;
|
state.userId = data.user_id;
|
||||||
state.userName = data.name || "";
|
state.userName = displayLabel ? (data[displayLabel] || "") : "";
|
||||||
localStorage.setItem(LS_TOKEN, state.token);
|
localStorage.setItem(LS_TOKEN, state.token);
|
||||||
localStorage.setItem(LS_UID, state.userId);
|
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();
|
enterApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("li-err").textContent = e.message;
|
$("li-err").textContent = e.message;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue