提供基本前后端骨架

This commit is contained in:
hisatri
2026-01-06 23:49:23 +08:00
parent 84d4ccc226
commit 06f8176e23
89 changed files with 19293 additions and 2 deletions

29
app/core/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
"""核心功能模块"""
from .config import Settings, get_settings, settings
from .config_loader import (
ConfigLoader,
ConfigSource,
ConfigSourceError,
EnvConfigSource,
YamlConfigSource,
config_loader,
create_config_loader,
get_config_loader,
)
__all__ = [
# 配置系统
"Settings",
"get_settings",
"settings",
# 配置加载器
"ConfigLoader",
"ConfigSource",
"ConfigSourceError",
"EnvConfigSource",
"YamlConfigSource",
"config_loader",
"create_config_loader",
"get_config_loader",
]

202
app/core/config.py Normal file
View File

@@ -0,0 +1,202 @@
"""
应用配置管理
使用 Pydantic Settings 进行类型安全的配置管理,
集成 ConfigLoader 支持环境变量和 YAML 配置文件。
配置优先级(从高到低):
1. 环境变量(前缀 SATONANO_
2. config.yaml 文件
3. .env 文件
4. 默认值
"""
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from pydantic import Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .config_loader import create_config_loader
def _yaml_settings_source() -> dict[str, Any]:
"""
YAML 配置源工厂函数
为 Pydantic Settings 提供 YAML 配置数据
"""
# 查找配置文件路径
config_paths = [
Path("config.yaml"),
Path("config.yml"),
Path(__file__).parent.parent.parent / "config.yaml",
Path(__file__).parent.parent.parent / "config.yml",
]
yaml_path = None
for path in config_paths:
if path.exists():
yaml_path = path
break
if yaml_path is None:
return {}
# 使用 ConfigLoader 加载配置(不带环境变量前缀,让 pydantic 处理)
loader = create_config_loader(
yaml_path=yaml_path,
env_prefix="", # 不读取环境变量,由 pydantic-settings 处理
yaml_required=False,
)
return loader.load()
class Settings(BaseSettings):
"""应用配置"""
model_config = SettingsConfigDict(
env_prefix="SATONANO_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# 应用基础配置
app_name: str = "SatoNano"
app_version: str = "0.1.0"
debug: bool = False
environment: Literal["development", "staging", "production"] = "development"
# API 配置
api_v1_prefix: str = "/api/v1"
# 安全配置
secret_key: str = Field(
default="CHANGE-THIS-SECRET-KEY-IN-PRODUCTION",
description="JWT 签名密钥,生产环境必须更改",
)
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 7
# 数据库配置
database_type: Literal["sqlite", "mysql", "postgresql"] = "sqlite"
database_host: str = "localhost"
database_port: int = 3306
database_name: str = "satonano"
database_username: str = ""
database_password: str = ""
database_sqlite_path: str = "./satonano.db"
database_echo: bool = False
# OAuth2 配置 (Linux.do)
oauth2_client_id: str = ""
oauth2_client_secret: str = ""
oauth2_callback_path: str = "/oauth2/callback"
# 首选端点
oauth2_authorize_endpoint: str = "https://connect.linux.do/oauth2/authorize"
oauth2_token_endpoint: str = "https://connect.linux.do/oauth2/token"
oauth2_user_info_endpoint: str = "https://connect.linux.do/api/user"
# 备用端点
oauth2_authorize_endpoint_reserve: str = "https://connect.linuxdo.org/oauth2/authorize"
oauth2_token_endpoint_reserve: str = "https://connect.linuxdo.org/oauth2/token"
oauth2_user_info_endpoint_reserve: str = "https://connect.linuxdo.org/api/user"
oauth2_request_timeout: int = 10 # 请求超时时间(秒)
# 密码策略
password_min_length: int = 8
password_max_length: int = 128
password_require_uppercase: bool = True
password_require_lowercase: bool = True
password_require_digit: bool = True
password_require_special: bool = False
# 用户名策略
username_min_length: int = 3
username_max_length: int = 32
# 前端静态文件配置
frontend_static_path: str = "./frontend/out"
@computed_field
@property
def database_url(self) -> str:
"""
动态构建数据库连接 URL
根据 database_type 自动生成对应的连接字符串
"""
if self.database_type == "sqlite":
return f"sqlite+aiosqlite:///{self.database_sqlite_path}"
if self.database_type == "mysql":
return (
f"mysql+aiomysql://{self.database_username}:{self.database_password}"
f"@{self.database_host}:{self.database_port}/{self.database_name}"
)
if self.database_type == "postgresql":
return (
f"postgresql+asyncpg://{self.database_username}:{self.database_password}"
f"@{self.database_host}:{self.database_port}/{self.database_name}"
)
return f"sqlite+aiosqlite:///{self.database_sqlite_path}"
@computed_field
@property
def is_production(self) -> bool:
"""是否为生产环境"""
return self.environment == "production"
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: Any,
env_settings: Any,
dotenv_settings: Any,
file_secret_settings: Any,
) -> tuple[Any, ...]:
"""
自定义配置源优先级
优先级顺序(从高到低):
1. 初始化参数
2. 环境变量
3. YAML 文件
4. .env 文件
5. 文件密钥
"""
class YamlConfigSettingsSource:
"""YAML 配置源适配器"""
def __init__(self, settings_cls: type[BaseSettings]) -> None:
self.settings_cls = settings_cls
self._yaml_data: dict[str, Any] | None = None
def __call__(self) -> dict[str, Any]:
if self._yaml_data is None:
self._yaml_data = _yaml_settings_source()
return self._yaml_data
return (
init_settings,
env_settings,
YamlConfigSettingsSource(settings_cls),
dotenv_settings,
file_secret_settings,
)
@lru_cache
def get_settings() -> Settings:
"""获取配置单例"""
return Settings()
settings = get_settings()

