From fe21ca1e8c8c882a32ee6d842b177ca97528bb0e Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 21 May 2026 15:51:02 +0800 Subject: [PATCH] =?UTF-8?q?ui+api:=20=E7=99=BB=E5=BD=95=E9=A1=B5=E5=8A=A0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=91=E7=94=A8=E6=88=B7=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=88=A0=20chat=20meta=20=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=20=E6=9D=A1/tok=20=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/auth.py 加 `create_user()` helper(CLI / web 共用)+ `AuthConfig.admin_token` 从 `ZCBOT_ADMIN_TOKEN` env 读,未设 → 接口返 503(功能默关) - web/app.py 新增 POST /v1/auth/admin/create_user,403/400/409 四分支(口令错 / 邮箱不合法或密码 < 6 / 邮箱占用);main.py user_add CLI 改调同 helper 避免漂移 - web/static/dev.html 登录卡片右下加 ghost link "+ 管理员添加用户" + 弹窗 (email/密码/管理员口令),成功后回填邮箱到登录表单不自动登录; 同时删 chat 顶栏 ${n_messages} 条 · ${tokens} tok 一行(与左 task 列表重复) - RUN.md 加 ZCBOT_ADMIN_TOKEN env 说明 + 故障表两行;PROGRESS.md 加一条 2026-05-21 Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 3 +- RUN.md | 12 ++++- main.py | 28 +++------- web/app.py | 42 ++++++++++++++- web/auth.py | 63 ++++++++++++++++++++++- web/static/dev.html | 122 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 243 insertions(+), 27 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index b64f482..99b0da9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-05-21(新增 documents skill 接 ai.ctc-zc.com:8100 内部知识库 API) +最后更新:2026-05-21(登录页加管理员发用户入口 + 删 chat meta 条/tok 显示) --- @@ -23,6 +23,7 @@ ### 2026-05-21 +- **登录页加"+ 管理员添加用户"入口 + 删 chat meta 条/tok 显示**:`web/auth.py` 加 `create_user()` helper(CLI/web 共用,避免漂移)+ `AuthConfig.admin_token` 从 `ZCBOT_ADMIN_TOKEN` env 读(未设 → None);`web/app.py` 加 `POST /v1/auth/admin/create_user` 校验共享口令后落库(503/403/400/409 分支);前端 `dev.html` 登录卡片右下加 ghost link + 弹窗(email/密码/管理员口令),成功后回填邮箱到登录表单提示"已创建请登录",不自动登录;同时删 chat 顶栏 `${n_messages} 条 · ${tokens} tok` 一行(与左 task 列表重复)。否决"User 表加 is_admin 列 + 管理员 JWT"方案 —— 开发期成本不划算,env 共享口令(类 PLATFORM_KEY 范式)够用。 - **新增 documents skill(内部材料学科知识库 document_search API)**:`skills/documents/{SKILL.md, client.py}`,四函数 `list_kb / search / download / health`;走 `https://ai.ctc-zc.com:8100/api` Bearer 认证,env `DOCUMENT_SEARCH_API_KEY` + `DOCUMENT_SEARCH_URL`(可覆盖);search 默认返 `md_content`(整篇 Markdown 50K-200K 字符级),SKILL.md 反模式约束"只 print 前 300 字"防爆上下文;smoke 验证发现库实质是 7 个材料学科预收的英文学术论文(胶凝/陶瓷/玻璃/晶体/复合/耐火/检验检测,21W+ 文件)+ 跨语言语义检索,SKILL.md 据此校准(原写"主语料中文"是错的);与 research(OpenAlex)互补,documents 已 Markdown 化对 LLM 更友好,但仅覆盖材料领域。 - **dev SPA SSE 客户端重连(覆盖 --reload 抖动)**:`fetchSse` 拆出 `consumeSseStream` + 包重连壳(1s/2s/4s 退避,最多 3 次);reader EOF 未见 done/error 算异常关流触发重连;后端 `stream_events` 入口检 `tasks.run_status`,非 running/cancelling 立即吐 done 关流(否则进程重启后新 broker 内存空,客户端会无限挂 ping)。3 次仍失败 → 卡片末尾红色"连接已断开,请重发"。断开期间 LLM delta 丢失,接受。 - **research skill 三次迭代 fetch_pdf 改走静态直链**:`fetch_pdf` 跟 `fetch_xml` 同范式,从 `paper["pdf_url"]` 流式下载,绕开 paper_pdf_view 路径 bug(disk 路径计算错);smoke 5/5 PASS。 diff --git a/RUN.md b/RUN.md index 0f9d472..b586f70 100644 --- a/RUN.md +++ b/RUN.md @@ -26,12 +26,14 @@ JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造> # 可选:覆盖默认 7d JWT TTL # ZCBOT_JWT_TTL_SECONDS=604800 + # 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503) + # ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令> ``` > 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` 里;含 `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 登录后端。发用户走 `main.py user add`(下方);撤用户 `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)。改密 / 改邮箱手动 SQL 或先 DELETE 再 add。 --- @@ -76,9 +78,13 @@ python -m venv .venv .venv/Scripts/python.exe main.py db downgrade -1 .venv/Scripts/python.exe main.py db current -# 发用户 +# 发用户(两条路径,任选其一) +# a) CLI: .venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6" # → [ok] user added email=alice@example.com user_id= +# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`, +# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。 +# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。 # 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径 .venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id @@ -274,6 +280,8 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干 | `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱不存在 / `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=...`(用 `.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` | 邮箱已存在,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks) | | `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=` 同理 | | `/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 | diff --git a/main.py b/main.py index 70119e0..b52be48 100644 --- a/main.py +++ b/main.py @@ -156,34 +156,22 @@ def user_add(email: str, password: str, user_id: str) -> None: email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接 `DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。 """ - from uuid import UUID as _UUID, uuid4 as _uuid4 + from uuid import UUID as _UUID - 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) + from web.auth import UserCreateError, create_user + + uid_arg = None if user_id: try: - uid = _UUID(user_id) + uid_arg = _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) + uid, e = create_user(email=email, password=password, user_id=uid_arg) + except UserCreateError as ex: + click.echo(f"[err] {ex.message}", err=True) sys.exit(2) click.echo(f"[ok] user added email={e} user_id={uid}") diff --git a/web/app.py b/web/app.py index 44464db..4c3fda2 100644 --- a/web/app.py +++ b/web/app.py @@ -38,7 +38,15 @@ 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_user_by_email +from .auth import ( + AuthConfig, + UserCreateError, + create_user, + ensure_user_row, + make_require_user, + mint_token, + resolve_user_by_email, +) from .broker import broker from .sinks import WebEventSink @@ -416,6 +424,12 @@ class PasswordLoginRequest(BaseModel): password: str +class AdminCreateUserRequest(BaseModel): + email: str + password: str + admin_token: str + + # ────────────────────── App 工厂 ────────────────────── # web/static 目录路径 — /static 静态挂载用,dev.html 也放这 @@ -567,6 +581,32 @@ def create_app() -> FastAPI: "ttl_seconds": auth_cfg.ttl_seconds, } + @app.post("/v1/auth/admin/create_user", tags=["auth"]) + def admin_create_user(body: AdminCreateUserRequest): + """管理员发用户(dev SPA 登录页右下角入口)。 + + - `ZCBOT_ADMIN_TOKEN` env 未设 → 503,功能关闭 + - `admin_token` 不匹配 → 403(不细分 "未设" / "错了",防探测) + - email 不合法 / password 太短 → 400 + - email 已存在 → 409 + - 成功 → `{"user_id": ..., "email": ...}`,前端提示 "已创建,请登录" + + 不签 token、不自动登录 —— 管理员发完用户用户自己登,逻辑清晰。 + """ + if auth_cfg.admin_token is None: + raise HTTPException(503, "admin create_user disabled (ZCBOT_ADMIN_TOKEN not set)") + if body.admin_token != auth_cfg.admin_token: + raise HTTPException(403, "invalid admin_token") + try: + uid, email = create_user(email=body.email, password=body.password) + except UserCreateError as ex: + if ex.code in ("invalid_email", "weak_password"): + raise HTTPException(400, ex.message) + if ex.code == "email_taken": + raise HTTPException(409, "email already exists") + raise HTTPException(500, f"create_user failed: {ex.message}") + return {"user_id": str(uid), "email": email} + @app.post("/v1/auth/login_password", tags=["auth"]) def login_password(body: PasswordLoginRequest): """邮箱密码登录(dev SPA 给同事 / 自己试用)。 diff --git a/web/auth.py b/web/auth.py index 1a7aaa7..978048b 100644 --- a/web/auth.py +++ b/web/auth.py @@ -39,10 +39,20 @@ _DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d class AuthConfig: """App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。""" - def __init__(self, platform_key: str, jwt_secret: str, ttl_seconds: int): + def __init__( + self, + platform_key: str, + jwt_secret: str, + ttl_seconds: int, + admin_token: Optional[str] = None, + ): self.platform_key = platform_key self.jwt_secret = jwt_secret self.ttl_seconds = ttl_seconds + # ZCBOT_ADMIN_TOKEN 未设 → None;此时 /v1/auth/admin/create_user 返 503(功能关闭)。 + # 这是个独立的共享口令,跟 PLATFORM_KEY / JWT_SECRET 分开 —— 它是"管理员发用户" + # 的单一钥匙,不参与 platform 机器对机器 / token 签名路径。 + self.admin_token = admin_token @classmethod def from_env(cls) -> "AuthConfig": @@ -68,7 +78,10 @@ class AuthConfig: ) if ttl <= 0: raise RuntimeError(f"ZCBOT_JWT_TTL_SECONDS must be > 0, got {ttl}") - return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl) + admin = os.environ.get("ZCBOT_ADMIN_TOKEN", "").strip() or None + return cls( + platform_key=key, jwt_secret=secret, ttl_seconds=ttl, admin_token=admin, + ) def hash_password(password: str) -> str: @@ -134,6 +147,50 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID: raise HTTPException(401, f"invalid sub in token: {sub!r}") +class UserCreateError(Exception): + """create_user 失败:`code` 是 HTTP-style 语义码('invalid_email' / 'weak_password' / + 'email_taken' / 'db_error'),`message` 是面向操作者的简短描述。 + + web 路由 / CLI 都用同一份;调用方决定是 raise HTTPException 还是 click.echo + exit。 + """ + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tuple[UUID, str]: + """新建用户:bcrypt(password) + INSERT users。 + + 校验:email 含 @ + 非空;password ≥ 6 字符。`user_id` 不传 → 随机 UUID4。 + 冲突:email UNIQUE / user_id PK 撞 → `UserCreateError('email_taken' | 'db_error')`。 + 返回 `(user_id, normalized_email)`,供调用方记 log / 提示。 + """ + from uuid import uuid4 as _uuid4 + + from sqlalchemy.exc import IntegrityError + + e = (email or "").strip().lower() + if not e or "@" not in e: + raise UserCreateError("invalid_email", f"email 不合法: {email!r}") + if not password or len(password) < 6: + raise UserCreateError("weak_password", "password 至少 6 字符") + uid = user_id or _uuid4() + try: + with session_scope() as s: + s.add(User(user_id=uid, email=e, password_hash=hash_password(password))) + except IntegrityError as ex: + # email UNIQUE 撞最常见;user_id PK 撞理论上几乎不可能(uuid4)但也归一到 email_taken + # 之外 — 这里只对 email 报 409 语义,其他 DB 异常归 db_error + msg = str(getattr(ex, "orig", ex)) + code = "email_taken" if "users_email" in msg or "email" in msg.lower() else "db_error" + raise UserCreateError(code, f"INSERT 失败: {msg}") + except Exception as ex: + raise UserCreateError("db_error", f"INSERT 失败: {type(ex).__name__}: {ex}") + return uid, e + + def ensure_user_row(user_id: UUID) -> None: """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 @@ -177,6 +234,8 @@ def make_require_user(cfg: AuthConfig): __all__ = [ "AuthConfig", + "UserCreateError", + "create_user", "ensure_user_row", "hash_password", "make_require_user", diff --git a/web/static/dev.html b/web/static/dev.html index 95d3c45..08a0946 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -137,6 +137,44 @@ background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: 11.5px; } + #login .card-footer { + margin-top: 10px; display: flex; justify-content: flex-end; + } + #login .ghost-link { + color: var(--muted); font-size: 12px; text-decoration: none; + padding: 2px 4px; border-radius: 4px; transition: color .15s, background .15s; + } + #login .ghost-link:hover { color: var(--accent); background: var(--accent-soft); } + + /* ───── admin add-user modal ───── */ + #admin-modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); + display: none; align-items: center; justify-content: center; z-index: 110; + } + #admin-modal.show { display: flex; } + #admin-modal .card { + background: var(--panel); padding: 20px 24px; border-radius: 10px; + width: 360px; box-shadow: 0 16px 40px rgba(0,0,0,.18); + } + #admin-modal h3 { margin: 0 0 12px; font-size: 15px; } + #admin-modal label { + display: block; margin-top: 10px; margin-bottom: 4px; + font-size: 12px; color: var(--muted); + } + #admin-modal input { + width: 100%; padding: 8px 10px; border-radius: 6px; + border: 1px solid var(--border); background: #fafafa; + } + #admin-modal input:focus { + outline: none; background: #fff; border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(192,57,43,.12); + } + #admin-modal .err { + color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; + } + #admin-modal .actions { + margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; + } /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } @@ -604,6 +642,27 @@
+ + + + + +
+
+

