ui+api: 登录页加管理员发用户入口 + 删 chat meta 重复的 条/tok 显示
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
7bdb6ca5eb
commit
fe21ca1e8c
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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 更友好,但仅覆盖材料领域。
|
- **新增 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 丢失,接受。
|
- **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。
|
- **research skill 三次迭代 fetch_pdf 改走静态直链**:`fetch_pdf` 跟 `fetch_xml` 同范式,从 `paper["pdf_url"]` 流式下载,绕开 paper_pdf_view 路径 bug(disk 路径计算错);smoke 5/5 PASS。
|
||||||
|
|
|
||||||
12
RUN.md
12
RUN.md
|
|
@ -26,12 +26,14 @@
|
||||||
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
|
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
|
||||||
# 可选:覆盖默认 7d JWT TTL
|
# 可选:覆盖默认 7d JWT TTL
|
||||||
# ZCBOT_JWT_TTL_SECONDS=604800
|
# 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=...`。
|
> 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`)。
|
- **依赖**:`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**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
|
- **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 downgrade -1
|
||||||
.venv/Scripts/python.exe main.py db current
|
.venv/Scripts/python.exe main.py db current
|
||||||
|
|
||||||
# 发用户
|
# 发用户(两条路径,任选其一)
|
||||||
|
# a) CLI:
|
||||||
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
|
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
|
||||||
# → [ok] user added email=alice@example.com user_id=<uuid>
|
# → [ok] user added email=alice@example.com user_id=<uuid>
|
||||||
|
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
|
||||||
|
# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。
|
||||||
|
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
|
||||||
|
|
||||||
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
|
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
|
||||||
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
|
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
|
||||||
|
|
@ -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 |
|
| `/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 ... uq_users_email` | 邮箱已存在,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks) |
|
||||||
| `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
|
| `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=<bcrypt>` 同理 |
|
| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理 |
|
||||||
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
|
| `/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 |
|
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
|
||||||
|
|
|
||||||
28
main.py
28
main.py
|
|
@ -156,34 +156,22 @@ def user_add(email: str, password: str, user_id: str) -> None:
|
||||||
email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接
|
email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接
|
||||||
`DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。
|
`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()
|
from web.auth import UserCreateError, create_user
|
||||||
if not e or "@" not in e:
|
|
||||||
click.echo(f"[err] email 不合法: {email!r}", err=True)
|
uid_arg = None
|
||||||
sys.exit(2)
|
|
||||||
if len(password) < 6:
|
|
||||||
click.echo("[err] password 至少 6 字符", err=True)
|
|
||||||
sys.exit(2)
|
|
||||||
if user_id:
|
if user_id:
|
||||||
try:
|
try:
|
||||||
uid = _UUID(user_id)
|
uid_arg = _UUID(user_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True)
|
click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True)
|
||||||
sys.exit(2)
|
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:
|
try:
|
||||||
with session_scope() as s:
|
uid, e = create_user(email=email, password=password, user_id=uid_arg)
|
||||||
s.add(User(user_id=uid, email=e, password_hash=hash_password(password)))
|
except UserCreateError as ex:
|
||||||
except Exception as ex:
|
click.echo(f"[err] {ex.message}", err=True)
|
||||||
# IntegrityError(email UNIQUE / user_id PK 撞)等都走这条
|
|
||||||
click.echo(f"[err] INSERT 失败: {type(ex).__name__}: {ex}", err=True)
|
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
click.echo(f"[ok] user added email={e} user_id={uid}")
|
click.echo(f"[ok] user added email={e} user_id={uid}")
|
||||||
|
|
||||||
|
|
|
||||||
42
web/app.py
42
web/app.py
|
|
@ -38,7 +38,15 @@ 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_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 .broker import broker
|
||||||
from .sinks import WebEventSink
|
from .sinks import WebEventSink
|
||||||
|
|
||||||
|
|
@ -416,6 +424,12 @@ class PasswordLoginRequest(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCreateUserRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
admin_token: str
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── App 工厂 ──────────────────────
|
# ────────────────────── App 工厂 ──────────────────────
|
||||||
|
|
||||||
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
||||||
|
|
@ -567,6 +581,32 @@ def create_app() -> FastAPI:
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"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"])
|
@app.post("/v1/auth/login_password", tags=["auth"])
|
||||||
def login_password(body: PasswordLoginRequest):
|
def login_password(body: PasswordLoginRequest):
|
||||||
"""邮箱密码登录(dev SPA 给同事 / 自己试用)。
|
"""邮箱密码登录(dev SPA 给同事 / 自己试用)。
|
||||||
|
|
|
||||||
63
web/auth.py
63
web/auth.py
|
|
@ -39,10 +39,20 @@ _DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d
|
||||||
class AuthConfig:
|
class AuthConfig:
|
||||||
"""App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。"""
|
"""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.platform_key = platform_key
|
||||||
self.jwt_secret = jwt_secret
|
self.jwt_secret = jwt_secret
|
||||||
self.ttl_seconds = ttl_seconds
|
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
|
@classmethod
|
||||||
def from_env(cls) -> "AuthConfig":
|
def from_env(cls) -> "AuthConfig":
|
||||||
|
|
@ -68,7 +78,10 @@ class AuthConfig:
|
||||||
)
|
)
|
||||||
if ttl <= 0:
|
if ttl <= 0:
|
||||||
raise RuntimeError(f"ZCBOT_JWT_TTL_SECONDS must be > 0, got {ttl}")
|
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:
|
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}")
|
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:
|
def ensure_user_row(user_id: UUID) -> None:
|
||||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
||||||
|
|
||||||
|
|
@ -177,6 +234,8 @@ def make_require_user(cfg: AuthConfig):
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthConfig",
|
"AuthConfig",
|
||||||
|
"UserCreateError",
|
||||||
|
"create_user",
|
||||||
"ensure_user_row",
|
"ensure_user_row",
|
||||||
"hash_password",
|
"hash_password",
|
||||||
"make_require_user",
|
"make_require_user",
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,44 @@
|
||||||
background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
|
background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
|
||||||
font-size: 11.5px;
|
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 ───── */
|
/* ───── 3-pane layout ───── */
|
||||||
#app { display: none; height: 100vh; }
|
#app { display: none; height: 100vh; }
|
||||||
|
|
@ -604,6 +642,27 @@
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" id="li-go">登录</button>
|
<button class="primary" id="li-go">登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a href="#" id="open-admin-add" class="ghost-link">+ 管理员添加用户</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ───── admin add-user modal ───── -->
|
||||||
|
<div id="admin-modal">
|
||||||
|
<div class="card">
|
||||||
|
<h3>添加用户</h3>
|
||||||
|
<label for="ad-email">邮箱</label>
|
||||||
|
<input id="ad-email" type="email" autocomplete="off" placeholder="new@example.com" />
|
||||||
|
<label for="ad-password">密码</label>
|
||||||
|
<input id="ad-password" type="password" autocomplete="new-password" placeholder="≥ 6 字符" />
|
||||||
|
<label for="ad-token">管理员口令</label>
|
||||||
|
<input id="ad-token" type="password" autocomplete="off" placeholder="$ZCBOT_ADMIN_TOKEN env 值" />
|
||||||
|
<div class="err" id="ad-err"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="ad-cancel">取消</button>
|
||||||
|
<button class="primary" id="ad-go">创建</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1036,6 +1095,68 @@ function logout() {
|
||||||
}
|
}
|
||||||
$("hd-logout").onclick = 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 折叠 toggle(rail 模式 + localStorage 持久化) ─────
|
||||||
// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向
|
// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向
|
||||||
function applyLeftCollapsed(collapsed) {
|
function applyLeftCollapsed(collapsed) {
|
||||||
|
|
@ -1331,7 +1452,6 @@ function renderChatMeta() {
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
${renderModelDropdown(t)}
|
${renderModelDropdown(t)}
|
||||||
${renderImageModelDropdown()}
|
${renderImageModelDropdown()}
|
||||||
<span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span>
|
|
||||||
`;
|
`;
|
||||||
const sel = $("chat-model-sel");
|
const sel = $("chat-model-sel");
|
||||||
if (sel) sel.onchange = onChangeModel;
|
if (sel) sel.onchange = onChangeModel;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue