第一步

This commit is contained in:
amb
2026-09-01 11:53:59 +08:00
commit 47bf6cc5ca
66 changed files with 6501 additions and 0 deletions
View File
+66
View File
@@ -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
+76
View File
@@ -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
+155
View File
@@ -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)
+31
View File
@@ -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)
+62
View File
@@ -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
+74
View File
@@ -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": "/",
}