feat: add theme preset system with admin CRUD, public listing, and user theme settings

- Add ChromaticColor (17 Tailwind colors) and NeutralColor (5 grays) enums
- Add ThemePreset table with flat color columns and unique name constraint
- Add admin theme endpoints (CRUD + set default) at /api/v1/admin/theme
- Add public theme listing at /api/v1/site/themes
- Add user theme settings (PATCH /theme) with color snapshot on User model
- User.color_* columns store per-user overrides; fallback to default preset then builtin
- Initialize default theme preset in migration
- Remove legacy defaultTheme/themes settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 19:34:41 +08:00
parent a99091ea7a
commit 4c1b7a8aad
29 changed files with 1832 additions and 404 deletions

View File

@@ -4,64 +4,64 @@ from fastapi import HTTPException, status
# --- 400 ---
def ensure_request_param(to_check: Any, *args, **kwargs) -> None:
def ensure_request_param(to_check: Any, detail: str | None = None) -> None:
"""
Ensures a parameter exists. If not, raises a 400 Bad Request.
This function returns None if the check passes.
"""
if not to_check:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def raise_bad_request(*args, **kwargs) -> NoReturn:
def raise_bad_request(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 400 Bad Request exception."""
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def raise_unauthorized(detail: str | None = None, *args, **kwargs) -> NoReturn:
def raise_unauthorized(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 401 Unauthorized exception."""
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
def raise_insufficient_quota(detail: str | None = None, *args, **kwargs) -> NoReturn:
def raise_insufficient_quota(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 402 Payment Required exception."""
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail)
def raise_forbidden(detail: str | None = None, *args, **kwargs) -> NoReturn:
def raise_forbidden(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 403 Forbidden exception."""
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
def raise_banned(detail: str = "此文件已被管理员封禁,仅允许删除操作", *args, **kwargs) -> NoReturn:
def raise_banned(detail: str = "此文件已被管理员封禁,仅允许删除操作") -> NoReturn:
"""Raises an HTTP 403 Forbidden exception for banned objects."""
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
def raise_not_found(detail: str | None = None, *args, **kwargs) -> NoReturn:
def raise_not_found(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 404 Not Found exception."""
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
def raise_conflict(*args, **kwargs) -> NoReturn:
def raise_conflict(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 409 Conflict exception."""
raise HTTPException(status_code=status.HTTP_409_CONFLICT, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
def raise_precondition_required(*args, **kwargs) -> NoReturn:
def raise_precondition_required(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 428 Precondition required exception."""
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail=detail)
def raise_too_many_requests(*args, **kwargs) -> NoReturn:
def raise_too_many_requests(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 429 Too Many Requests exception."""
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail)
# --- 500 ---
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员", *args, **kwargs) -> NoReturn:
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
"""Raises an HTTP 500 Internal Server Error exception."""
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
def raise_not_implemented(detail: str = "尚未支持这种方法", *args, **kwargs) -> NoReturn:
def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
"""Raises an HTTP 501 Not Implemented exception."""
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail)
def raise_service_unavailable(*args, **kwargs) -> NoReturn:
def raise_service_unavailable(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 503 Service Unavailable exception."""
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
def raise_gateway_timeout(*args, **kwargs) -> NoReturn:
def raise_gateway_timeout(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 504 Gateway Timeout exception."""
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, *args, **kwargs)
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=detail)

View File

@@ -3,13 +3,13 @@ from typing import Literal
from loguru import logger
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import VerifyMismatchError, VerificationError
from enum import StrEnum
import pyotp
from itsdangerous import URLSafeTimedSerializer
from pydantic import BaseModel, Field
from utils.JWT import SECRET_KEY
from utils import JWT
from utils.conf import appmeta
# FIRST RECOMMENDED option per RFC 9106.
@@ -57,7 +57,7 @@ class TwoFactorResponse(TwoFactorBase):
class TwoFactorVerifyRequest(TwoFactorBase):
"""两步验证-验证请求 DTO"""
code: int = Field(..., ge=100000, le=999999)
code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
"""6 位验证码"""
class Password:
@@ -126,6 +126,9 @@ class Password:
return PasswordStatus.VALID
except VerifyMismatchError:
return PasswordStatus.INVALID
except VerificationError:
logger.warning("密码哈希格式无效,无法解码,可能需要删库重建。")
return PasswordStatus.INVALID
@staticmethod
async def generate_totp(
@@ -138,7 +141,7 @@ class Password:
:return: 包含 TOTP 密钥和 URI 的元组
"""
serializer = URLSafeTimedSerializer(SECRET_KEY)
serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
secret = pyotp.random_base32()
@@ -159,7 +162,7 @@ class Password:
@staticmethod
def verify_totp(
secret: str,
code: int,
code: str,
*args, **kwargs
) -> PasswordStatus:
"""
@@ -173,7 +176,7 @@ class Password:
:return: 验证是否成功
"""
totp = pyotp.TOTP(secret)
if totp.verify(otp=str(code), *args, **kwargs):
if totp.verify(otp=code, *args, **kwargs):
return PasswordStatus.VALID
else:
return PasswordStatus.INVALID