Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming f17da6a6e1 feat(auth): 平台登录注入 name/user_name + 监控页/dev 顶栏用户名展示 + bump 0.26.1
平台登录档案注入(0.26.0):
- users 加 name/user_name 两列(migration 0016,纯加 nullable 列,平滑兼容存量行)
- /v1/auth/login body 可选收 name/user_name,ensure_user_row 升级为 upsert
  (COALESCE(EXCLUDED, 旧值):平台传非空就刷新、传 null 不覆盖清空)
- login / login_password / /v1/me 响应回带 name/user_name/role

用户名展示(0.26.1):
- 统一兜底链 name → user_name → email → uid8,监控页与 dev 页共用
- 监控页 admin.js:各用户用量 / 存储 / overview 迷你表用户列走 userCellHTML,
  name+user_name 都有时主显 name + 浅灰 user_name;title 悬浮完整身份。
  admin.py 两表 SELECT 补 User.name/user_name
- dev 顶栏 main.js renderWho:默认显 name,hover 显账号/邮箱/ID;
  state.js 加 userUserName/userEmail + setIdentity/userDisplayName/userDisplayTitle helper,
  登录 / embed / /v1/me 校准共用

注:migration 0016 需在目标环境 `main.py db upgrade head` 应用后生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:31:32 +08:00
caoqianming 2b2b4531b3 fix(web): 登录失败提示统一为「账号或密码错误」,不再回显原始状态码 + bump 0.25.2
输错密码时前端弹「404」:后端 login_password 实际返 403,前置网关/旧构建
把状态改写成 404 后,doLogin 直接回显 r.status 导致语义错误。

- auth.js doLogin 失败分支:表单已校验非空,非 2xx 绝大多数是凭据不对,
  统一给「账号或密码错误」(pw)/「user_id 或 PLATFORM_KEY 错误」(key);
  仅 5xx 暴露状态码提示服务端问题。
- app.py:1399 detail 同步改中文,保持契约自洽。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:08:00 +08:00
13 changed files with 244 additions and 56 deletions

View File

@ -306,10 +306,10 @@ done {}
### 7.3 认证 ### 7.3 认证
**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): **当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL):
- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入) - `POST /v1/auth/login {user_id, platform_key, name?, user_name?}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)。body 可选带 `name`(显示名)/ `user_name`(平台账号名),`ensure_user_row` upsert 落 `users.name/user_name`(`COALESCE(EXCLUDED, 旧值)`:平台传非空就刷新、同步平台侧改名,传 null 不覆盖);响应回带 `{name, user_name, role}`。缺省即旧行为(只填 user_id),向后兼容老调用方。与未来 OIDC 的 `name/preferred_username` claim 注入同构
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户 - `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403) - `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
- `GET /v1/me` — 返 `{user_id, role}`(role 走 DB 查),dev SPA 据此决定显不显"管理"入口 - `GET /v1/me` — 返 `{user_id, role, name, user_name, email}`(走 DB 查),dev SPA 据 role 决定显不显"管理"入口,据 name/user_name/email 渲顶栏用户名(默认 name,hover 显账号 / 邮箱)。两条 login 响应同样回带 name/user_name(平滑展示,登录即有名,/v1/me 再校准)
- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作 - `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
@ -322,6 +322,9 @@ done {}
```sql ```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名);
-- platform_key 入口 ensure_user_row upsert 写,
-- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台 role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
created_at) created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存 -- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存

View File