387
app/core/config_loader.py Normal file
View File

@@ -0,0 +1,387 @@
"""
配置加载器模块
提供统一的配置加载机制,支持多数据源配置合并:
- 环境变量(优先级最高)
- YAML 配置文件
- 默认值(优先级最低)
设计原则:
- 单一职责:仅负责配置的加载和合并
- 依赖倒置:通过抽象接口解耦配置源
- 开闭原则:易于扩展新的配置源
"""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from functools import lru_cache
from pathlib import Path
from typing import Any, TypeVar
import yaml
T = TypeVar("T")
class ConfigSourceError(Exception):
"""配置源加载错误"""
def __init__(self, source: str, message: str) -> None:
self.source = source
self.message = message
super().__init__(f"[{source}] {message}")
class ConfigSource(ABC):
"""配置源抽象基类"""
@property
@abstractmethod
def name(self) -> str:
"""配置源名称"""
...
@property
@abstractmethod
def priority(self) -> int:
"""优先级,数值越大优先级越高"""
...
@abstractmethod
def load(self) -> dict[str, Any]:
"""加载配置,返回扁平化的键值对"""
...
class EnvConfigSource(ConfigSource):
"""环境变量配置源"""
def __init__(self, prefix: str = "") -> None:
"""
初始化环境变量配置源
Args:
prefix: 环境变量前缀,用于过滤相关配置
例如 prefix="SATONANO_" 时只读取以此为前缀的变量
"""
self._prefix = prefix.upper()
@property
def name(self) -> str:
return "environment"
@property
def priority(self) -> int:
return 100 # 最高优先级
def load(self) -> dict[str, Any]:
"""
加载环境变量
Returns:
配置字典,键名转换为小写并移除前缀
"""
config: dict[str, Any] = {}
prefix_len = len(self._prefix)
for key, value in os.environ.items():
if self._prefix and not key.upper().startswith(self._prefix):
continue
# 移除前缀并转为小写
config_key = key[prefix_len:].lower() if self._prefix else key.lower()
config[config_key] = self._parse_value(value)
return config
@staticmethod
def _parse_value(value: str) -> Any:
"""
解析环境变量值,自动转换类型
Args:
value: 原始字符串值
Returns:
转换后的值bool/int/float/str
"""
# 布尔值
if value.lower() in ("true", "yes", "1", "on"):
return True
if value.lower() in ("false", "no", "0", "off"):
return False
# 整数
try:
return int(value)
except ValueError:
pass
# 浮点数
try:
return float(value)
except ValueError:
pass
return value
class YamlConfigSource(ConfigSource):
"""YAML 配置文件源"""
def __init__(
self,
file_path: str | Path = "config.yaml",
required: bool = False,
) -> None:
"""
初始化 YAML 配置源
Args:
file_path: 配置文件路径
required: 是否必须存在,为 True 时文件不存在会抛出异常
"""
self._file_path = Path(file_path)
self._required = required
@property
def name(self) -> str:
return f"yaml:{self._file_path}"
@property
def priority(self) -> int:
return 50 # 中等优先级
def load(self) -> dict[str, Any]:
"""
加载 YAML 配置文件
Returns:
配置字典
Raises:
ConfigSourceError: 文件不存在required=True或解析错误
"""
if not self._file_path.exists():
if self._required:
raise ConfigSourceError(
self.name,
f"配置文件不存在: {self._file_path.absolute()}",
)
return {}
try:
with open(self._file_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ConfigSourceError(self.name, f"YAML 解析错误: {e}") from e
if data is None:
return {}
# 处理列表格式(兼容用户示例格式)
if isinstance(data, list):
return self._flatten_list(data)
# 标准字典格式
if isinstance(data, dict):
return self._normalize_keys(data)
raise ConfigSourceError(
self.name,
f"不支持的配置格式,期望 dict 或 list得到 {type(data).__name__}",
)
@staticmethod
def _flatten_list(items: list) -> dict[str, Any]:
"""
将列表格式配置展平为字典
支持格式:
- key: value
- key: value
"""
result: dict[str, Any] = {}
for item in items:
if isinstance(item, dict):
for key, value in item.items():
result[key.lower()] = value
return result
@staticmethod
def _normalize_keys(data: dict) -> dict[str, Any]:
"""键名标准化为小写"""
return {k.lower(): v for k, v in data.items()}
class ConfigLoader:
"""
配置加载器
负责从多个配置源加载并合并配置,按优先级覆盖。
Example:
>>> loader = ConfigLoader()
>>> loader.add_source(YamlConfigSource("config.yaml"))
>>> loader.add_source(EnvConfigSource("SATONANO_"))
>>> config = loader.load()
>>> db_type = loader.get("database_type", "sqlite")
"""
def __init__(self) -> None:
self._sources: list[ConfigSource] = []
self._config: dict[str, Any] = {}
self._loaded = False
def add_source(self, source: ConfigSource) -> ConfigLoader:
"""
添加配置源
Args:
source: 配置源实例
Returns:
self支持链式调用
"""
self._sources.append(source)
self._loaded = False # 标记需要重新加载
return self
def load(self) -> dict[str, Any]:
"""
加载并合并所有配置源
Returns:
合并后的配置字典
"""
# 按优先级升序排序,后加载的覆盖先加载的
sorted_sources = sorted(self._sources, key=lambda s: s.priority)
self._config = {}
for source in sorted_sources:
source_config = source.load()
self._config.update(source_config)
self._loaded = True
return self._config.copy()
def get(self, key: str, default: T = None) -> T | Any:
"""
获取配置值
Args:
key: 配置键名(不区分大小写)
default: 默认值
Returns:
配置值或默认值
"""
if not self._loaded:
self.load()
return self._config.get(key.lower(), default)
def get_str(self, key: str, default: str = "") -> str:
"""获取字符串配置"""
value = self.get(key, default)
return str(value) if value is not None else default
def get_int(self, key: str, default: int = 0) -> int:
"""获取整数配置"""
value = self.get(key, default)
if isinstance(value, int):
return value
try:
return int(value)
except (ValueError, TypeError):
return default
def get_float(self, key: str, default: float = 0.0) -> float:
"""获取浮点数配置"""
value = self.get(key, default)
if isinstance(value, float):
return value
try:
return float(value)
except (ValueError, TypeError):
return default
def get_bool(self, key: str, default: bool = False) -> bool:
"""获取布尔配置"""
value = self.get(key, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ("true", "yes", "1", "on")
return bool(value)
def require(self, key: str) -> Any:
"""
获取必需的配置值
Args:
key: 配置键名
Returns:
配置值
Raises:
KeyError: 配置不存在
"""
if not self._loaded:
self.load()
key_lower = key.lower()
if key_lower not in self._config:
raise KeyError(f"缺少必需的配置项: {key}")
return self._config[key_lower]
def all(self) -> dict[str, Any]:
"""获取所有配置的副本"""
if not self._loaded:
self.load()
return self._config.copy()
def __contains__(self, key: str) -> bool:
"""检查配置是否存在"""
if not self._loaded:
self.load()
return key.lower() in self._config
def __repr__(self) -> str:
sources = ", ".join(s.name for s in self._sources)
return f"ConfigLoader(sources=[{sources}], loaded={self._loaded})"
def create_config_loader(
yaml_path: str | Path = "config.yaml",
env_prefix: str = "SATONANO_",
yaml_required: bool = False,
) -> ConfigLoader:
"""
创建预配置的配置加载器
Args:
yaml_path: YAML 配置文件路径
env_prefix: 环境变量前缀
yaml_required: YAML 文件是否必须存在
Returns:
配置好的 ConfigLoader 实例
"""
loader = ConfigLoader()
loader.add_source(YamlConfigSource(yaml_path, required=yaml_required))
loader.add_source(EnvConfigSource(env_prefix))
return loader
@lru_cache
def get_config_loader() -> ConfigLoader:
"""获取全局配置加载器单例"""
return create_config_loader()
# 便捷访问的全局实例
config_loader = get_config_loader()

224
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,224 @@
"""
自定义异常类
定义业务层面的异常,便于统一处理和返回合适的 HTTP 响应。
"""
from typing import Any
class AppException(Exception):
"""应用基础异常"""
def __init__(
self,
message: str,
code: str = "APP_ERROR",
details: dict[str, Any] | None = None,
):
self.message = message
self.code = code
self.details = details or {}
super().__init__(message)
class AuthenticationError(AppException):
"""认证错误"""
def __init__(
self,
message: str = "认证失败",
code: str = "AUTHENTICATION_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(message, code, details)
class InvalidCredentialsError(AuthenticationError):
"""无效凭证"""
def __init__(self, message: str = "用户名或密码错误"):
super().__init__(message, "INVALID_CREDENTIALS")
class TokenError(AuthenticationError):
"""令牌错误"""
def __init__(self, message: str = "令牌无效或已过期"):
super().__init__(message, "TOKEN_ERROR")
class TokenExpiredError(TokenError):
"""令牌过期"""
def __init__(self, message: str = "令牌已过期"):
super().__init__(message)
self.code = "TOKEN_EXPIRED"
class AuthorizationError(AppException):
"""授权错误"""
def __init__(
self,
message: str = "权限不足",
code: str = "AUTHORIZATION_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(message, code, details)
class ValidationError(AppException):
"""验证错误"""
def __init__(
self,
message: str = "数据验证失败",
code: str = "VALIDATION_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(message, code, details)
class ResourceNotFoundError(AppException):
"""资源未找到"""
def __init__(
self,
message: str = "资源不存在",
resource_type: str = "resource",
resource_id: Any = None,
):
super().__init__(
message,
"RESOURCE_NOT_FOUND",
{"resource_type": resource_type, "resource_id": resource_id},
)
class ResourceConflictError(AppException):
"""资源冲突(如重复创建)"""
def __init__(
self,
message: str = "资源已存在",
code: str = "RESOURCE_CONFLICT",
details: dict[str, Any] | None = None,
):
super().__init__(message, code, details)
class UserNotFoundError(ResourceNotFoundError):
"""用户不存在"""
def __init__(self, user_id: Any = None):
super().__init__("用户不存在", "user", user_id)
class UserAlreadyExistsError(ResourceConflictError):
"""用户已存在"""
def __init__(self, field: str = "username"):
super().__init__(
f"{field}已被注册",
"USER_ALREADY_EXISTS",
{"field": field},
)
class UserDisabledError(AuthenticationError):
"""用户被禁用"""
def __init__(self):
super().__init__("账户已被禁用", "USER_DISABLED")
class PasswordValidationError(ValidationError):
"""密码验证错误"""
def __init__(self, message: str = "密码不符合要求"):
super().__init__(message, "PASSWORD_VALIDATION_ERROR")
# ============================================================
# 余额相关异常
# ============================================================
class InsufficientBalanceError(AppException):
"""余额不足"""
def __init__(self, required: int, available: int):
super().__init__(
f"余额不足,需要 {required / 1000:.2f},当前可用 {available / 1000:.2f}",
"INSUFFICIENT_BALANCE",
{"required_units": required, "available_units": available},
)
class DuplicateTransactionError(AppException):
"""重复交易"""
def __init__(self, idempotency_key: str):
super().__init__(
"该交易已处理",
"DUPLICATE_TRANSACTION",
{"idempotency_key": idempotency_key},
)
class ConcurrencyError(AppException):
"""并发冲突"""
def __init__(self):
super().__init__(
"操作冲突,请重试",
"CONCURRENCY_ERROR",
)
# ============================================================
# 兑换码相关异常
# ============================================================
class RedeemCodeNotFoundError(AppException):
"""兑换码不存在"""
def __init__(self, code: str):
super().__init__(
"兑换码不存在",
"REDEEM_CODE_NOT_FOUND",
{"code": code},
)
class RedeemCodeInvalidError(AppException):
"""兑换码无效"""
def __init__(self, code: str, reason: str):
super().__init__(
f"兑换码无效: {reason}",
"REDEEM_CODE_INVALID",
{"code": code, "reason": reason},
)
class RedeemCodeExpiredError(AppException):
"""兑换码已过期"""
def __init__(self, code: str):
super().__init__(
"兑换码已过期",
"REDEEM_CODE_EXPIRED",
{"code": code},
)
class RedeemCodeUsedError(AppException):
"""兑换码已使用"""
def __init__(self, code: str):
super().__init__(
"兑换码已使用",
"REDEEM_CODE_USED",
{"code": code},
)

155
app/core/security.py Normal file
View File

@@ -0,0 +1,155 @@
"""
安全相关功能
包括密码哈希、JWT 令牌生成与验证。
使用 Argon2 作为密码哈希算法(目前最安全的选择)。
"""
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from app.core.config import settings
# Argon2 密码哈希器,使用推荐的安全参数
_password_hasher = PasswordHasher(
time_cost=3, # 迭代次数
memory_cost=65536, # 内存使用 (64MB)
parallelism=4, # 并行度
)
def hash_password(password: str) -> str:
"""
对密码进行哈希处理
Args:
password: 明文密码
Returns:
Argon2 哈希字符串
"""
return _password_hasher.hash(password)
def verify_password(password: str, hashed_password: str) -> bool:
"""
验证密码是否匹配
Args:
password: 明文密码
hashed_password: 已哈希的密码
Returns:
密码是否正确
"""
try:
_password_hasher.verify(hashed_password, password)
return True
except (VerifyMismatchError, InvalidHashError):
return False
def password_needs_rehash(hashed_password: str) -> bool:
"""
检查密码是否需要重新哈希(参数升级时使用)
Args:
hashed_password: 已哈希的密码
Returns:
是否需要重新哈希
"""
return _password_hasher.check_needs_rehash(hashed_password)
def create_access_token(
subject: str | int,
expires_delta: timedelta | None = None,
extra_claims: dict[str, Any] | None = None,
) -> str:
"""
创建访问令牌
Args:
subject: 令牌主体通常是用户ID
expires_delta: 过期时间增量
extra_claims: 额外的声明数据
Returns:
JWT 访问令牌
"""
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(subject),
"iat": now,
"exp": expire,
"type": "access",
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(
subject: str | int,
expires_delta: timedelta | None = None,
) -> str:
"""
创建刷新令牌
Args:
subject: 令牌主体通常是用户ID
expires_delta: 过期时间增量
Returns:
JWT 刷新令牌
"""
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(days=settings.refresh_token_expire_days)
payload = {
"sub": str(subject),
"iat": now,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""
解码并验证 JWT 令牌
Args:
token: JWT 令牌字符串
Returns:
解码后的负载数据
Raises:
jwt.InvalidTokenError: 令牌无效
jwt.ExpiredSignatureError: 令牌已过期
"""
return jwt.decode(
token,
settings.secret_key,
algorithms=[settings.algorithm],
)