第一步
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""全局配置:pydantic-settings,全部来自环境变量 / .env。
|
||||
|
||||
规则(docs/technical-review.md §1.3):
|
||||
- 密钥不得硬编码;SECRET_KEY 缺失或仍为模板值时,生产环境拒绝启动。
|
||||
- DATABASE_URL 可切换 PostgreSQL(扩展接口 1)。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_TEMPLATE_MARKERS = ("change-me",)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# --- 基础 ---
|
||||
environment: str = "local" # local / production
|
||||
secret_key: str = Field(min_length=16)
|
||||
|
||||
# --- 数据库 ---
|
||||
database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
# --- 文件存储 ---
|
||||
storage_root: str = "./data"
|
||||
|
||||
# --- 套餐 ---
|
||||
default_storage_quota: int = 104_857_600 # 100 MB
|
||||
default_max_file_size: int = 20_971_520 # 20 MB
|
||||
|
||||
# --- 限流 ---
|
||||
rate_limit_per_token_per_min: int = 60
|
||||
rate_limit_per_ip_per_min: int = 30
|
||||
|
||||
# --- CORS ---
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.environment == "production"
|
||||
|
||||
@property
|
||||
def storage_root_path(self) -> Path:
|
||||
return Path(self.storage_root).resolve()
|
||||
|
||||
def validate_secrets(self) -> None:
|
||||
"""生产环境禁止携带模板密钥启动。"""
|
||||
if not self.is_production:
|
||||
return
|
||||
for field_name in ("secret_key",):
|
||||
value = getattr(self, field_name).lower()
|
||||
if any(marker in value for marker in _TEMPLATE_MARKERS):
|
||||
raise RuntimeError(
|
||||
f"配置错误:{field_name} 仍为模板值,生产环境禁止启动。"
|
||||
"请运行: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
s = Settings() # type: ignore[call-arg]
|
||||
s.validate_secrets()
|
||||
return s
|
||||
@@ -0,0 +1,76 @@
|
||||
"""SQLAlchemy 2.x 同步引擎 + 会话管理(MVP: SQLite)。
|
||||
|
||||
切换 PostgreSQL 时(扩展接口 1):
|
||||
1. DATABASE_URL 改为 postgresql+asyncpg://...
|
||||
2. 引擎换 create_async_engine + async_sessionmaker
|
||||
3. get_session 改 async generator + yield
|
||||
4. 业务层 Repository 调用加 await
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
# SQLite 专属:启用 WAL 模式(并发读 + 写串行化)
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
|
||||
)
|
||||
# SQLite: 启用 WAL 模式与外键约束
|
||||
if "sqlite" in settings.database_url:
|
||||
from sqlalchemy import event, text
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_conn, _): # type: ignore[no-untyped-def]
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = sessionmaker(
|
||||
get_engine(),
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""FastAPI 依赖:请求级会话。"""
|
||||
factory = get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def dispose_engine() -> None:
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""统一业务异常与错误响应体(需求 §37 → 统一 code+message+detail)。
|
||||
|
||||
对外格式:{"code": "...", "message": "中文文案", "detail": null}
|
||||
未捕获异常兜底为 500 INTERNAL_ERROR,原始异常只进日志。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""业务异常基类。"""
|
||||
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
code: str = "INTERNAL_ERROR"
|
||||
message: str = "服务内部错误,请稍后重试。"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
detail: Any = None,
|
||||
code: str | None = None,
|
||||
) -> None:
|
||||
self.detail = detail
|
||||
if message is not None:
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_response(self) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=self.status_code,
|
||||
content={"code": self.code, "message": self.message, "detail": self.detail},
|
||||
)
|
||||
|
||||
|
||||
# --- 通用 ---
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
code = "NOT_FOUND"
|
||||
message = "资源不存在。"
|
||||
|
||||
|
||||
class PermissionDeniedError(AppError):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
code = "PERMISSION_DENIED"
|
||||
message = "没有权限访问该资源。"
|
||||
|
||||
|
||||
class AuthRequiredError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_REQUIRED"
|
||||
message = "请先登录。"
|
||||
|
||||
|
||||
class InvalidCredentialsError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_INVALID_CREDENTIALS"
|
||||
message = "用户名或密码错误。"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
code = "CONFLICT"
|
||||
message = "资源冲突。"
|
||||
|
||||
|
||||
class RateLimitedError(AppError):
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
code = "RATE_LIMITED"
|
||||
message = "请求过于频繁,请稍后再试。"
|
||||
|
||||
|
||||
# --- 上传与配额 ---
|
||||
|
||||
|
||||
class StorageQuotaExceededError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "STORAGE_QUOTA_EXCEEDED"
|
||||
message = "存储空间不足。"
|
||||
|
||||
|
||||
class FileTooLargeError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "FILE_TOO_LARGE"
|
||||
message = "单个文件大小超出限制。"
|
||||
|
||||
|
||||
class FileTypeUnsupportedError(AppError):
|
||||
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
code = "FILE_TYPE_UNSUPPORTED"
|
||||
message = "不支持的文件类型。"
|
||||
|
||||
|
||||
# --- 异常处理器 ---
|
||||
|
||||
|
||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||
if exc.status_code >= 500:
|
||||
logger.error("AppError %s: %s", exc.code, exc.message, exc_info=exc)
|
||||
return exc.to_response()
|
||||
|
||||
|
||||
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "请求参数不正确。",
|
||||
"detail": exc.errors(include_url=False, include_input=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_STATUS_CODE_MAP = {
|
||||
status.HTTP_404_NOT_FOUND: ("NOT_FOUND", "资源不存在。"),
|
||||
status.HTTP_405_METHOD_NOT_ALLOWED: ("METHOD_NOT_ALLOWED", "请求方法不被允许。"),
|
||||
status.HTTP_401_UNAUTHORIZED: ("AUTH_REQUIRED", "请先登录。"),
|
||||
}
|
||||
|
||||
|
||||
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""把框架层 HTTPException(如未匹配路由的 404)转成统一错误体。"""
|
||||
code, message = _STATUS_CODE_MAP.get(exc.status_code, ("HTTP_ERROR", str(exc.detail)))
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": code, "message": message, "detail": None},
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("Unhandled: %s %s", request.method, request.url.path, exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"code": "INTERNAL_ERROR", "message": "服务内部错误,请稍后重试。", "detail": None},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: Any) -> None:
|
||||
app.add_exception_handler(AppError, app_error_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_error_handler)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""结构化日志配置。"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
global _CONFIGURED
|
||||
if _CONFIGURED:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers = [handler]
|
||||
|
||||
for noisy in ("uvicorn.access", "asyncio"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""内存限流(MVP: 单进程 TokenBucket)。
|
||||
|
||||
未来切换 Redis: 实现 RedisRateLimiter,接口不变。
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class _Bucket:
|
||||
__slots__ = ("tokens", "max_tokens", "refill_rate", "last_refill")
|
||||
|
||||
def __init__(self, max_tokens: int, per_seconds: int) -> None:
|
||||
self.max_tokens = max_tokens
|
||||
self.refill_rate = max_tokens / per_seconds
|
||||
self.tokens = float(max_tokens)
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def allow(self) -> bool:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
self.tokens = min(self.max_tokens, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
if self.tokens >= 1.0:
|
||||
self.tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# key → Bucket
|
||||
_token_buckets: dict[str, _Bucket] = defaultdict(lambda: _Bucket(_token_limit, 60))
|
||||
_ip_buckets: dict[str, _Bucket] = defaultdict(lambda: _Bucket(_ip_limit, 60))
|
||||
|
||||
# 延迟初始化
|
||||
_token_limit = 60
|
||||
_ip_limit = 30
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_init() -> None:
|
||||
global _token_limit, _ip_limit, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
settings = get_settings()
|
||||
_token_limit = settings.rate_limit_per_token_per_min
|
||||
_ip_limit = settings.rate_limit_per_ip_per_min
|
||||
# 重建 default dict factories
|
||||
_token_buckets.default_factory = lambda: _Bucket(_token_limit, 60) # type: ignore[assignment]
|
||||
_ip_buckets.default_factory = lambda: _Bucket(_ip_limit, 60) # type: ignore[assignment]
|
||||
_initialized = True
|
||||
|
||||
|
||||
def check_rate_limit(token_key: str | None = None, ip_key: str | None = None) -> bool:
|
||||
"""检查是否允许请求。返回 True 表示允许。"""
|
||||
_ensure_init()
|
||||
if token_key and not _token_buckets[token_key].allow():
|
||||
return False
|
||||
if ip_key and not _ip_buckets[ip_key].allow():
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,74 @@
|
||||
"""服务端 Session 管理(MVP: 内存 dict + TTL)。
|
||||
|
||||
- Session token: secrets.token_urlsafe(32)
|
||||
- Cookie: HttpOnly, SameSite=Lax, 生产环境 Secure
|
||||
- 默认过期: 7 天
|
||||
|
||||
未来切换 Redis: 改 _store 为 Redis 客户端,接口不变。
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
# 内存存储:{token: SessionEntry}
|
||||
_store: dict[str, "SessionEntry"] = {}
|
||||
|
||||
SESSION_COOKIE_NAME = "session_id"
|
||||
SESSION_TTL_SECONDS = 7 * 24 * 3600 # 7 天
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionEntry:
|
||||
user_id: str
|
||||
created_at: float
|
||||
last_accessed: float
|
||||
|
||||
|
||||
def create_session(user_id: str) -> str:
|
||||
"""创建 session,返回 token(写入 Cookie)。"""
|
||||
_cleanup_expired()
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
_store[token] = SessionEntry(user_id=user_id, created_at=now, last_accessed=now)
|
||||
return token
|
||||
|
||||
|
||||
def get_session_user(token: str) -> str | None:
|
||||
"""根据 token 获取 user_id;不存在或过期返回 None。"""
|
||||
entry = _store.get(token)
|
||||
if entry is None:
|
||||
return None
|
||||
now = time.time()
|
||||
if now - entry.last_accessed > SESSION_TTL_SECONDS:
|
||||
_store.pop(token, None)
|
||||
return None
|
||||
entry.last_accessed = now
|
||||
return entry.user_id
|
||||
|
||||
|
||||
def delete_session(token: str) -> None:
|
||||
_store.pop(token, None)
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""惰性清理过期 session。"""
|
||||
now = time.time()
|
||||
expired = [t for t, e in _store.items() if now - e.last_accessed > SESSION_TTL_SECONDS]
|
||||
for t in expired:
|
||||
_store.pop(t, None)
|
||||
|
||||
|
||||
def get_cookie_params() -> dict:
|
||||
"""构建 Set-Cookie 参数。"""
|
||||
settings = get_settings()
|
||||
return {
|
||||
"key": SESSION_COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": settings.is_production,
|
||||
"max_age": SESSION_TTL_SECONDS,
|
||||
"path": "/",
|
||||
}
|
||||
Reference in New Issue
Block a user