@ -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-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1) 最后更新:2026-06-25(用户名展示:监控页用户列 + dev 顶栏走 name→user_name→email→uid8 兜底链,hover 显完整身份 + bump 0.26.1)
--- ---
@ -21,6 +21,21 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-25 / 用户名展示:监控页 + dev 顶栏(bump 0.26.1)
- 统一一条兜底链 `name → user_name → email → uid8`,监控页与 dev 页共用。
- 监控页(`admin.js`):各用户用量 / 存储两表 + overview 迷你表的用户列改走 `userCellHTML`/`userLabelText`,name 与 user_name 都有时主显 name + 浅灰 user_name;`title` 悬浮给完整姓名/账号/邮箱/ID。后端 `admin.py` 两张表 SELECT 补 `User.name/user_name` 回带。
- dev 顶栏(`main.js` `renderWho`):默认显 name,hover(title)显账号/邮箱/ID。`state.js` 加 `userUserName/userEmail` + LS 持久化,抽 `setIdentity`/`userDisplayName`/`userDisplayTitle` 三个 helper,登录(`auth.js`)、embed 签发(`embed.js`)、`/v1/me` 校准(`loadRole`)共用;`login_password` 响应也回带 name/user_name 避免展示闪烁。
### 2026-06-25 / 平台登录注入用户档案 name/user_name(bump 0.26.0)
- 需求:平台作为可信中间层登录时,把用户 `name`(显示名)/ `user_name`(平台账号名)一并注入 zcbot 持久化,供前端展示。
- 实现:`users` 加两列(migration `0016`,纯加 nullable 列,平滑兼容存量行);`LoginRequest` 加可选 `name/user_name`,缺省即旧行为(向后兼容老调用方);`ensure_user_row` 升级为 upsert,`ON CONFLICT DO UPDATE SET x = COALESCE(EXCLUDED.x, users.x)` —— 平台传非空就刷新(同步平台侧改名),传 null/空不覆盖清空,空串归一到 None。
- 暴露:`/v1/auth/login` 响应 + `/v1/me` 回带 `{name, user_name, role}`(新增 `get_user_profile` 单次 SELECT)。机制选 platform 在 login body 推送(零额外往返,与未来 OIDC 的 name/preferred_username claim 注入同构),未选 zcbot 反向拉平台 API。
- 待办:migration `0016` 需在配好 `ZCBOT_DB_URL` 的环境跑 `.venv/Scripts/python.exe main.py db upgrade head` 应用;前端可消费 `/v1/me` 的 name 显示用户名。
### 2026-06-25 / 登录失败提示修正(bump 0.25.2)
- 问题:邮箱密码输错时前端弹「404」(后端 `login_password` 实际返 403「invalid email or password」,前置网关/旧构建把状态改写成 404 后,前端 `doLogin` 直接回显 `r.status + " login failed"` → 用户看到「404 login failed」,语义错误)。
- 修:`web/static/js/auth.js` `doLogin` 失败分支不再回显原始状态码 —— 表单已校验非空,非 2xx 绝大多数是凭据不对,统一给「账号或密码错误」(pw tab)/「user_id 或 PLATFORM_KEY 错误」(key tab);仅 5xx 暴露状态码提示服务端问题。后端 `web/app.py:1399` detail 同步改中文「账号或密码错误」保持契约自洽。
### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1) ### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1)
- 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。 - 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。
- 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。 - 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.25.1" __version__ = "0.26.1"

View File

@ -46,6 +46,11 @@ class User(Base):
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)
# 0016:平台登录注入的用户档案。name=显示名/姓名,user_name=平台账号名;均 nullable
# (platform_key 入口 ensure_user_row upsert 写;邮箱密码 / 历史行留空)。未来 OIDC
# 接管时由 ID token 的 name / preferred_username claim 注入,数据流不变。
name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
user_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。 # 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。
# 提管理员:main.py user role --email X --role admin。 # 提管理员:main.py user role --email X --role admin。
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user") role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")

View File

