feat: 新增用户管理(用户增删改查、密码重置、角色权限、会话认证)与 API 指南

- 新增 app/api/deps.py、app/core/users.py、app/core/sessions.py:会话鉴权依赖、
  用户存储(PBKDF2-HMAC-SHA256 + 随机 salt,Redis/内存降级)、会话签发与校验(TTL 12h)
- auth.py 新增用户管理端点(列表/创建/重置密码/删除)与 admin/user 角色权限边界,
  user 访问用户管理返回 1006,禁删自己与最后一个 admin
- admin.html 新增用户管理面板(仅 admin 挂载)与 API 指南在线测试台
- Dockerfile 将 uv 放入 PATH;docker-compose 调整 qdrant 依赖为 service_started
  并移除依赖 curl 的 healthcheck(官方镜像不含 curl)
- 新增用户管理测试(users/sessions/auth_api/auth_integration),全量 461 项测试通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
2026-07-31 21:29:02 +08:00
parent 2ab8b56a01
commit 92b062c048
24 changed files with 2771 additions and 350 deletions
+223 -3
View File
@@ -4,8 +4,15 @@
1. 路由存在性:200 + content-type 为 text/html
2. 五区块可识别标记(文案与 section id)
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
4. fetch 调用路径与后端 API 契约一致
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*
5. 删除操作的 confirm() 二次确认逻辑
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
9. 用户管理区块:列表/创建表单/角色下拉/重置/删除 confirm、admin 角色门禁
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
"""
import re
@@ -68,12 +75,17 @@ def test_admin_page_no_external_resources(admin_html: str) -> None:
def test_admin_page_fetch_paths(admin_html: str) -> None:
"""fetch 调用路径与后端 API 契约一致"""
"""fetch 调用路径与后端 API 契约一致(含认证相关端点)"""
for path in (
"/api/v1/search",
"/api/v1/documents",
"/api/v1/knowledge/stats",
"/api/v1/knowledge/categories",
"/api/v1/auth/login",
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password",
"/api/v1/auth/users",
):
assert path in admin_html
@@ -92,9 +104,17 @@ def _collect_route_paths(routes: list) -> set[str]:
def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
"""页面 api() 调用路径均在真实后端路由集合内(含新增的 tasks 路径)"""
"""页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)"""
route_paths = _collect_route_paths(app.routes)
assert "/api/v1/documents/tasks/{task_id}" in route_paths
for auth_path in (
"/api/v1/auth/login",
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password",
"/api/v1/auth/users",
):
assert auth_path in route_paths
prefixes = set(re.findall(r'api\("([^"?]+)', admin_html))
assert prefixes, "页面应包含 api() 调用"
@@ -123,3 +143,203 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
def test_admin_page_has_confirm(admin_html: str) -> None:
"""删除操作包含 confirm() 二次确认逻辑"""
assert "confirm(" in admin_html
def test_admin_page_login_card(admin_html: str) -> None:
"""登录门禁:登录卡片、localStorage key、/auth/me 验证与 /auth/login 路径"""
assert 'id="login-overlay"' in admin_html
assert 'id="login-form"' in admin_html
assert 'id="login-username"' in admin_html
assert 'id="login-password"' in admin_html
assert "登录" in admin_html
# token 与用户信息持久化 key
assert "qmd_token" in admin_html
assert "qmd_user" in admin_html
assert "localStorage" in admin_html
# 页面加载时经 /auth/me 验证登录态
assert "/api/v1/auth/me" in admin_html
# 登录请求路径
assert "/api/v1/auth/login" in admin_html
def test_admin_page_user_area(admin_html: str) -> None:
"""顶栏用户区:用户名、角色徽章、修改密码与退出登录按钮"""
assert 'id="user-area"' in admin_html
assert 'id="user-name"' in admin_html
assert 'id="user-role"' in admin_html
assert "role-badge" in admin_html
assert 'id="btn-change-password"' in admin_html
assert "修改密码" in admin_html
assert 'id="btn-logout"' in admin_html
assert "退出登录" in admin_html
# 退出登录调用后端 logout 端点并清理 localStorage
assert "/api/v1/auth/logout" in admin_html
assert "clearAuth(" in admin_html
def test_admin_page_password_form(admin_html: str) -> None:
"""修改密码:旧/新/确认表单、改密端点与 must_change_password 强制改密层"""
assert 'id="password-overlay"' in admin_html
assert 'id="password-form"' in admin_html
assert 'id="password-old"' in admin_html
assert 'id="password-new"' in admin_html
assert 'id="password-confirm"' in admin_html
assert "/api/v1/auth/password" in admin_html
# must_change_password 登录后强制改密:提示语 + 取消按钮在强制模式隐藏
assert "must_change_password" in admin_html
assert "首次登录须" in admin_html
assert 'id="password-forced-tip"' in admin_html
assert 'id="btn-password-cancel"' in admin_html
assert "passwordForced" in admin_html
def test_admin_page_users_section(admin_html: str) -> None:
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码/删除 confirm/角色门禁"""
assert 'id="section-users"' in admin_html
assert 'id="users-tbody"' in admin_html
# 表头列:用户名/角色/须改密/创建时间/操作
for col in ("用户名", "角色", "须改密", "创建时间", "操作"):
assert col in admin_html
# 创建用户表单与角色下拉
assert 'id="user-create-form"' in admin_html
assert 'id="user-new-name"' in admin_html
assert 'id="user-new-password"' in admin_html
assert 'id="user-new-role"' in admin_html
assert '<option value="admin"' in admin_html
assert '<option value="user"' in admin_html
assert "创建用户" in admin_html
# 操作列:重置密码(弹输入)与删除(confirm 二次确认)
assert "重置密码" in admin_html
assert "prompt(" in admin_html
assert "确定删除用户" in admin_html
# 用户管理端点
assert "/api/v1/auth/users" in admin_html
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
assert 'role === "admin"' in admin_html
assert "mountUsersSection" in admin_html
assert "unmountUsersSection" in admin_html
def test_admin_page_auth_interceptor(admin_html: str) -> None:
"""请求拦截:统一注入 Authorization Bearer1005 回登录;1006 错误条提示"""
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
# 1005 未认证/凭证无效 → 清 localStorage 并回登录卡片
assert "body.code === 1005" in admin_html
assert "clearAuth();" in admin_html
assert "showLogin();" in admin_html
# 1006 权限不足 → 错误码透传给错误条
assert "1006" in admin_html
def test_admin_page_api_guide_section(admin_html: str) -> None:
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
assert 'data-target="section-api-guide"' in admin_html
assert 'id="section-api-guide"' in admin_html
assert 'id="api-guide-list"' in admin_html
assert "API 指南" in admin_html
# 静态维护的端点清单
assert "API_GUIDE" in admin_html
# auth 中文标注:免登录 / 需登录 / 仅 admin
for label in ("免登录", "需登录", "仅 admin"):
assert label in admin_html
# 方法颜色徽章
for cls in ("method-get", "method-post", "method-delete"):
assert cls in admin_html
def test_admin_page_api_guide_try_panel(admin_html: str) -> None:
"""试一下面板:发送按钮/状态码/耗时/格式化 JSON 展示/curl 复制/upload 文件选择"""
assert "试一下" in admin_html
assert "try-panel" in admin_html
assert "try-toggle" in admin_html
# 发送按钮与结果区(状态码/耗时/pre 格式化展示)
assert "try-send" in admin_html
assert "发送" in admin_html
assert "状态码" in admin_html
assert "耗时" in admin_html
assert "try-result-pre" in admin_html
assert "JSON.stringify(JSON.parse(text), null, 2)" in admin_html
# 需鉴权端点自动带当前 token
assert 'ep.auth !== "none"' in admin_html
# body 非 JSON 本地报错不发请求
assert "未发送请求" in admin_html
# curl 示例与复制(clipboard + 降级 textarea 选中)
assert "curl 示例" in admin_html
assert "curl-copy" in admin_html
assert "复制" in admin_html
assert "navigator.clipboard" in admin_html
assert "execCommand" in admin_html
# base URL 用 location.origin 动态拼接
assert "location.origin" in admin_html
# upload 端点:file input + FormData(不带 Content-Type 头)
assert "try-file" in admin_html
assert 'fileInput.type = "file"' in admin_html
assert "FormData" in admin_html
def test_admin_page_api_guide_no_custom_url(admin_html: str) -> None:
"""安全约束:只能从清单选择端点,页面无自定义任意 URL 输入"""
assert "api-url" not in admin_html
assert "自定义 URL" not in admin_html.replace("不支持自定义 URL", "")
# 试一下面板只能从清单展开:端点 URL 由清单定义拼接,无自由输入
assert "不提供任意 URL 输入框" in admin_html
_API_GUIDE_ENTRY_RE = re.compile(r'method:\s*"(\w+)",\s*path:\s*"([^"]+)"')
def _extract_api_guide_entries(admin_html: str) -> list[tuple[str, str]]:
"""从页面 JS 提取 API_GUIDE 静态清单中的 (method, path) 列表"""
match = re.search(r"API_GUIDE\s*=\s*\[(.*?)\n\];", admin_html, re.S)
assert match, "页面应包含 API_GUIDE 静态清单"
return _API_GUIDE_ENTRY_RE.findall(match.group(1))
def _collect_route_methods(routes: list) -> set[tuple[str, str]]:
"""递归收集路由 (method, path) 对(FastAPI 0.140+ include_router 包装为 _IncludedRouter"""
pairs: set[tuple[str, str]] = set()
for route in routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None)
if path and methods:
for method in methods:
pairs.add((method, path))
sub_router = getattr(route, "original_router", None)
if sub_router is not None:
pairs |= _collect_route_methods(sub_router.routes)
return pairs
def test_admin_page_api_guide_paths_in_real_routes(admin_html: str) -> None:
"""API_GUIDE 每个 method+path 均在真实后端路由集合内,且全部真实端点被覆盖(防漂移)"""
entries = _extract_api_guide_entries(admin_html)
assert entries, "API_GUIDE 应至少包含一个端点"
route_methods = _collect_route_methods(app.routes)
for method, path in entries:
assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内"
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
expected = {
("GET", "/api/v1/health"),
("POST", "/api/v1/search"),
("POST", "/api/v1/documents"),
("POST", "/api/v1/documents/upload"),
("GET", "/api/v1/documents/tasks/{task_id}"),
("GET", "/api/v1/documents"),
("GET", "/api/v1/documents/{doc_id}"),
("DELETE", "/api/v1/documents/{doc_id}"),
("GET", "/api/v1/knowledge/categories"),
("GET", "/api/v1/knowledge/stats"),
("POST", "/api/v1/auth/login"),
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/password"),
("GET", "/api/v1/auth/me"),
("GET", "/api/v1/auth/users"),
("POST", "/api/v1/auth/users"),
("POST", "/api/v1/auth/users/{username}/password"),
("DELETE", "/api/v1/auth/users/{username}"),
}
assert expected <= route_methods, f"后端缺少预期端点: {sorted(expected - route_methods)}"
missing = expected - set(entries)
assert not missing, f"API_GUIDE 缺少端点: {sorted(missing)}"