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
+95
View File
@@ -0,0 +1,95 @@
"""API 鉴权依赖:Bearer token → session → 用户记录
UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败时构造传 None,
降级为进程级内存存储(仅开发/兜底,重启失效)。存储后端读写异常统一转 2001。
测试可 monkeypatch _user_store/_session_store 单例完成注入。
"""
import structlog
from fastapi import Depends, Header
from redis import asyncio as redis_async
from app.api.response import ApiError
from app.config import settings
from app.core.sessions import SessionStore, SessionStoreError
from app.core.users import UserRecord, UserStore, UserStoreError
logger = structlog.get_logger()
# 模块级懒加载单例
_user_store: UserStore | None = None
_session_store: SessionStore | None = None
def _create_redis_client() -> redis_async.Redis | None:
"""创建 redis.asyncio 客户端(decode_responses=True);失败返回 None 走内存降级"""
try:
return redis_async.from_url(settings.redis_url, decode_responses=True)
except Exception:
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True)
return None
def _get_user_store() -> UserStore:
"""用户存储懒加载单例"""
global _user_store
if _user_store is None:
_user_store = UserStore(_create_redis_client())
return _user_store
def _get_session_store() -> SessionStore:
"""会话存储懒加载单例"""
global _session_store
if _session_store is None:
_session_store = SessionStore(_create_redis_client())
return _session_store
def _parse_bearer(authorization: str | None) -> str | None:
"""解析 Authorization 头中的 Bearer token;缺失或格式非法返回 None"""
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token.strip():
return None
return token.strip()
async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
"""Bearer token → session → 用户记录(不拦截 must_change_password
凭证缺失/无效/用户不存在抛 ApiError(1005);存储后端异常抛 ApiError(2001)。
供 get_current_user 与改密/退出端点共用(后者须对 must_change_password 用户放行)。
"""
token = _parse_bearer(authorization)
if token is None:
raise ApiError(1005, "未认证或凭证无效")
try:
session = await _get_session_store().get(token)
if session is None:
raise ApiError(1005, "未认证或凭证无效")
user = await _get_user_store().get(session["username"])
except (SessionStoreError, UserStoreError) as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
if user is None:
raise ApiError(1005, "未认证或凭证无效")
return user, token
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
"""
user, _ = await _resolve_user(authorization)
if user.must_change_password:
raise ApiError(1006, "首次登录须先修改密码")
return user
async def require_admin(user: UserRecord = Depends(get_current_user)) -> UserRecord:
"""鉴权依赖:在 get_current_user 之上要求 admin 角色"""
if user.role != "admin":
raise ApiError(1006, "权限不足")
return user
+172 -42
View File
@@ -1,62 +1,192 @@
"""认证 APIPOST /auth/login、POST /auth/register、GET /auth/me"""
"""认证与用户管理 API/api/v1/auth 下的登录/退出/改密与用户管理端点
from typing import Any
会话制认证:登录签发 session token(存储见 app/core/sessions.pyTTL 12h),
请求经 Authorization: Bearer <token> 携带,鉴权依赖见 app/api/deps.py。
用户管理端点(/users*)全部要求 admin 角色;/logout 与 /password 对
must_change_password 用户放行(改密是被拦截用户唯一能用的接口)。
"""
from typing import Any, Literal
import structlog
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header
from pydantic import BaseModel
from app.api import deps
from app.api.response import ApiError, ok
from app.config import settings
from app.core.auth import (
ERR_REGISTER_DISABLED,
UserStore,
create_access_token,
get_current_user,
get_user_store,
)
from app.models.auth import (
AuthUser,
LoginRequest,
RegisterRequest,
TokenResponse,
)
from app.core.sessions import SessionStoreError
from app.core.users import UserExistsError, UserRecord, UserStoreError
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1", tags=["auth"])
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _build_token_response(user, store: UserStore) -> dict[str, Any]:
"""签发 token 并构造统一响应 data"""
token, expires_in = create_access_token(user.username, user.role)
auth_user = AuthUser(username=user.username, role=user.role, created_at=user.created_at)
resp = TokenResponse(access_token=token, expires_in=expires_in, user=auth_user)
return resp.model_dump(mode="json")
class LoginRequest(BaseModel):
"""登录请求"""
username: str
password: str
@router.post("/auth/login")
class PasswordChangeRequest(BaseModel):
"""修改自己的密码"""
old_password: str
new_password: str
class UserCreateRequest(BaseModel):
"""管理员创建用户"""
username: str
password: str
role: Literal["admin", "user"] = "user"
class PasswordResetRequest(BaseModel):
"""管理员重置他人密码"""
new_password: str
def _public_user(user: UserRecord) -> dict[str, Any]:
"""用户记录脱敏:剔除 password_hash/salt"""
return {
"username": user.username,
"role": user.role,
"must_change_password": user.must_change_password,
"created_at": user.created_at,
}
@router.post("/login")
async def login(req: LoginRequest) -> dict[str, Any]:
"""用户名密码登录,返回 JWT access_token"""
store = get_user_store()
user = await store.authenticate(req.username, req.password)
"""用户名密码登录:成功签发 session tokenmust_change_password 用户也可登录)"""
try:
user = await deps._get_user_store().verify_password(req.username, req.password)
except UserStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
if user is None:
raise ApiError(1005, "用户名或密码错误")
try:
token = await deps._get_session_store().create(user.username, user.role)
except SessionStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
logger.info("用户登录成功", username=user.username)
return ok(_build_token_response(user, store))
return ok(
{
"token": token,
"username": user.username,
"role": user.role,
"must_change_password": user.must_change_password,
}
)
@router.post("/auth/register")
async def register(req: RegisterRequest) -> dict[str, Any]:
"""注册新用户(role=user),注册后自动签发 token
@router.get("/me")
async def me(user: UserRecord = Depends(deps.get_current_user)) -> dict[str, Any]:
"""返回当前登录用户信息(脱敏),供管理页面校验登录态"""
return ok(_public_user(user))
受 settings.auth_register_enabled 控制,关闭时返回 ERR_REGISTER_DISABLED。
@router.post("/logout")
async def logout(authorization: str | None = Header(None)) -> dict[str, Any]:
"""退出登录:删除当前 session(自解析 Bearer,不拦截 must_change_password"""
_, token = await deps._resolve_user(authorization)
try:
await deps._get_session_store().delete(token)
except SessionStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
return ok({"logged_out": True})
@router.post("/password")
async def change_password(
req: PasswordChangeRequest, authorization: str | None = Header(None)
) -> dict[str, Any]:
"""修改自己的密码(自解析 Bearer,不拦截 must_change_password——这是被拦截用户唯一可用接口)
旧密码错误 1005;新密码不足 8 位 1001。改密不清除既有 sessionTask1 语义)。
"""
if not settings.auth_register_enabled:
raise ApiError(ERR_REGISTER_DISABLED, "注册已关闭")
store = get_user_store()
user = await store.create(req.username, req.password, role="user")
return ok(_build_token_response(user, store))
user, _ = await deps._resolve_user(authorization)
store = deps._get_user_store()
try:
verified = await store.verify_password(user.username, req.old_password)
if verified is None:
raise ApiError(1005, "旧密码错误")
await store.set_password(user.username, req.new_password)
except (UserStoreError, SessionStoreError) as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
logger.info("用户修改密码", username=user.username)
return ok({"username": user.username})
@router.get("/auth/me")
async def me(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""返回当前登录用户信息"""
return ok(user.model_dump(mode="json"))
@router.get("/users")
async def list_users(admin: UserRecord = Depends(deps.require_admin)) -> dict[str, Any]:
"""列出全部用户(脱敏:不含 password_hash/salt"""
try:
users = await deps._get_user_store().list()
except UserStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
return ok([_public_user(user) for user in users])
@router.post("/users")
async def create_user(
req: UserCreateRequest, admin: UserRecord = Depends(deps.require_admin)
) -> dict[str, Any]:
"""创建用户:重名/非法用户名/弱密码 1001;成功返回脱敏后的创建记录"""
try:
user = await deps._get_user_store().create(req.username, req.password, role=req.role)
except UserExistsError as exc:
raise ApiError(1001, str(exc)) from exc
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
except UserStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
logger.info("管理员创建用户", username=user.username, role=user.role, operator=admin.username)
return ok(_public_user(user))
@router.post("/users/{username}/password")
async def reset_user_password(
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
) -> dict[str, Any]:
"""重置指定用户密码:用户不存在 1004;成功后清除该用户全部 session"""
store = deps._get_user_store()
try:
if await store.get(username) is None:
raise ApiError(1004, "用户不存在")
await store.set_password(username, req.new_password)
await deps._get_session_store().delete_by_username(username)
except (UserStoreError, SessionStoreError) as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
logger.info("管理员重置用户密码", username=username, operator=admin.username)
return ok({"username": username})
@router.delete("/users/{username}")
async def delete_user(
username: str, admin: UserRecord = Depends(deps.require_admin)
) -> dict[str, Any]:
"""删除用户:不存在 1004;删除自己 1001;最后一个 admin 禁止删除 1001;成功后清除其 session"""
store = deps._get_user_store()
try:
target = await store.get(username)
if target is None:
raise ApiError(1004, "用户不存在")
if target.username == admin.username:
raise ApiError(1001, "不能删除当前登录账号")
if target.role == "admin" and await store.count_admins() <= 1:
raise ApiError(1001, "禁止删除最后一个管理员")
await store.delete(username)
await deps._get_session_store().delete_by_username(username)
except (UserStoreError, SessionStoreError) as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
logger.info("管理员删除用户", username=username, operator=admin.username)
return ok({"username": username})
+7 -11
View File
@@ -10,12 +10,13 @@ import structlog
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse
from app.api.deps import get_current_user
from app.api.response import ApiError, ok
from app.config import Settings, settings
from app.core.auth import AuthUser, get_current_user, require_admin
from app.core.file_parser import parse_file
from app.core.ingest_tasks import IngestTaskManager
from app.core.ingestion import Ingester
from app.core.users import UserRecord
from app.models.document import DocumentInput
from app.services.qdrant import QdrantService
from app.services.redis import RedisCache, get_cache
@@ -74,7 +75,7 @@ def _allowed_extensions() -> set[str]:
@router.post("/documents")
async def ingest_document(
doc: DocumentInput, user: AuthUser = Depends(get_current_user)
doc: DocumentInput, user: UserRecord = Depends(get_current_user)
) -> JSONResponse:
"""文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取"""
if not doc.text.strip():
@@ -95,7 +96,7 @@ async def upload_document(
metadata: str = Form(
default="", description='可选元数据 JSON 字符串,如 \'{"author":"x"}\''
),
user: AuthUser = Depends(get_current_user),
user: UserRecord = Depends(get_current_user),
) -> JSONResponse:
"""文件上传入库入口:校验 → 提取文本 → 落盘 → 提交异步入库流水线
@@ -183,9 +184,7 @@ async def upload_document(
@router.get("/documents/tasks/{task_id}")
async def get_ingest_task(
task_id: str, user: AuthUser = Depends(get_current_user)
) -> dict[str, Any]:
async def get_ingest_task(task_id: str) -> dict[str, Any]:
"""查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error"""
task = await _get_task_manager().get(task_id)
if task is None:
@@ -197,7 +196,6 @@ async def get_ingest_task(
async def list_documents(
limit: int = Query(default=20, ge=1, le=100),
offset: str | None = None,
user: AuthUser = Depends(get_current_user),
) -> dict[str, Any]:
"""分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset"""
try:
@@ -209,9 +207,7 @@ async def list_documents(
@router.get("/documents/{doc_id}")
async def get_document(
doc_id: str, user: AuthUser = Depends(get_current_user)
) -> dict[str, Any]:
async def get_document(doc_id: str) -> dict[str, Any]:
"""获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量"""
try:
detail = await _get_qdrant().get_doc_detail(doc_id)
@@ -225,7 +221,7 @@ async def get_document(
@router.delete("/documents/{doc_id}")
async def delete_document(
doc_id: str, user: AuthUser = Depends(require_admin)
doc_id: str, user: UserRecord = Depends(get_current_user)
) -> dict[str, Any]:
"""删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)"""
try:
+100
View File
@@ -0,0 +1,100 @@
"""Session 核心:登录会话的签发与校验
存储后端为 Rediskey: session:{token}JSON 序列化,TTL 12h);
构造时传入 None 降级为进程级内存 dict(带过期时间戳,惰性清理过期项,重启失效)。
所有 Redis 读写异常统一包装为 SessionStoreError 抛出(不静默)。
"""
import json
import secrets
import time
from redis import asyncio as redis_async
# Redis 键前缀
_SESSION_KEY_PREFIX = "session:"
class SessionStoreError(Exception):
"""Session 存储后端读写异常"""
class SessionStore:
"""会话存储:Redis 持久化(带 TTL),构造传 None 降级为内存 dict"""
TTL: int = 43200 # 会话有效期(秒),12h
def __init__(self, redis_client: redis_async.Redis | None) -> None:
# redis.asyncio 客户端(需 decode_responses=True);None → 内存降级
self._redis = redis_client
# 内存降级:token -> (payload, 过期时间戳)
self._memory: dict[str, tuple[dict[str, str], float]] = {}
@staticmethod
def _key(token: str) -> str:
return f"{_SESSION_KEY_PREFIX}{token}"
async def create(self, username: str, role: str) -> str:
"""签发会话:生成 64 位 hex token,存 session:{token} = {username, role}"""
token = secrets.token_hex(32)
payload = {"username": username, "role": role}
if self._redis is None:
self._purge_expired()
self._memory[token] = (payload, time.time() + self.TTL)
return token
try:
await self._redis.setex(self._key(token), self.TTL, json.dumps(payload, ensure_ascii=False))
except Exception as e:
raise SessionStoreError("写入会话失败") from e
return token
async def get(self, token: str) -> dict[str, str] | None:
"""校验会话:无效或已过期返回 None,有效返回 {username, role}"""
if self._redis is None:
item = self._memory.get(token)
if item is None:
return None
payload, expire_at = item
if expire_at <= time.time():
# 惰性清理过期项
del self._memory[token]
return None
return dict(payload)
try:
raw = await self._redis.get(self._key(token))
except Exception as e:
raise SessionStoreError("读取会话失败") from e
if raw is None:
return None
return json.loads(raw)
async def delete(self, token: str) -> None:
"""删除单个会话(幂等)"""
if self._redis is None:
self._memory.pop(token, None)
return
try:
await self._redis.delete(self._key(token))
except Exception as e:
raise SessionStoreError("删除会话失败") from e
async def delete_by_username(self, username: str) -> None:
"""删除指定用户的全部会话(删除用户时清理其登录态)"""
if self._redis is None:
matched = [t for t, (payload, _) in self._memory.items() if payload["username"] == username]
for token in matched:
del self._memory[token]
return
try:
async for key in self._redis.scan_iter(match=f"{_SESSION_KEY_PREFIX}*"):
raw = await self._redis.get(key)
if raw is not None and json.loads(raw).get("username") == username:
await self._redis.delete(key)
except Exception as e:
raise SessionStoreError(f"按用户删除会话失败: {username}") from e
def _purge_expired(self) -> None:
"""惰性清理内存降级模式下的过期会话"""
now = time.time()
for token in [t for t, (_, expire_at) in self._memory.items() if expire_at <= now]:
del self._memory[token]
+191
View File
@@ -0,0 +1,191 @@
"""用户核心:用户记录存储与密码哈希
存储后端为 Rediskey: user:{username},JSON 序列化,无 TTL 持久化);
构造时传入 None 降级为进程级内存 dict(重启丢失,仅用于测试与 Redis 故障兜底)。
所有 Redis 读写异常统一包装为 UserStoreError 抛出(不静默),由上层转 2001。
密码哈希使用标准库 hashlib.pbkdf2_hmacSHA256100_000 迭代 + 16 字节随机 salt)。
"""
import hashlib
import hmac
import json
import re
import secrets
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from redis import asyncio as redis_async
from structlog.typing import FilteringBoundLogger
# 用户名规则:字母/数字/下划线/连字符,2~32 位
USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
# 密码最小长度
MIN_PASSWORD_LENGTH = 8
# PBKDF2 迭代次数
_HASH_ITERATIONS = 100_000
# salt 字节数(hex 序列化后 32 字符)
_SALT_BYTES = 16
# Redis 键前缀
_USER_KEY_PREFIX = "user:"
class UserExistsError(Exception):
"""用户名已存在"""
class UserStoreError(Exception):
"""用户存储后端读写异常"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
username: str
role: str # "admin" | "user"
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
created_at: str # UTC ISO8601
class UserStore:
"""用户存储:Redis 持久化,构造传 None 降级为内存 dict"""
def __init__(self, redis_client: redis_async.Redis | None) -> None:
# redis.asyncio 客户端(需 decode_responses=True);None → 内存降级
self._redis = redis_client
# 内存降级:username -> JSON 字符串(与 Redis 路径同构)
self._memory: dict[str, str] = {}
@staticmethod
def _key(username: str) -> str:
return f"{_USER_KEY_PREFIX}{username}"
def hash_password(self, password: str, salt: bytes) -> str:
"""PBKDF2-HMAC-SHA256100_000 迭代),返回 hex 摘要"""
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _HASH_ITERATIONS).hex()
async def create(
self,
username: str,
password: str,
role: str = "user",
must_change_password: bool = False,
) -> UserRecord:
"""创建用户
重名抛 UserExistsError;用户名不符合 USERNAME_PATTERN 或密码过短抛 ValueError。
"""
if not USERNAME_PATTERN.match(username):
raise ValueError(f"用户名非法: {username!r}(须为 2~32 位字母/数字/_/-)")
if len(password) < MIN_PASSWORD_LENGTH:
raise ValueError(f"密码长度不得少于 {MIN_PASSWORD_LENGTH}")
if await self.get(username) is not None:
raise UserExistsError(f"用户已存在: {username}")
salt = secrets.token_bytes(_SALT_BYTES)
record = UserRecord(
username=username,
role=role,
password_hash=self.hash_password(password, salt),
salt=salt.hex(),
must_change_password=must_change_password,
created_at=datetime.now(UTC).isoformat(),
)
await self._write(record)
return record
async def get(self, username: str) -> UserRecord | None:
"""按用户名查询,不存在返回 None"""
raw = await self._read_raw(username)
if raw is None:
return None
return UserRecord(**json.loads(raw))
async def list(self) -> list[UserRecord]:
"""列出全部用户(扫描 user:* 键)"""
if self._redis is None:
raws: list[str | None] = list(self._memory.values())
else:
try:
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
except Exception as e:
raise UserStoreError("列出用户失败") from e
return [UserRecord(**json.loads(raw)) for raw in raws if raw is not None]
async def delete(self, username: str) -> bool:
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
if self._redis is None:
return self._memory.pop(username, None) is not None
try:
return bool(await self._redis.delete(self._key(username)))
except Exception as e:
raise UserStoreError(f"删除用户失败: {username}") from e
async def verify_password(self, username: str, password: str) -> UserRecord | None:
"""校验密码:成功返回用户记录,用户不存在或密码错误返回 None"""
record = await self.get(username)
if record is None:
return None
candidate = self.hash_password(password, bytes.fromhex(record.salt))
# 常量时间比较,防时序侧信道
if not hmac.compare_digest(candidate, record.password_hash):
return None
return record
async def set_password(self, username: str, new_password: str) -> bool:
"""重置密码:重新生成 salt 并哈希,同时清除 must_change_password 标记
用户不存在返回 False;新密码过短抛 ValueError。
"""
if len(new_password) < MIN_PASSWORD_LENGTH:
raise ValueError(f"密码长度不得少于 {MIN_PASSWORD_LENGTH}")
record = await self.get(username)
if record is None:
return False
salt = secrets.token_bytes(_SALT_BYTES)
record.salt = salt.hex()
record.password_hash = self.hash_password(new_password, salt)
record.must_change_password = False
await self._write(record)
return True
async def count_admins(self) -> int:
"""统计 admin 角色用户数"""
return sum(1 for record in await self.list() if record.role == "admin")
async def _read_raw(self, username: str) -> str | None:
if self._redis is None:
return self._memory.get(username)
try:
return await self._redis.get(self._key(username))
except Exception as e:
raise UserStoreError(f"读取用户失败: {username}") from e
async def _write(self, record: UserRecord) -> None:
raw = json.dumps(asdict(record), ensure_ascii=False)
if self._redis is None:
self._memory[record.username] = raw
return
try:
await self._redis.set(self._key(record.username), raw)
except Exception as e:
raise UserStoreError(f"写入用户失败: {record.username}") from e
async def bootstrap_admin(store: UserStore, logger: FilteringBoundLogger) -> str | None:
"""空库引导:无任何用户时创建随机密码的 admin 账号
明文密码通过 logger.warning 打印一次并返回(仅此一次机会);
已有用户时不做任何事,返回 None。
"""
if await store.list():
return None
password = secrets.token_urlsafe(12)
await store.create("admin", password, role="admin", must_change_password=True)
logger.warning("已创建默认管理员,请立即登录并修改密码", username="admin", password=password)
return password
+9 -2
View File
@@ -6,6 +6,7 @@ import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from redis import asyncio as redis_async
from app.api.response import ApiError, error
from app.api.v1.auth import router as auth_router
@@ -14,7 +15,7 @@ from app.api.v1.knowledge import router as knowledge_router
from app.api.v1.search import router as search_router
from app.api.v1.settings import router as settings_router
from app.config import settings
from app.core.auth import ensure_default_admin
from app.core.users import UserStore, bootstrap_admin
from app.services.qdrant import QdrantService
logger = structlog.get_logger()
@@ -32,7 +33,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
except Exception:
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
try:
await ensure_default_admin()
# Redis 客户端创建失败(如 URL 非法)时传 None,UserStore 降级为内存模式
redis_client = redis_async.from_url(settings.redis_url, decode_responses=True)
except Exception:
redis_client = None
try:
# 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次
await bootstrap_admin(UserStore(redis_client), logger)
except Exception:
logger.warning("默认管理员初始化失败,跳过", exc_info=True)
try:
+691 -23
View File
@@ -79,7 +79,7 @@
button.primary:disabled { background: #9ca3af; cursor: not-allowed; }
form .field { margin-bottom: 12px; }
form label { display: block; font-size: 13px; margin-bottom: 4px; color: #374151; }
input[type="text"], input[type="number"], textarea {
input[type="text"], input[type="number"], input[type="password"], textarea, select {
width: 100%; border: 1px solid #d1d5db; border-radius: 4px;
padding: 8px; font-size: 13px; font-family: inherit;
}
@@ -118,6 +118,12 @@
header { position: relative; }
.user-area { position: absolute; top: 14px; right: 24px; display: flex; align-items: center; gap: 8px; }
.user-area .action { padding: 4px 10px; }
.user-area #user-name { color: #f9fafb; font-size: 13px; }
.role-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px; font-size: 12px;
background: #eff6ff; color: #1d4ed8; border: 1px solid #93c5fd;
}
.role-badge.role-admin { background: #fef3c7; color: #b45309; border-color: #fcd34d; }
.login-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 100;
@@ -139,6 +145,45 @@
padding: 12px 14px; margin-bottom: 12px; font-size: 13px; white-space: pre-wrap;
}
.summary-box .sum-title { font-weight: 600; margin-bottom: 6px; color: #15803d; }
.method-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
font-size: 12px; font-weight: 600; color: #fff; margin-right: 8px;
}
.method-get { background: #16a34a; }
.method-post { background: #2563eb; }
.method-delete { background: #dc2626; }
.auth-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
font-size: 12px; margin-left: 8px; border: 1px solid transparent;
}
.auth-badge.auth-none { background: #f0fdf4; color: #15803d; border-color: #86efac; }
.auth-badge.auth-bearer { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
.auth-badge.auth-admin { background: #fef3c7; color: #b45309; border-color: #fcd34d; }
.api-item { border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px 14px; margin-bottom: 12px; }
.api-item .api-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
.api-item code { background: #f3f4f6; border-radius: 3px; padding: 1px 6px; font-size: 13px; }
.api-item .api-desc { font-size: 13px; color: #374151; margin-bottom: 8px; }
.api-item h4 { font-size: 13px; margin: 10px 0 6px; color: #374151; }
.json-pre {
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 8px; white-space: pre-wrap; word-break: break-word;
font-size: 12px; max-height: 260px; overflow: auto;
}
.curl-box { position: relative; }
.curl-box .curl-copy { position: absolute; top: 6px; right: 6px; }
.curl-pre {
background: #1f2937; color: #e5e7eb; border-radius: 4px;
padding: 8px; white-space: pre-wrap; word-break: break-all; font-size: 12px;
}
.try-panel { margin-top: 10px; border-top: 1px dashed #e5e7eb; padding-top: 10px; }
.try-panel .field { margin-bottom: 10px; }
.try-panel label { display: block; font-size: 13px; margin-bottom: 4px; color: #374151; }
.try-panel textarea { min-height: 100px; font-family: ui-monospace, monospace; }
.try-result pre {
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 8px; white-space: pre-wrap; word-break: break-word;
font-size: 12px; max-height: 320px; overflow: auto;
}
</style>
</head>
<body>
@@ -159,11 +204,36 @@
</form>
</div>
</div>
<div id="password-overlay" class="login-overlay hidden">
<div class="login-box">
<h2 id="password-title">修改密码</h2>
<div class="error-bar hidden" id="error-password"></div>
<div class="fallback-flag hidden" id="password-forced-tip">首次登录须先修改密码,完成后方可进入管理后台</div>
<form id="password-form">
<div class="field">
<label for="password-old">旧密码</label>
<input type="password" id="password-old" name="old_password" required autocomplete="current-password">
</div>
<div class="field">
<label for="password-new">新密码(至少 8 位)</label>
<input type="password" id="password-new" name="new_password" required autocomplete="new-password">
</div>
<div class="field">
<label for="password-confirm">确认新密码</label>
<input type="password" id="password-confirm" name="confirm_password" required autocomplete="new-password">
</div>
<button type="submit" class="primary">确认修改</button>
<button type="button" class="action" id="btn-password-cancel">取消</button>
</form>
</div>
</div>
<header>
<h1>知识库管理后台</h1>
<div id="user-area" class="user-area hidden">
<span class="muted" id="user-name"></span>
<button type="button" class="action" id="btn-logout">登出</button>
<span id="user-name"></span>
<span id="user-role" class="role-badge"></span>
<button type="button" class="action" id="btn-change-password">修改密码</button>
<button type="button" class="action" id="btn-logout">退出登录</button>
</div>
<nav id="nav">
<button type="button" data-target="section-overview" class="active">概览</button>
@@ -171,6 +241,7 @@
<button type="button" data-target="section-ingest">文档入库</button>
<button type="button" data-target="section-search">检索测试台</button>
<button type="button" data-target="section-categories">类目列表</button>
<button type="button" data-target="section-api-guide">API 指南</button>
</nav>
</header>
<main>
@@ -268,8 +339,49 @@
<tbody id="categories-tbody"></tbody>
</table>
</section>
<section id="section-api-guide" class="hidden">
<h2>API 指南</h2>
<div class="muted" style="margin-bottom:12px;">全部端点清单与在线测试台;base URL 取当前站点(location.origin),需鉴权端点发送时自动附带当前登录 token。仅可从清单选择端点,不支持自定义 URL。</div>
<div id="api-guide-list"></div>
</section>
</main>
<template id="tpl-section-users">
<section id="section-users" class="hidden">
<h2>用户管理</h2>
<div class="error-bar hidden" id="error-users"></div>
<div class="toolbar">
<button type="button" class="action" id="btn-refresh-users">刷新</button>
</div>
<table>
<thead>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
<h3 style="font-size:14px; margin-top:24px;">创建用户</h3>
<form id="user-create-form">
<div class="field">
<label for="user-new-name">用户名</label>
<input type="text" id="user-new-name" name="username" required autocomplete="off">
</div>
<div class="field">
<label for="user-new-password">初始密码(至少 8 位)</label>
<input type="password" id="user-new-password" name="password" required autocomplete="new-password">
</div>
<div class="field">
<label for="user-new-role">角色</label>
<select id="user-new-role" name="role">
<option value="user" selected>user</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit" class="primary">创建用户</button>
</form>
</section>
</template>
<script>
"use strict";
@@ -299,25 +411,68 @@ function hideError(boxId) {
document.getElementById(boxId).classList.add("hidden");
}
/* ---------- 认证 token 管理 ---------- */
function getToken() { return localStorage.getItem("qmd_token") || ""; }
function setToken(t) { localStorage.setItem("qmd_token", t); }
function clearToken() { localStorage.removeItem("qmd_token"); }
/* ---------- 认证状态管理 ---------- */
var TOKEN_KEY = "qmd_token";
var USER_KEY = "qmd_user";
var passwordForced = false;
function getToken() { return localStorage.getItem(TOKEN_KEY) || ""; }
function getStoredUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY) || "null");
} catch (e) {
return null;
}
}
function saveAuth(token, user) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify({
username: user.username,
role: user.role,
must_change_password: !!user.must_change_password
}));
}
function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
function showLogin() {
document.getElementById("login-overlay").classList.remove("hidden");
document.getElementById("password-overlay").classList.add("hidden");
document.getElementById("user-area").classList.add("hidden");
loadedOnce = {};
unmountUsersSection();
}
function afterLogin(user) {
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块) */
function enterApp(user) {
document.getElementById("login-overlay").classList.add("hidden");
document.getElementById("password-overlay").classList.add("hidden");
document.getElementById("user-area").classList.remove("hidden");
document.getElementById("user-name").textContent = user.username + " (" + user.role + ")";
document.getElementById("user-name").textContent = user.username;
var badge = document.getElementById("user-role");
badge.textContent = user.role;
badge.className = "role-badge" + (user.role === "admin" ? " role-admin" : "");
loadedOnce = {};
if (user.role === "admin") {
mountUsersSection();
} else {
unmountUsersSection();
}
activateSection("section-overview");
}
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
function afterAuth(user) {
if (user.must_change_password) {
document.getElementById("login-overlay").classList.add("hidden");
openPasswordModal(true);
return;
}
enterApp(user);
}
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-login");
@@ -325,6 +480,7 @@ document.getElementById("login-form").addEventListener("submit", function (event
username: document.getElementById("login-username").value,
password: document.getElementById("login-password").value
};
/* 登录接口不携带 Authorization 头 */
fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -334,19 +490,84 @@ document.getElementById("login-form").addEventListener("submit", function (event
showError("error-login", { code: body.code, message: body.message });
return;
}
setToken(body.data.access_token);
afterLogin(body.data.user);
var data = body.data;
saveAuth(data.token, data);
afterAuth(data);
}).catch(function (err) {
showError("error-login", { code: "NETWORK", message: "登录请求失败: " + (err && err.message ? err.message : err) });
});
});
document.getElementById("btn-logout").addEventListener("click", function () {
clearToken();
showLogin();
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
/* 服务端退出失败不阻塞本地登出 */
}).finally(function () {
clearAuth();
showLogin();
});
});
/* 统一 API 封装:自动注入 token,code !== 0 抛错,未认证跳登录 */
/* ---------- 修改密码 ---------- */
function openPasswordModal(forced) {
passwordForced = !!forced;
document.getElementById("password-title").textContent = forced ? "首次登录须修改密码" : "修改密码";
document.getElementById("password-forced-tip").classList.toggle("hidden", !forced);
/* 强制改密层无取消按钮、不可跳过 */
document.getElementById("btn-password-cancel").classList.toggle("hidden", forced);
if (forced) {
document.getElementById("login-overlay").classList.add("hidden");
}
hideError("error-password");
document.getElementById("password-form").reset();
document.getElementById("password-overlay").classList.remove("hidden");
}
document.getElementById("btn-change-password").addEventListener("click", function () {
openPasswordModal(false);
});
document.getElementById("btn-password-cancel").addEventListener("click", function () {
if (passwordForced) { return; }
document.getElementById("password-overlay").classList.add("hidden");
});
document.getElementById("password-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-password");
var newPwd = document.getElementById("password-new").value;
if (newPwd !== document.getElementById("password-confirm").value) {
showError("error-password", { code: "VALIDATION", message: "两次输入的新密码不一致" });
return;
}
api("/api/v1/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_password: document.getElementById("password-old").value,
new_password: newPwd
})
}).then(function () {
document.getElementById("password-overlay").classList.add("hidden");
if (passwordForced) {
/* 强制改密成功后才进入主界面 */
passwordForced = false;
var user = getStoredUser();
if (user) {
user.must_change_password = false;
localStorage.setItem(USER_KEY, JSON.stringify(user));
enterApp(user);
}
} else {
alert("密码修改成功");
}
}).catch(function (err) {
showError("error-password", err);
});
});
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
function api(path, options) {
options = options || {};
options.headers = Object.assign({}, options.headers || {});
@@ -355,18 +576,13 @@ function api(path, options) {
options.headers["Authorization"] = "Bearer " + token;
}
return fetch(path, options).then(function (resp) {
if (resp.status === 401) {
clearToken();
showLogin();
throw { code: 1003, message: "未认证或登录已过期,请重新登录" };
}
return resp.json().catch(function () {
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
});
}).then(function (body) {
if (body.code !== 0) {
if (body.code === 1003 || body.code === 1005) {
clearToken();
if (body.code === 1005) {
clearAuth();
showLogin();
}
throw { code: body.code, message: body.message };
@@ -402,6 +618,8 @@ function activateSection(targetId) {
if (targetId === "section-overview") { loadOverview(); }
if (targetId === "section-docs") { loadDocuments(true); }
if (targetId === "section-categories") { loadCategories(); }
if (targetId === "section-users") { loadUsers(); }
if (targetId === "section-api-guide") { renderApiGuide(); }
}
}
@@ -870,6 +1088,449 @@ function loadCategories() {
}).catch(function (err) { showError("error-categories", err); });
}
/* ---------- 6. 用户管理(仅 admin 渲染) ---------- */
/* admin 登录时将用户管理区块从 template 挂载进 DOM;非 admin 完全不渲染 */
function mountUsersSection() {
if (document.getElementById("section-users")) { return; }
var tpl = document.getElementById("tpl-section-users");
document.querySelector("main").appendChild(tpl.content.cloneNode(true));
var navBtn = el("button", "用户管理");
navBtn.type = "button";
navBtn.id = "nav-users";
navBtn.setAttribute("data-target", "section-users");
document.getElementById("nav").appendChild(navBtn);
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
document.getElementById("user-create-form").addEventListener("submit", createUser);
}
function unmountUsersSection() {
var sec = document.getElementById("section-users");
if (sec) { sec.parentNode.removeChild(sec); }
var navBtn = document.getElementById("nav-users");
if (navBtn) { navBtn.parentNode.removeChild(navBtn); }
}
function loadUsers() {
hideError("error-users");
api("/api/v1/auth/users").then(function (users) {
renderUsers(users || []);
}).catch(function (err) { showError("error-users", err); });
}
function renderUsers(users) {
var tbody = document.getElementById("users-tbody");
clearChildren(tbody);
users.forEach(function (user) {
var tr = el("tr");
tr.appendChild(el("td", user.username));
var roleTd = el("td");
roleTd.appendChild(el("span", user.role, "role-badge" + (user.role === "admin" ? " role-admin" : "")));
tr.appendChild(roleTd);
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
tr.appendChild(el("td", user.created_at || ""));
var opsTd = el("td");
var resetBtn = el("button", "重置密码", "action");
resetBtn.type = "button";
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
opsTd.appendChild(resetBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
tbody.appendChild(tr);
});
}
function createUser(event) {
event.preventDefault();
hideError("error-users");
var payload = {
username: document.getElementById("user-new-name").value,
password: document.getElementById("user-new-password").value,
role: document.getElementById("user-new-role").value
};
api("/api/v1/auth/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function () {
document.getElementById("user-create-form").reset();
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
function resetUserPassword(username) {
var newPwd = prompt("为用户「" + username + "」设置新密码(至少 8 位):");
if (newPwd === null) { return; }
if (!newPwd) {
showError("error-users", { code: "VALIDATION", message: "新密码不能为空" });
return;
}
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPwd })
}).then(function () {
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
function deleteUser(username, role) {
if (!confirm("确定删除用户「" + username + "」(角色 " + role + ")吗?该操作不可恢复。")) {
return;
}
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
/* ---------- 7. API 指南 ---------- */
/* 展示用静态维护清单:与后端路由表无自动同步,
测试 tests/test_admin_page.py 校验每个 method+path 均在真实路由集合内防止漂移。
auth 取值:none=免登录 / bearer=需登录 / admin=仅 admin */
var API_GUIDE = [
{ method: "GET", path: "/api/v1/health", desc: "健康检查", auth: "none",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "POST", path: "/api/v1/search", desc: "分层检索(query 路由 → L1→L2→L3 剪枝 → chunk hybrid 检索)", auth: "none",
query: [], pathParams: [], form: [],
body: { query: "NAS 上如何部署服务?", top_k: 5, summarize: false }, upload: false },
{ method: "POST", path: "/api/v1/documents", desc: "文档入库(202 异步入库,返回 task_id", auth: "bearer",
query: [], pathParams: [], form: [],
body: { title: "示例文档", source: "manual", text: "文档正文内容……" }, upload: false },
{ method: "POST", path: "/api/v1/documents/upload", desc: "multipart 文件上传入库(.txt/.md/.html/.htm/.pdf/.docx202 异步)", auth: "bearer",
query: [], pathParams: [],
form: [
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
],
body: null, upload: true },
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 resultfailed 附 error", auth: "none",
query: [],
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/documents", desc: "文档列表(limit/offset 分页)", auth: "none",
query: [
{ name: "limit", desc: "每页条数(1-100", def: "20" },
{ name: "offset", desc: "分页游标(上一页返回的 next_offset", def: "" }
],
pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/documents/{doc_id}", desc: "文档详情(L1 记录 + L2/L3 节点 + chunks 数量)", auth: "none",
query: [],
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
form: [], body: null, upload: false },
{ method: "DELETE", path: "/api/v1/documents/{doc_id}", desc: "删除文档(四层集合全量删除,幂等)", auth: "bearer",
query: [],
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "POST", path: "/api/v1/auth/login", desc: "用户名密码登录,成功签发 Bearer tokensession TTL 12h", auth: "none",
query: [], pathParams: [], form: [],
body: { username: "admin", password: "你的密码" }, upload: false },
{ method: "POST", path: "/api/v1/auth/logout", desc: "退出登录(删除当前 session", auth: "bearer",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "POST", path: "/api/v1/auth/password", desc: "修改自己的密码(must_change_password 用户唯一可用接口)", auth: "bearer",
query: [], pathParams: [], form: [],
body: { old_password: "旧密码", new_password: "新密码至少8位" }, upload: false },
{ method: "GET", path: "/api/v1/auth/me", desc: "当前登录用户信息(脱敏)", auth: "bearer",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/auth/users", desc: "用户列表(脱敏,不含 password_hash/salt", auth: "admin",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "POST", path: "/api/v1/auth/users", desc: "创建用户(重名/弱密码 1001", auth: "admin",
query: [], pathParams: [], form: [],
body: { username: "newuser", password: "初始密码至少8位", role: "user" }, upload: false },
{ method: "POST", path: "/api/v1/auth/users/{username}/password", desc: "重置指定用户密码(成功后清除其全部 session)", auth: "admin",
query: [],
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
form: [],
body: { new_password: "新密码至少8位" }, upload: false },
{ method: "DELETE", path: "/api/v1/auth/users/{username}", desc: "删除用户并清其 session(不能删自己/最后一个 admin", auth: "admin",
query: [],
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
form: [], body: null, upload: false }
];
var API_AUTH_LABELS = { none: "免登录", bearer: "需登录", admin: "仅 admin" };
/* path 模板占位替换:values 为空时回退默认值,再回退 {name} 占位(供 curl 展示) */
function substitutePath(ep, values) {
var p = ep.path;
ep.pathParams.forEach(function (pp) {
var v = values[pp.name] !== undefined && values[pp.name] !== "" ? values[pp.name] : (pp.def || "{" + pp.name + "}");
p = p.split("{" + pp.name + "}").join(v);
});
return p;
}
function buildCurl(ep) {
var url = location.origin + substitutePath(ep, {});
var qs = [];
ep.query.forEach(function (q) {
if (q.def) { qs.push(q.name + "=" + encodeURIComponent(q.def)); }
});
if (qs.length) { url += "?" + qs.join("&"); }
var parts = ['curl -X ' + ep.method + ' "' + url + '"'];
if (ep.auth !== "none") {
parts.push('-H "Authorization: Bearer <TOKEN>"');
}
if (ep.upload) {
parts.push('-F "file=@/path/to/file.md"');
} else if (ep.body) {
parts.push('-H "Content-Type: application/json"');
parts.push("-d '" + JSON.stringify(ep.body) + "'");
}
return parts.join(" \\\n ");
}
/* 复制到剪贴板:navigator.clipboard 不可用时降级为 textarea 选中复制 */
function copyText(text, btn) {
function done() {
btn.textContent = "已复制";
setTimeout(function () { btn.textContent = "复制"; }, 1500);
}
function fallback() {
var ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) { /* 复制失败不阻塞 */ }
document.body.removeChild(ta);
done();
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else {
fallback();
}
}
function renderParamTable(item, title, params) {
if (!params.length) { return; }
item.appendChild(el("h4", title));
var table = el("table");
var headTr = el("tr");
headTr.appendChild(el("th", "名称"));
headTr.appendChild(el("th", "说明"));
headTr.appendChild(el("th", "默认"));
var thead = el("thead");
thead.appendChild(headTr);
table.appendChild(thead);
var tbody = el("tbody");
params.forEach(function (p) {
var tr = el("tr");
tr.appendChild(el("td", p.name));
tr.appendChild(el("td", p.desc || ""));
tr.appendChild(el("td", p.def || "(空)"));
tbody.appendChild(tr);
});
table.appendChild(tbody);
item.appendChild(table);
}
/* 试一下面板内按 kindpath/query/form)渲染参数输入框 */
function renderTryInputs(panel, kind, params) {
params.forEach(function (p) {
var field = el("div", null, "field");
field.appendChild(el("label", p.name + (p.desc ? "" + p.desc + "" : "")));
var input = el("input");
input.type = "text";
input.className = "try-" + kind + "-input";
input.setAttribute("data-param", p.name);
input.value = p.def || "";
input.placeholder = p.desc || p.name;
field.appendChild(input);
panel.appendChild(field);
});
}
function renderTryResult(panel, status, elapsed, text) {
var result = panel.querySelector(".try-result");
result.querySelector(".try-status").textContent = String(status);
result.querySelector(".try-elapsed").textContent = elapsed + " ms";
var pretty = text;
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 非 JSON 响应原样展示 */ }
result.querySelector(".try-result-pre").textContent = pretty;
result.classList.remove("hidden");
}
function sendApiTry(idx, panel) {
var ep = API_GUIDE[idx];
var errBox = panel.querySelector(".try-error");
var sendBtn = panel.querySelector(".try-send");
function fail(message) {
errBox.textContent = message;
errBox.classList.remove("hidden");
}
errBox.classList.add("hidden");
/* path 参数:必填,替换 path 模板占位 */
var pathValues = {};
var pathInputs = panel.querySelectorAll(".try-path-input");
for (var i = 0; i < pathInputs.length; i++) {
var pName = pathInputs[i].getAttribute("data-param");
var pVal = pathInputs[i].value.trim();
if (!pVal) {
fail("请填写 path 参数:" + pName);
return;
}
pathValues[pName] = encodeURIComponent(pVal);
}
var url = substitutePath(ep, pathValues);
/* query 参数:仅拼接非空值 */
var pairs = [];
var queryInputs = panel.querySelectorAll(".try-query-input");
for (var j = 0; j < queryInputs.length; j++) {
var qVal = queryInputs[j].value.trim();
if (qVal) {
pairs.push(queryInputs[j].getAttribute("data-param") + "=" + encodeURIComponent(qVal));
}
}
if (pairs.length) { url += "?" + pairs.join("&"); }
var options = { method: ep.method, headers: {} };
if (ep.upload) {
var fileInput = panel.querySelector(".try-file");
if (!fileInput.files || fileInput.files.length === 0) {
fail("请选择要上传的文件");
return;
}
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
var formData = new FormData();
formData.append("file", fileInput.files[0]);
var formInputs = panel.querySelectorAll(".try-form-input");
for (var k = 0; k < formInputs.length; k++) {
var fVal = formInputs[k].value.trim();
if (fVal) { formData.append(formInputs[k].getAttribute("data-param"), fVal); }
}
options.body = formData;
} else if (ep.body) {
var raw = panel.querySelector(".try-body").value;
try {
JSON.parse(raw);
} catch (e) {
fail("Body 不是合法 JSON,未发送请求:" + (e && e.message ? e.message : e));
return;
}
options.headers["Content-Type"] = "application/json";
options.body = raw;
}
if (ep.auth !== "none") {
var token = getToken();
if (token) { options.headers["Authorization"] = "Bearer " + token; }
}
sendBtn.disabled = true;
var started = performance.now();
fetch(url, options).then(function (resp) {
var elapsed = Math.round(performance.now() - started);
return resp.text().then(function (text) {
renderTryResult(panel, resp.status, elapsed, text);
});
}).catch(function (err) {
renderTryResult(panel, "请求失败", Math.round(performance.now() - started),
String(err && err.message ? err.message : err));
}).finally(function () {
sendBtn.disabled = false;
});
}
function renderApiItem(ep, idx) {
var item = el("div", null, "api-item");
var head = el("div", null, "api-head");
head.appendChild(el("span", ep.method, "method-badge method-" + ep.method.toLowerCase()));
var code = el("code", ep.path);
head.appendChild(code);
head.appendChild(el("span", API_AUTH_LABELS[ep.auth] || ep.auth, "auth-badge auth-" + ep.auth));
item.appendChild(head);
item.appendChild(el("div", ep.desc, "api-desc"));
renderParamTable(item, "Path 参数", ep.pathParams);
renderParamTable(item, "Query 参数", ep.query);
renderParamTable(item, "表单字段", ep.form);
if (ep.body) {
item.appendChild(el("h4", "Body 示例(JSON"));
item.appendChild(el("pre", JSON.stringify(ep.body, null, 2), "json-pre"));
}
item.appendChild(el("h4", "curl 示例"));
var curlBox = el("div", null, "curl-box");
var curlPre = el("pre", buildCurl(ep), "curl-pre");
var copyBtn = el("button", "复制", "action curl-copy");
copyBtn.type = "button";
copyBtn.addEventListener("click", function () { copyText(curlPre.textContent, copyBtn); });
curlBox.appendChild(copyBtn);
curlBox.appendChild(curlPre);
item.appendChild(curlBox);
/* 「试一下」面板:只能从清单展开,不提供任意 URL 输入框 */
var toggleBtn = el("button", "试一下", "action try-toggle");
toggleBtn.type = "button";
var panel = el("div", null, "try-panel hidden");
toggleBtn.addEventListener("click", function () { panel.classList.toggle("hidden"); });
item.appendChild(toggleBtn);
renderTryInputs(panel, "path", ep.pathParams);
renderTryInputs(panel, "query", ep.query);
if (ep.upload) {
renderTryInputs(panel, "form", ep.form);
var fileField = el("div", null, "field");
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx"));
var fileInput = el("input");
fileInput.type = "file";
fileInput.className = "try-file";
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
fileField.appendChild(fileInput);
panel.appendChild(fileField);
}
if (ep.body) {
var bodyField = el("div", null, "field");
bodyField.appendChild(el("label", "BodyJSON,可编辑)"));
bodyField.appendChild(el("textarea", JSON.stringify(ep.body, null, 2), "try-body"));
panel.appendChild(bodyField);
}
var sendBtn = el("button", "发送", "primary try-send");
sendBtn.type = "button";
sendBtn.addEventListener("click", function () { sendApiTry(idx, panel); });
panel.appendChild(sendBtn);
panel.appendChild(el("div", null, "error-bar hidden try-error"));
var result = el("div", null, "result-box hidden try-result");
var statusKv = el("div", null, "kv");
statusKv.appendChild(el("span", "状态码", "k"));
statusKv.appendChild(el("span", null, "try-status"));
result.appendChild(statusKv);
var elapsedKv = el("div", null, "kv");
elapsedKv.appendChild(el("span", "耗时", "k"));
elapsedKv.appendChild(el("span", null, "try-elapsed"));
result.appendChild(elapsedKv);
result.appendChild(el("pre", null, "try-result-pre"));
panel.appendChild(result);
item.appendChild(panel);
return item;
}
function renderApiGuide() {
var container = document.getElementById("api-guide-list");
clearChildren(container);
API_GUIDE.forEach(function (ep, idx) {
container.appendChild(renderApiItem(ep, idx));
});
}
/* ---------- 初始化 ---------- */
(function init() {
@@ -877,9 +1538,16 @@ function loadCategories() {
showLogin();
return;
}
/* 本地有 token 时先调 /auth/me 验证登录态 */
api("/api/v1/auth/me").then(function (user) {
afterLogin(user);
}).catch(function () {
saveAuth(getToken(), user);
afterAuth(user);
}).catch(function (err) {
if (err && err.code === 1006) {
/* must_change_password 用户被 /me 拦截:强制先改密(后端放行 /auth/password */
openPasswordModal(true);
return;
}
showLogin();
});
})();