提供基本前后端骨架
This commit is contained in:
29
app/core/__init__.py
Normal file
29
app/core/__init__.py
Normal 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
202
app/core/config.py
Normal 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
387
app/core/config_loader.py
Normal 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
224
app/core/exceptions.py
Normal 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
155
app/core/security.py
Normal 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],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user