chore: 完成全量功能迭代与部署准备

- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
This commit is contained in:
2026-08-01 00:02:42 +08:00
parent f92eff6f65
commit 6c6f690788
19 changed files with 2000 additions and 485 deletions
+4 -4
View File
@@ -4,7 +4,7 @@
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
"""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
@@ -67,7 +67,7 @@ def verify_password(password: str, hashed: str) -> bool:
def create_access_token(username: str, role: str) -> tuple[str, int]:
"""签发 JWT,返回 (token, expires_in_seconds)"""
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": username,
@@ -136,7 +136,7 @@ class UserStore:
user = StoredUser(
username=username,
role=role,
created_at=datetime.now(UTC),
created_at=datetime.now(timezone.utc),
hashed_password=hash_password(password),
)
try:
@@ -203,7 +203,7 @@ async def get_current_user(
)
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
return AuthUser(username=username, role=role, created_at=datetime.now(UTC))
return AuthUser(username=username, role=role, created_at=datetime.now(timezone.utc))
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
+44 -4
View File
@@ -11,7 +11,7 @@ import hmac
import json
import re
import secrets
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from redis import asyncio as redis_async
@@ -41,6 +41,10 @@ class UserStoreError(Exception):
"""用户存储后端读写异常"""
class UserNotFoundError(Exception):
"""用户不存在"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
@@ -50,7 +54,9 @@ class UserRecord:
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
created_at: str # UTC ISO8601
enabled: bool = True
# created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
class UserStore:
@@ -76,6 +82,7 @@ class UserStore:
password: str,
role: str = "user",
must_change_password: bool = False,
enabled: bool = True,
) -> UserRecord:
"""创建用户
@@ -94,6 +101,7 @@ class UserStore:
password_hash=self.hash_password(password, salt),
salt=salt.hex(),
must_change_password=must_change_password,
enabled=enabled,
created_at=datetime.now(UTC).isoformat(),
)
await self._write(record)
@@ -104,7 +112,7 @@ class UserStore:
raw = await self._read_raw(username)
if raw is None:
return None
return UserRecord(**json.loads(raw))
return self._from_raw(raw)
async def list(self) -> list[UserRecord]:
"""列出全部用户(扫描 user:* 键)"""
@@ -115,7 +123,15 @@ class UserStore:
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]
return [self._from_raw(raw) for raw in raws if raw is not None]
@staticmethod
def _from_raw(raw: str) -> UserRecord:
"""从 JSON 反序列化 UserRecord;存量记录无 enabled 字段时按 True 兼容"""
data = json.loads(raw)
if "enabled" not in data:
data["enabled"] = True
return UserRecord(**data)
async def delete(self, username: str) -> bool:
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
@@ -158,6 +174,30 @@ class UserStore:
"""统计 admin 角色用户数"""
return sum(1 for record in await self.list() if record.role == "admin")
async def update_user(
self,
username: str,
*,
role: str | None = None,
enabled: bool | None = None,
) -> UserRecord:
"""更新用户角色或启用状态
用户不存在抛 UserNotFoundErrorrole 非 admin/user 抛 ValueError。
role/enabled 为 None 表示不修改对应字段。更新后写回并返回最新记录。
"""
record = await self.get(username)
if record is None:
raise UserNotFoundError(f"用户不存在: {username}")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"非法角色: {role!r}(须为 admin/user")
if role is not None:
record.role = role
if enabled is not None:
record.enabled = enabled
await self._write(record)
return record
async def _read_raw(self, username: str) -> str | None:
if self._redis is None:
return self._memory.get(username)