@ -0,0 +1,33 @@
"""users.name / users.user_name 列(平台登录注入的用户档案).
Revision ID: 0016
Revises: 0015
Create Date: 2026-06-25
users 加两列:name(显示名/姓名)+ user_name(平台账号名), nullable
平台经 /v1/auth/login(platform_key 形态) body 里注入,ensure_user_row upsert
落库;邮箱密码 / 历史行留空将来 OIDC 接管时由 ID token name / preferred_username
claim 注入,数据流不变 DESIGN §7.3 / §7.4
纯加列不动现有数据(平滑兼容线上存量行, NULL)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("name", sa.Text(), nullable=True))
op.add_column("users", sa.Column("user_name", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "user_name")
op.drop_column("users", "name")

View File

@ -203,6 +203,8 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
{ {
"user_id": str(uid), "user_id": str(uid),
"email": email or "", "email": email or "",
"name": name or "",
"user_name": uname or "",
"role": role or "user", "role": role or "user",
"cost_cny": float(c or 0), "cost_cny": float(c or 0),
"tokens_in": int(ti or 0), "tokens_in": int(ti or 0),
@ -210,15 +212,15 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
"tokens_cache_hit": int(h or 0), "tokens_cache_hit": int(h or 0),
"n_events": int(n or 0), "n_events": int(n or 0),
} }
for uid, email, role, c, ti, to, h, n in s.execute( for uid, email, name, uname, role, c, ti, to, h, n in s.execute(
select( select(
User.user_id, User.email, User.role, User.user_id, User.email, User.name, User.user_name, User.role,
cost_sum, tin_sum, tout_sum, cost_sum, tin_sum, tout_sum,
func.coalesce(func.sum(hit).filter(chat), 0), func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id), func.count(UsageEvent.event_id),
) )
.join(UsageEvent, join_cond, isouter=True) .join(UsageEvent, join_cond, isouter=True)
.group_by(User.user_id, User.email, User.role) .group_by(User.user_id, User.email, User.name, User.user_name, User.role)
.order_by(order, User.user_id) .order_by(order, User.user_id)
.limit(page_size) .limit(page_size)
.offset(page * page_size) .offset(page * page_size)
@ -241,14 +243,18 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
{ {
"user_id": str(uid), "user_id": str(uid),
"email": email or "", "email": email or "",
"name": name or "",
"user_name": uname or "",
"bytes_used": int(b or 0), "bytes_used": int(b or 0),
"file_count": int(fc or 0), "file_count": int(fc or 0),
"scanned_at": scanned.isoformat() if scanned else None, "scanned_at": scanned.isoformat() if scanned else None,
} }
for uid, email, b, fc, scanned in s.execute( for uid, email, name, uname, b, fc, scanned in s.execute(
select( select(
UserDiskUsage.user_id, UserDiskUsage.user_id,
User.email, User.email,
User.name,
User.user_name,
UserDiskUsage.bytes_used, UserDiskUsage.bytes_used,
UserDiskUsage.file_count, UserDiskUsage.file_count,
UserDiskUsage.scanned_at, UserDiskUsage.scanned_at,

View File

@ -51,7 +51,7 @@ from .auth import (
change_password, change_password,
create_user, create_user,
ensure_user_row, ensure_user_row,
get_user_role, get_user_profile,
make_require_admin, make_require_admin,
make_require_user, make_require_user,
mint_token, mint_token,
@ -554,6 +554,9 @@ class FileTransferRequest(BaseModel):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
user_id: str user_id: str
platform_key: str platform_key: str
# 0016:平台可选注入的用户档案,缺省即旧行为(只填 user_id),向后兼容老调用方。
name: Optional[str] = None # 显示名 / 姓名
user_name: Optional[str] = None # 平台账号名
class PasswordLoginRequest(BaseModel): class PasswordLoginRequest(BaseModel):
@ -1118,8 +1121,16 @@ def create_app() -> FastAPI:
前端 dev SPA localStorage 里的 token 恢复会话时调一次, role=='admin' 前端 dev SPA localStorage 里的 token 恢复会话时调一次, role=='admin'
决定显不显"管理"入口(/static/admin.html)role DB ,改完即时生效 决定显不显"管理"入口(/static/admin.html)role DB ,改完即时生效
name / user_name 是平台登录注入的档案(0016),可能为 null(邮箱密码 / 历史行)
""" """
return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"} prof = get_user_profile(user_id) or {}
return {
"user_id": str(user_id),
"role": prof.get("role", "user"),
"name": prof.get("name"),
"user_name": prof.get("user_name"),
"email": prof.get("email"),
}
# ───────────── 微信接入(ClawBot,§8.7)───────────── # ───────────── 微信接入(ClawBot,§8.7)─────────────
@ -1338,7 +1349,8 @@ def create_app() -> FastAPI:
"""platform_key 校验通过 → 签 JWT(user_id 作为 sub)。 """platform_key 校验通过 → 签 JWT(user_id 作为 sub)。
platform_key 403;user_id UUID 400 platform_key 403;user_id UUID 400
user_id 未存在则幂等创建 users (避免下游 FK 失败) user_id 未存在则幂等创建 users (避免下游 FK 失败);body name / user_name
时一并 upsert 落库(平台侧改名每次登录自动同步, ensure_user_row)
platform 服务端用此入口注入指定 user_id;dev SPA /login_password platform 服务端用此入口注入指定 user_id;dev SPA /login_password
""" """
if body.platform_key != auth_cfg.platform_key: if body.platform_key != auth_cfg.platform_key:
@ -1347,12 +1359,16 @@ def create_app() -> FastAPI:
uid = UUID(body.user_id) uid = UUID(body.user_id)
except (ValueError, TypeError): except (ValueError, TypeError):
raise HTTPException(400, f"invalid user_id (must be UUID): {body.user_id!r}") raise HTTPException(400, f"invalid user_id (must be UUID): {body.user_id!r}")
ensure_user_row(uid) ensure_user_row(uid, name=body.name, user_name=body.user_name)
token, exp = mint_token(auth_cfg, uid) token, exp = mint_token(auth_cfg, uid)
prof = get_user_profile(uid) or {}
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": prof.get("name"),
"user_name": prof.get("user_name"),
"role": prof.get("role", "user"),
"ttl_seconds": auth_cfg.ttl_seconds, "ttl_seconds": auth_cfg.ttl_seconds,
} }
@ -1396,15 +1412,18 @@ def create_app() -> FastAPI:
""" """
hit = resolve_user_by_email(body.email, body.password) hit = resolve_user_by_email(body.email, body.password)
if hit is None: if hit is None:
raise HTTPException(403, "invalid email or password") raise HTTPException(403, "账号或密码错误")
uid, email = hit uid, email = hit
token, exp = mint_token(auth_cfg, uid) token, exp = mint_token(auth_cfg, uid)
prof = get_user_profile(uid) or {}
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),
"email": email, "email": email,
"role": get_user_role(uid) or "user", "name": prof.get("name"),
"user_name": prof.get("user_name"),
"role": prof.get("role", "user"),
"ttl_seconds": auth_cfg.ttl_seconds, "ttl_seconds": auth_cfg.ttl_seconds,
} }

View File

@ -27,7 +27,7 @@ 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 func, select
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import User from core.storage.models import User
@ -232,6 +232,28 @@ def get_user_role(user_id: UUID) -> Optional[str]:
return row[0] if row is not None else None return row[0] if row is not None else None
def get_user_profile(user_id: UUID) -> Optional[dict]:
"""查当前用户档案 → `{role, name, user_name, email}`;无对应行返 None。
/v1/me + login 响应用,单次 SELECTname / user_name 是平台登录注入的(0016),
可能为 None(邮箱密码 / 历史行)
"""
with session_scope() as s:
row = s.execute(
select(User.role, User.name, User.user_name, User.email).where(
User.user_id == user_id
)
).first()
if row is None:
return None
return {
"role": row.role or "user",
"name": row.name,
"user_name": row.user_name,
"email": row.email,
}
def set_user_role(email: str, role: str) -> tuple[UUID, str]: def set_user_role(email: str, role: str) -> tuple[UUID, str]:
"""按 email 改 users.role。返 `(user_id, normalized_email)`。 """按 email 改 users.role。返 `(user_id, normalized_email)`。
@ -252,15 +274,32 @@ def set_user_role(email: str, role: str) -> tuple[UUID, str]:
return user.user_id, e return user.user_id, e
def ensure_user_row(user_id: UUID) -> None: def ensure_user_row(
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 user_id: UUID,
name: Optional[str] = None,
user_name: Optional[str] = None,
) -> None:
"""幂等 upsert 一行 users 占位,并同步平台注入的用户档案(name / user_name)。
platform_key 登录入口用 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行 platform_key 登录入口用 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行
避免下游 FK 失败邮箱密码登录走 `main.py user add` 已经写好 users ,不走这条 避免下游 FK 失败邮箱密码登录走 `main.py user add` 已经写好 users ,不走这条
name / user_name 由平台在 login body 注入(0016):
- 首次建行:有值就一并写入
- 已存在行:`COALESCE(EXCLUDED.x, users.x)` 平台传了非空值就刷新(同步平台侧改名),
None / 空则保留旧值,不会被覆盖清空空串归一到 None,不当有效值落库
""" """
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing(
index_elements=["user_id"] nm = (name or "").strip() or None
un = (user_name or "").strip() or None
stmt = insert(User).values(user_id=user_id, name=nm, user_name=un)
stmt = stmt.on_conflict_do_update(
index_elements=["user_id"],
set_={
"name": func.coalesce(stmt.excluded.name, User.name),
"user_name": func.coalesce(stmt.excluded.user_name, User.user_name),
},
) )
with session_scope() as s: with session_scope() as s:
s.execute(stmt) s.execute(stmt)
@ -321,6 +360,7 @@ __all__ = [
"change_password", "change_password",
"create_user", "create_user",
"ensure_user_row", "ensure_user_row",
"get_user_profile",
"get_user_role", "get_user_role",
"hash_password", "hash_password",
"make_require_admin", "make_require_admin",

View File

@ -19,6 +19,29 @@ const SECTIONS = [
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || ""; const token = () => localStorage.getItem(LS_TOKEN) || "";
// 用户显示名兜底链:name → user_name → email → uid8。监控页各处共用同一规则。
// userCellHTML 给表格单元格:主文本走兜底链;name 与 user_name 都有时,name 后跟一个
// 浅灰 user_name;title 悬浮给完整 name/账号/邮箱/user_id。userLabelText 给概览迷你表(纯文本)。
function userLabelText(r) {
return r.name || r.user_name || r.email || (r.user_id || "").slice(0, 8);
}
function userTitle(r) {
const parts = [];
if (r.name) parts.push(`姓名 ${r.name}`);
if (r.user_name) parts.push(`账号 ${r.user_name}`);
if (r.email) parts.push(`邮箱 ${r.email}`);
parts.push(`ID ${r.user_id}`);
return parts.join("\n");
}
function userCellHTML(r) {
const primary = escapeHtml(userLabelText(r));
// name 与 user_name 同时存在 → 主显 name,后缀浅灰 user_name(满足"name 和 user_name 都显")
const sub = (r.name && r.user_name)
? ` <span style="color:var(--muted);font-size:.85em;">${escapeHtml(r.user_name)}</span>`
: "";
return `${primary}${sub}`;
}
let timer = null; let timer = null;
// 各表独立状态(不随 overview 轮询重置) // 各表独立状态(不随 overview 轮询重置)
let modelRange = "all", modelSort = "cost"; let modelRange = "all", modelSort = "cost";
@ -156,7 +179,7 @@ function renderUserUsage(d) {
const body = rows.map(r => { const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0; const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return `<tr>` return `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}` + `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>` + (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>` + `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>` + `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
@ -198,7 +221,7 @@ function renderStorage(d) {
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "") : cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
: tint(r.bytes_used, maxUsed); : tint(r.bytes_used, maxUsed);
return `<tr>` return `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>` + `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}</td>`
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>` + `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
+ `<td class="num">${pctTxt}</td>` + `<td class="num">${pctTxt}</td>`
+ `<td class="num">${r.file_count || 0}</td>` + `<td class="num">${r.file_count || 0}</td>`
@ -371,13 +394,13 @@ function buildReport(ov, models, users, storage, health) {
const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>` const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>` + `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`; + `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>` const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(userLabelText(r))}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>` + `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`; + `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const quota = storage.quota_bytes; const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => { const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—"; const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td><td>${humanSize(r.bytes_used)}</td>` return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`; + `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
}).join("") || `<tr><td colspan="4">无数据</td></tr>`; }).join("") || `<tr><td colspan="4">无数据</td></tr>`;

View File

@ -3,7 +3,7 @@
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。 // (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting // 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
// (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。 // (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。
import { state, LS_TOKEN, LS_UID, LS_NAME, EMBED } from "./state.js"; import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, setIdentity } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { enterApp } from "./main.js"; import { enterApp } from "./main.js";
@ -40,7 +40,7 @@ document.querySelectorAll("#login input").forEach(i => {
async function doLogin() { async function doLogin() {
$("li-err").textContent = ""; $("li-err").textContent = "";
let url, body, displayLabel; let url, body;
if (loginTab === "pw") { if (loginTab === "pw") {
const email = $("li-email").value.trim(); const email = $("li-email").value.trim();
const password = $("li-password").value; const password = $("li-password").value;
@ -50,7 +50,6 @@ async function doLogin() {
} }
url = "/v1/auth/login_password"; url = "/v1/auth/login_password";
body = { email, password }; body = { email, password };
displayLabel = "email";
} else { } else {
const uid = $("li-uid").value.trim(); const uid = $("li-uid").value.trim();
const pkey = $("li-pkey").value; const pkey = $("li-pkey").value;
@ -60,7 +59,6 @@ async function doLogin() {
} }
url = "/v1/auth/login"; url = "/v1/auth/login";
body = { user_id: uid, platform_key: pkey }; body = { user_id: uid, platform_key: pkey };
displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位
} }
try { try {
const r = await fetch(url, { const r = await fetch(url, {
@ -69,19 +67,27 @@ async function doLogin() {
}); });
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " login failed")); // 登录失败:表单已校验非空,非 2xx 绝大多数是凭据不对。
// 凭据类状态(400/401/403/404 —— 404 多半是前置网关把 403 改写了)统一给友好提示;
// 5xx 才暴露状态码,提示是服务端问题而非用户输错。
if (r.status >= 500) {
throw new Error(`服务器错误,请稍后重试(${r.status}`);
}
throw new Error(loginTab === "pw"
? "账号或密码错误"
: "user_id 或 PLATFORM_KEY 错误");
} }
const data = await r.json(); const data = await r.json();
state.token = data.token; state.token = data.token;
state.userId = data.user_id;
state.userName = displayLabel ? (data[displayLabel] || "") : "";
localStorage.setItem(LS_TOKEN, state.token); localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId); // 身份字段写 state + LS:platform_key 路径返 name/user_name,邮箱密码路径返 email;
if (state.userName) { // 缺的走 setIdentity 的兜底(顶栏 userDisplayName)。/v1/me(loadRole)随后再校准一次。
localStorage.setItem(LS_NAME, state.userName); setIdentity({
} else { user_id: data.user_id,
localStorage.removeItem(LS_NAME); name: data.name,
} user_name: data.user_name,
email: data.email,
});
enterApp(); enterApp();
} catch (e) { } catch (e) {
$("li-err").textContent = e.message; $("li-err").textContent = e.message;
@ -89,10 +95,10 @@ async function doLogin() {
} }
export function logout() { export function logout() {
state.token = ""; state.userId = ""; state.userName = ""; state.token = "";
localStorage.removeItem(LS_TOKEN); localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_UID); // 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户
localStorage.removeItem(LS_NAME); setIdentity({ user_id: "" });
if (state.evtSrc) state.evtSrc.close(); if (state.evtSrc) state.evtSrc.close();
if (EMBED) { if (EMBED) {
embedPostToParent({ type: "zcbot-401" }); embedPostToParent({ type: "zcbot-401" });

View File

@ -1,7 +1,7 @@
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。 // embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+ // 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
// embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。 // embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。
import { state, LS_TOKEN, LS_UID, LS_NAME, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID } from "./state.js"; import { state, LS_TOKEN, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { enterApp } from "./main.js"; import { enterApp } from "./main.js";
import { loadTaskList, selectTask } from "./chat.js"; import { loadTaskList, selectTask } from "./chat.js";
@ -32,12 +32,9 @@ function embedHandleMessage(e) {
const d = e.data || {}; const d = e.data || {};
if (d.type === "zcbot-token" && d.token && d.user_id) { if (d.type === "zcbot-token" && d.token && d.user_id) {
state.token = d.token; state.token = d.token;
state.userId = d.user_id;
state.userName = d.user_name || "";
localStorage.setItem(LS_TOKEN, state.token); localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId); // 父页面可带 name/user_name/email;缺的由 enterApp → loadRole 拉 /v1/me 补齐
if (state.userName) localStorage.setItem(LS_NAME, state.userName); setIdentity({ user_id: d.user_id, name: d.name, user_name: d.user_name, email: d.email });
else localStorage.removeItem(LS_NAME);
document.body.classList.remove("embed-waiting"); document.body.classList.remove("embed-waiting");
if ($("app").classList.contains("ready")) { if ($("app").classList.contains("ready")) {
// 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择) // 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择)

View File

@ -1,7 +1,7 @@
// zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、 // zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、
// Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…), // Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…),
// 经各模块顶层 import 拉入依赖图(layout/markdown/media 由 chat 等引入,副作用照常)。 // 经各模块顶层 import 拉入依赖图(layout/markdown/media 由 chat 等引入,副作用照常)。
import { state, EMBED } from "./state.js"; import { state, EMBED, setIdentity, userDisplayName, userDisplayTitle } from "./state.js";
import { humanSize, fmtTime } from "./format.js"; import { humanSize, fmtTime } from "./format.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
@ -20,10 +20,7 @@ import { loadTaskList, loadModels } from "./chat.js";
export function enterApp() { export function enterApp() {
$("login").style.display = "none"; $("login").style.display = "none";
$("app").classList.add("ready"); $("app").classList.add("ready");
// 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份
const uid8 = (state.userId || "").slice(0, 8);
$("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId;
$("hd-who").title = state.userId;
loadTaskList(); loadTaskList();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
@ -32,15 +29,27 @@ export function enterApp() {
loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html) loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html)
} }
// 当前用户角色:/v1/me 返 {user_id, role}。admin → 显顶栏「管理」链接。 // 顶栏用户名:默认显 name(兜底 user_name → email → uid8),title 悬浮给完整身份。
// 失败静默(入口是增量功能,拉不到就当普通用户,不挡主流程)。 function renderWho() {
const el = $("hd-who");
if (!el) return;
el.textContent = userDisplayName();
el.title = userDisplayTitle();
}
// 当前用户身份 + 角色:/v1/me 返 {user_id, role, name, user_name, email}。
// admin → 显顶栏「管理」链接;并用服务端权威值校准顶栏用户名(platform_key 登录 / 老 token
// 升级后,name/user_name 这一刻才到齐)。失败静默(增量功能,拉不到就维持登录时的兜底值)。
async function loadRole() { async function loadRole() {
const link = $("hd-admin"); const link = $("hd-admin");
if (!link) return;
try { try {
const me = await api("GET", "/v1/me"); const me = await api("GET", "/v1/me");
link.style.display = (me && me.role === "admin") ? "" : "none"; if (me) {
} catch (e) { link.style.display = "none"; } setIdentity({ user_id: me.user_id, name: me.name, user_name: me.user_name, email: me.email });
renderWho();
}
if (link) link.style.display = (me && me.role === "admin") ? "" : "none";
} catch (e) { if (link) link.style.display = "none"; }
} }
// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描, // 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描,

View File

@ -4,6 +4,8 @@
export const LS_TOKEN = "zcbot.token"; export const LS_TOKEN = "zcbot.token";
export const LS_UID = "zcbot.user_id"; export const LS_UID = "zcbot.user_id";
export const LS_NAME = "zcbot.name"; export const LS_NAME = "zcbot.name";
export const LS_USERNAME = "zcbot.user_name"; // 平台账号名(hover 展示)
export const LS_EMAIL = "zcbot.email"; // 邮箱(hover 展示)
export const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; export const LS_LEFT_COLLAPSED = "zcbot.left-collapsed";
export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed"; export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed";
export const LS_LEFT_WIDTH = "zcbot.left-width"; export const LS_LEFT_WIDTH = "zcbot.left-width";
@ -20,7 +22,9 @@ export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
export const state = { export const state = {
token: localStorage.getItem(LS_TOKEN) || "", token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "", userId: localStorage.getItem(LS_UID) || "",
userName: localStorage.getItem(LS_NAME) || "", userName: localStorage.getItem(LS_NAME) || "", // name(显示名/姓名)
userUserName: localStorage.getItem(LS_USERNAME) || "", // user_name(平台账号名)
userEmail: localStorage.getItem(LS_EMAIL) || "", // email
taskId: null, taskId: null,
taskMeta: null, taskMeta: null,
filesPath: "", filesPath: "",
@ -62,3 +66,31 @@ export const state = {
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled) // disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
optimizing: false, optimizing: false,
}; };
// 写入当前用户身份到 state + localStorage(登录 / embed 签发 / /v1/me 刷新共用)。
// 缺省字段写空串并清掉对应 LS,避免上一个用户的残留串到下一个。token 单独存,不在这里。
export function setIdentity({ user_id, name, user_name, email } = {}) {
if (user_id !== undefined) {
state.userId = user_id || "";
if (state.userId) localStorage.setItem(LS_UID, state.userId); else localStorage.removeItem(LS_UID);
}
state.userName = name || "";
state.userUserName = user_name || "";
state.userEmail = email || "";
for (const [k, v] of [[LS_NAME, state.userName], [LS_USERNAME, state.userUserName], [LS_EMAIL, state.userEmail]]) {
if (v) localStorage.setItem(k, v); else localStorage.removeItem(k);
}
}
// 顶栏显示名兜底链:name → user_name → email → uid8。hover(title)给完整身份。
export function userDisplayName() {
return state.userName || state.userUserName || state.userEmail || (state.userId || "").slice(0, 8);
}
export function userDisplayTitle() {
const parts = [];
if (state.userName) parts.push(`姓名 ${state.userName}`);
if (state.userUserName) parts.push(`账号 ${state.userUserName}`);
if (state.userEmail) parts.push(`邮箱 ${state.userEmail}`);
if (state.userId) parts.push(`ID ${state.userId}`);
return parts.join("\n");
}