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
+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]