添加用户

+ + + + + + +
+
+ + +
@@ -1036,6 +1095,68 @@ function logout() { } $("hd-logout").onclick = logout; +// ───── admin add-user ───── +// 入口在登录页右下角链接;弹窗收 email/password/admin_token 三项,POST /v1/auth/admin/create_user。 +// 成功后不自动登录(让用户自己用新账号登,逻辑清晰),只回填邮箱到登录表单 + 提示。 +function openAdminModal() { + $("ad-email").value = ""; + $("ad-password").value = ""; + $("ad-token").value = ""; + $("ad-err").textContent = ""; + $("admin-modal").classList.add("show"); + $("ad-email").focus(); +} +function closeAdminModal() { + $("admin-modal").classList.remove("show"); +} +$("open-admin-add").onclick = (e) => { e.preventDefault(); openAdminModal(); }; +$("ad-cancel").onclick = closeAdminModal; +$("admin-modal").addEventListener("click", (e) => { + if (e.target.id === "admin-modal") closeAdminModal(); // 点遮罩关闭 +}); +// 任一 input 上回车触发提交 +document.querySelectorAll("#admin-modal input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doAdminAdd(); }); +}); + +async function doAdminAdd() { + $("ad-err").textContent = ""; + const email = $("ad-email").value.trim(); + const password = $("ad-password").value; + const admin_token = $("ad-token").value; + if (!email || !password || !admin_token) { + $("ad-err").textContent = "请填邮箱、密码、管理员口令"; + return; + } + if (password.length < 6) { + $("ad-err").textContent = "密码至少 6 字符"; + return; + } + try { + const r = await fetch("/v1/auth/admin/create_user", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, admin_token }), + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || (r.status + " create failed")); + } + const data = await r.json(); + closeAdminModal(); + // 切到邮箱密码 tab,回填邮箱,提示一下 + switchLoginTab("pw"); + $("li-email").value = data.email || email; + $("li-password").value = ""; + $("li-password").focus(); + $("li-err").style.color = "var(--muted)"; // 临时降级为提示色 + $("li-err").textContent = `已创建 ${data.email},请登录`; + setTimeout(() => { $("li-err").style.color = ""; }, 4000); + } catch (e) { + $("ad-err").textContent = e.message; + } +} +$("ad-go").onclick = doAdminAdd; + // ───── 左 pane 折叠 toggle(rail 模式 + localStorage 持久化) ───── // 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向 function applyLeftCollapsed(collapsed) { @@ -1331,7 +1452,6 @@ function renderChatMeta() { ${renderModelDropdown(t)} ${renderImageModelDropdown()} - ${t.n_messages || 0} 条 · ${t.tokens || 0} tok `; const sel = $("chat-model-sel"); if (sel) sel.onchange = onChangeModel;