├── app ├── __init__.py ├── api │ └── v1 │ │ ├── __init__.py │ │ ├── auth_route.py │ │ ├── new_hero_route.py │ │ ├── collections_route.py │ │ └── heroes_route.py ├── core │ ├── __init__.py │ ├── redis_db.py │ ├── security.py │ ├── exceptions.py │ ├── dependencies.py │ ├── config.py │ ├── database.py │ └── repository.py ├── domains │ ├── __init__.py │ ├── heroes │ │ ├── __init__.py │ │ ├── heroes_repo.py │ │ ├── heroes_services.py │ │ ├── heroes_serv.py │ │ └── heroes_repository.py │ ├── users │ │ ├── __init__.py │ │ ├── service_dependencies.py │ │ ├── user_repository.py │ │ ├── user_service.py │ │ └── auth_dependencies.py │ └── collections │ │ ├── __init__.py │ │ ├── collections_repository.py │ │ └── collections_service.py ├── schemas │ ├── __init__.py │ ├── auth.py │ ├── users.py │ ├── collections.py │ ├── heroes_filter.py │ ├── response.py │ └── heroes.py ├── models │ ├── base.py │ ├── __init__.py │ ├── heroes.py │ ├── users.py │ ├── mixin.py │ └── collections.py ├── lifespan.py └── main.py ├── .python-version ├── env.dev ├── alembic ├── README ├── script.py.mako ├── versions │ ├── 902aac14d239_add_powers_column_to_heroes_table.py │ ├── 42022bfd0269_intergrate_hero_with_dateimemixin.py │ ├── 6185a991de0c_initial_migration.py │ └── f321df2cf6a5_add_collections_table.py └── env.py ├── README.md ├── .dockerignore ├── .gitignore ├── .env.sample ├── pyproject.toml ├── LICENSE ├── Dockerfile ├── compose.yaml ├── fill_fake_heroes.py └── alembic.ini /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domains/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /app/domains/heroes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domains/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domains/collections/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /env.dev: -------------------------------------------------------------------------------- 1 | # env.dev 2 | ENVIRONMENT=dev -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acelee0621/fastapi-demo-project/HEAD/README.md -------------------------------------------------------------------------------- /app/models/base.py: -------------------------------------------------------------------------------- 1 | # app/models/base.py 2 | from sqlalchemy.orm import DeclarativeBase 3 | 4 | 5 | # --- 基类 --- 6 | class Base(DeclarativeBase): 7 | pass -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ # 忽略缓存 2 | .venv/ # 忽略虚拟环境 3 | tests/ # 忽略测试目录 4 | 5 | # 忽略 git 相关的内容 6 | .coverage 7 | .git 8 | .gitignore 9 | .python-version 10 | README.md 11 | LICENSE 12 | 13 | *.pyc 14 | *.pyo 15 | *.log -------------------------------------------------------------------------------- /app/schemas/auth.py: -------------------------------------------------------------------------------- 1 | # app/schemas/auth.py 2 | from pydantic import BaseModel 3 | 4 | 5 | class Token(BaseModel): 6 | access_token: str 7 | token_type: str = "bearer" 8 | 9 | 10 | class LoginData(BaseModel): 11 | username: str 12 | password: str -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv/ 11 | 12 | # Environment-specific files 13 | # 忽略所有目录下的 .env.* 文件 14 | **/.env.* 15 | # 但不要忽略 .env.sample 文件 16 | !.env.sample 17 | -------------------------------------------------------------------------------- /app/domains/users/service_dependencies.py: -------------------------------------------------------------------------------- 1 | # users/service_dependencies.py 2 | from fastapi import Depends 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from app.core.database import get_db 5 | from app.domains.users.user_repository import UserRepository 6 | from app.domains.users.user_service import UserService 7 | 8 | async def get_user_service(session: AsyncSession = Depends(get_db)) -> UserService: 9 | repository = UserRepository(session) 10 | return UserService(repository) -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # /.env.sample 2 | # 这是一个配置模板文件。请根据此模板创建你自己的 .env.dev 或 .env.prod 文件。 3 | 4 | # 应用配置 5 | DEMO_DEBUG=True 6 | DEMO_APP_NAME="FastAPI Demo Project (Dev)" 7 | 8 | # 安全配置 9 | DEMO_JWT_SECRET="your secret key" 10 | DEMO_JWT_ALGORITHM=HS256 11 | DEMO_JWT_EXPIRATION=30 12 | 13 | # 数据库配置 14 | DEMO_DB_HOST=localhost 15 | DEMO_DB_PORT=5432 16 | DEMO_DB_USER=postgres 17 | DEMO_DB_PASSWORD=postgres 18 | DEMO_DB_DB=tutorial 19 | 20 | DEMO_DB_POOL_SIZE=20 21 | DEMO_DB_MAX_OVERFLOW=10 22 | DEMO_DB_POOL_TIMEOUT=30 23 | DEMO_DB_POOL_RECYCLE=3600 24 | DEMO_DB_ECHO=False -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi-demo-project" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "alembic>=1.16.4", 9 | "argon2-cffi>=25.1.0", 10 | "asyncpg>=0.30.0", 11 | "fastapi-filter>=2.0.1", 12 | "fastapi-pagination[sqlalchemy]>=0.13.3", 13 | "fastapi[standard]>=0.116.1", 14 | "httptools>=0.6.4", 15 | "loguru>=0.7.3", 16 | "passlib>=1.7.4", 17 | "pydantic-settings>=2.10.1", 18 | "pyjwt>=2.10.1", 19 | "redis>=6.4.0", 20 | "sqlalchemy>=2.0.41", 21 | ] 22 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | # app/models/__init__.py 2 | 3 | # 写法 1 :手动导入 4 | from .base import Base 5 | from .users import User 6 | from .heroes import Hero 7 | from .collections import Collection 8 | 9 | # 可选:声明公开接口(清晰化模块导出) 10 | __all__ = ["Base", "User", "Hero", "Collection"] 11 | 12 | 13 | # 写法 2:动态导入 14 | # import importlib 15 | # import pkgutil 16 | 17 | # from .base import Base 18 | 19 | # # 把当前包(models)下除了 base.py 以外的所有 .py 文件都 import 一遍 20 | # for _, modelname, _ in pkgutil.iter_modules(__path__): 21 | # if modelname != "base": 22 | # importlib.import_module(f"{__name__}.{modelname}") 23 | 24 | # __all__ = ["Base"] 25 | -------------------------------------------------------------------------------- /app/schemas/users.py: -------------------------------------------------------------------------------- 1 | # app/schemas/user.py 2 | from pydantic import BaseModel, ConfigDict 3 | 4 | 5 | # 基础模型,包含所有用户共有的字段 6 | class UserBase(BaseModel): 7 | username: str 8 | 9 | 10 | # 创建用户时,从请求体中读取的模型 11 | # 需要提供密码 12 | class UserCreate(UserBase): 13 | password: str 14 | 15 | 16 | class UserInDB(UserBase): 17 | id: int 18 | password_hash: str 19 | 20 | model_config = ConfigDict(from_attributes=True) 21 | 22 | 23 | # 从数据库读取并返回给客户端的模型 24 | # 不应该包含密码,但应该包含 id 25 | class UserResponse(UserBase): 26 | id: int 27 | # Pydantic V2 的新配置方式 28 | model_config = ConfigDict( 29 | from_attributes=True 30 | ) # 告诉 Pydantic 模型可以从 ORM 对象属性中读取数据 31 | -------------------------------------------------------------------------------- /app/models/heroes.py: -------------------------------------------------------------------------------- 1 | # app/models/heroes.py 2 | from sqlalchemy import String, Integer, Text 3 | from sqlalchemy.orm import Mapped, mapped_column 4 | 5 | from app.models.base import Base 6 | from app.models.mixin import DateTimeMixin 7 | 8 | class Hero(Base, DateTimeMixin): 9 | __tablename__ = "heroes" 10 | 11 | id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 12 | name: Mapped[str] = mapped_column(String(100), nullable=False) 13 | alias: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) 14 | powers: Mapped[str | None] = mapped_column(Text, nullable=True) 15 | 16 | def __repr__(self) -> str: 17 | return f"" -------------------------------------------------------------------------------- /app/core/redis_db.py: -------------------------------------------------------------------------------- 1 | # app/core/redis_db.py 2 | from fastapi import Request 3 | from redis.asyncio import Redis 4 | from app.core.config import settings 5 | 6 | def create_auth_redis() -> Redis: 7 | return Redis.from_url( 8 | f"redis://{settings.REDIS_HOST}/1", 9 | max_connections=20, 10 | decode_responses=True, 11 | ) 12 | 13 | def create_cache_redis() -> Redis: 14 | return Redis.from_url( 15 | f"redis://{settings.REDIS_HOST}/2", 16 | max_connections=20, 17 | decode_responses=True, 18 | ) 19 | 20 | 21 | async def get_auth_redis(request: Request) -> Redis: 22 | return request.state.auth_redis 23 | 24 | async def get_cache_redis(request: Request) -> Redis: 25 | return request.state.cache_redis -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | """Downgrade schema.""" 28 | ${downgrades if downgrades else "pass"} 29 | -------------------------------------------------------------------------------- /app/schemas/collections.py: -------------------------------------------------------------------------------- 1 | # app/schemas/collections.py 2 | from pydantic import BaseModel, ConfigDict, Field 3 | from app.schemas.heroes import HeroResponse 4 | 5 | 6 | class CollectionBase(BaseModel): 7 | title: str 8 | description: str | None = None 9 | 10 | 11 | class CollectionCreate(CollectionBase): 12 | pass 13 | 14 | 15 | class CollectionUpdate(BaseModel): 16 | title: str | None = None 17 | description: str | None = None 18 | 19 | 20 | # 从数据库读取并返回给客户端的模型 21 | class CollectionResponse(CollectionBase): 22 | id: int 23 | 24 | 25 | model_config = ConfigDict(from_attributes=True) 26 | 27 | 28 | class CollectionResponseDetail(CollectionBase): 29 | id: int 30 | heroes: list["HeroResponse"] | None 31 | 32 | model_config = ConfigDict(from_attributes=True) 33 | 34 | 35 | class HeroAttachRequest(BaseModel): 36 | hero_id: int = Field(..., gt=0, description="要加入收藏的英雄 id") 37 | -------------------------------------------------------------------------------- /alembic/versions/902aac14d239_add_powers_column_to_heroes_table.py: -------------------------------------------------------------------------------- 1 | """add powers column to heroes table 2 | 3 | Revision ID: 902aac14d239 4 | Revises: 6185a991de0c 5 | Create Date: 2025-08-12 10:57:30.550975 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '902aac14d239' 16 | down_revision: Union[str, Sequence[str], None] = '6185a991de0c' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.add_column('heroes', sa.Column('powers', sa.Text(), nullable=True)) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade() -> None: 29 | """Downgrade schema.""" 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_column('heroes', 'powers') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 acelee0621 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/models/users.py: -------------------------------------------------------------------------------- 1 | # app/models/users.py 2 | from typing import Optional 3 | 4 | from sqlalchemy import String, Integer 5 | from sqlalchemy.orm import Mapped, mapped_column, relationship 6 | 7 | from app.models.base import Base 8 | from app.models.mixin import DateTimeMixin 9 | 10 | from typing import TYPE_CHECKING 11 | if TYPE_CHECKING: 12 | from app.models.collections import Collection 13 | 14 | 15 | class User(Base, DateTimeMixin): 16 | __tablename__ = "users" 17 | 18 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 19 | username: Mapped[str] = mapped_column( 20 | String(64), index=True, unique=True, nullable=False 21 | ) 22 | password_hash: Mapped[Optional[str]] = mapped_column(String(256)) 23 | # 一对多关系 User -> Collection 24 | collections: Mapped[list["Collection"]] = relationship( 25 | "Collection", 26 | back_populates="owner", 27 | cascade="all, delete-orphan", 28 | # lazy 默认是 "select",但我们查 user 时不希望自动加载 collections,保持默认即可 29 | ) 30 | 31 | def __repr__(self): 32 | return f"" 33 | -------------------------------------------------------------------------------- /app/schemas/heroes_filter.py: -------------------------------------------------------------------------------- 1 | # app/schemas/heroes_filter.py 2 | from fastapi_filter.contrib.sqlalchemy import Filter 3 | from app.models.heroes import Hero 4 | 5 | 6 | class HeroFilter(Filter): 7 | search: str | None = None 8 | order_by: list[str] = [] 9 | 10 | # 自定义排序实现 11 | def sort(self, query): 12 | # 让 fastapi-filter 先处理前端给的 order_by 13 | if self.ordering_values: 14 | query = super().sort(query) 15 | 16 | # 追加默认/固定排序 17 | if not any(v.lstrip("+-") == "name" for v in self.ordering_values): 18 | query = query.order_by(Hero.name.asc()) 19 | query = query.order_by(Hero.id.asc()) 20 | 21 | return query 22 | 23 | class Constants(Filter.Constants): 24 | model = Hero 25 | # 使用自定义字段名称,默认为 `order_by`和`search` 26 | # 如果要自定义字段名称,可以在这里指定,但上面的字段也要改 27 | # ordering_field_name = "sorting" 28 | # search_field_name = "find" 29 | ordering_field_name = "order_by" 30 | search_model_fields = [ 31 | "name", 32 | "alias", 33 | "powers", 34 | ] # 指定搜索字段,必须,否则搜索不起作用,会报错 35 | -------------------------------------------------------------------------------- /app/models/mixin.py: -------------------------------------------------------------------------------- 1 | # app/models/mixin.py 2 | from datetime import datetime 3 | 4 | from sqlalchemy import func 5 | from sqlalchemy import DateTime 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | from sqlalchemy.dialects.postgresql import TIMESTAMP 8 | 9 | class DateTimeMixin: 10 | created_at: Mapped[datetime] = mapped_column( 11 | TIMESTAMP(timezone=True), # 用 PostgreSQL 方言,等价于 DateTime(timezone=True) 12 | # DateTime(timezone=True), # 更通用的写法,你可以直接使用这个,去掉上面的 TIMESTAMP 13 | server_default=func.now(), # 插入时由 PG 生成,采用服务器时间 14 | insert_sentinel=False, # 禁止 ORM 隐式写入 15 | nullable=False, # 不允许为空 16 | index=True, # 可通过创建时间索引 17 | ) 18 | 19 | updated_at: Mapped[datetime] = mapped_column( 20 | TIMESTAMP(timezone=True), # 用 PostgreSQL 方言,等价于 DateTime(timezone=True) 21 | # DateTime(timezone=True), # 更通用的写法,你可以直接使用这个,去掉上面的 TIMESTAMP 22 | server_default=func.now(), # 插入时由 PG 生成,采用服务器时间 23 | onupdate=func.now(), # 更新时由 PG 刷新,采用服务器时间 24 | insert_sentinel=False, # 禁止 ORM 隐式写入 25 | nullable=False, 26 | ) -------------------------------------------------------------------------------- /app/lifespan.py: -------------------------------------------------------------------------------- 1 | # app/lifespan.py 2 | from collections.abc import AsyncIterator 3 | from contextlib import asynccontextmanager 4 | from typing import TypedDict 5 | 6 | from fastapi import FastAPI 7 | from loguru import logger 8 | from redis.asyncio import Redis 9 | 10 | from app.core.config import get_settings 11 | from app.core.database import setup_database_connection, close_database_connection 12 | from app.core.redis_db import create_auth_redis, create_cache_redis 13 | 14 | 15 | # Lifespan: 在应用启动时调用 get_settings,触发配置加载和缓存 16 | class LifespanState(TypedDict, total=False): 17 | auth_redis: Redis 18 | cache_redis: Redis 19 | 20 | @asynccontextmanager 21 | async def lifespan(app: FastAPI) -> AsyncIterator[LifespanState]: 22 | # -------- 启动 -------- 23 | get_settings() 24 | await setup_database_connection() 25 | 26 | auth_redis = create_auth_redis() 27 | cache_redis = create_cache_redis() 28 | 29 | await auth_redis.ping() 30 | await cache_redis.ping() 31 | logger.info("🚀 应用启动,数据库 & Redis 已就绪。") 32 | 33 | yield {"auth_redis": auth_redis, "cache_redis": cache_redis} 34 | 35 | # -------- 关闭 -------- 36 | await close_database_connection() 37 | await auth_redis.aclose() 38 | await cache_redis.aclose() 39 | logger.info("应用关闭,资源已释放。") -------------------------------------------------------------------------------- /app/core/security.py: -------------------------------------------------------------------------------- 1 | # app/core/security.py 2 | from datetime import datetime, timedelta, timezone 3 | 4 | import jwt 5 | from fastapi.security import OAuth2PasswordBearer 6 | from passlib.context import CryptContext 7 | 8 | from app.core.config import settings 9 | 10 | 11 | # Password hashing context 12 | pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") 13 | 14 | # OAuth2 scheme for token authentication 15 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login") 16 | 17 | 18 | def verify_password(plain_password: str, password_hash: str) -> bool: 19 | return pwd_context.verify(plain_password, password_hash) 20 | 21 | 22 | def get_password_hash(password: str) -> str: 23 | return pwd_context.hash(password) 24 | 25 | 26 | def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: 27 | """Create JWT access token.""" 28 | to_encode = data.copy() 29 | if expires_delta: 30 | expire = datetime.now(timezone.utc) + expires_delta 31 | else: 32 | expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRATION) 33 | to_encode.update({"exp": expire}) 34 | encoded_jwt = jwt.encode( 35 | to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM 36 | ) 37 | return encoded_jwt 38 | -------------------------------------------------------------------------------- /app/schemas/response.py: -------------------------------------------------------------------------------- 1 | # app/schemas/response.py 2 | 3 | from typing import Generic, TypeVar 4 | from pydantic import BaseModel, Field, ConfigDict 5 | 6 | # 创建一个类型变量,可以代表任何 Pydantic 模型 7 | T = TypeVar("T") 8 | 9 | 10 | class PaginationInfo(BaseModel): 11 | """分页信息""" 12 | total: int = Field(..., description="总项目数") 13 | page: int = Field(..., ge=1, description="当前页码") 14 | size: int = Field(..., ge=1, description="每页大小") 15 | pages: int = Field(..., ge=0, description="总页数") # pages可以是0,当total为0时 16 | 17 | model_config = ConfigDict( 18 | from_attributes=True 19 | ) 20 | 21 | 22 | class Meta(BaseModel): 23 | """元数据容器""" 24 | pagination: PaginationInfo | None = None # 让分页信息变为可选 25 | # 未来可扩展 sort: SortInfo | None = None 等 26 | 27 | 28 | class DetailResponse(BaseModel, Generic[T]): 29 | """ 30 | 通用单体响应结构 (例如: GET /items/{id}) 31 | """ 32 | data: T = Field(..., description="具体的业务数据") 33 | model_config = ConfigDict( 34 | from_attributes=True 35 | ) 36 | 37 | 38 | class ListResponse(BaseModel, Generic[T]): 39 | """ 40 | 通用列表响应结构 (例如: GET /items) 41 | """ 42 | data: list[T] = Field(..., description="当前页的数据列表") 43 | meta: Meta = Field(..., description="元数据") 44 | model_config = ConfigDict( 45 | from_attributes=True 46 | ) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # -------------- 构建阶段 (Builder Stage) -------------- 2 | # 使用一个轻量的 Python 镜像作为基础 3 | FROM python:3.13.5-slim-bookworm AS builder 4 | 5 | # 将 uv 从官方镜像中复制到我们的构建环境中 6 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ 7 | 8 | # 设置工作目录 9 | WORKDIR /app 10 | 11 | # 仅复制依赖定义文件,以利用 Docker 的层缓存机制 12 | # 只有当这些文件变化时,下面的依赖安装步骤才会重新运行 13 | COPY pyproject.toml uv.lock ./ 14 | 15 | # 使用 uv 创建虚拟环境并安装所有依赖 16 | RUN uv sync --frozen --no-cache 17 | 18 | # 复制项目的全部代码到工作目录 19 | COPY . . 20 | 21 | # 清除构建中产生的缓存、pyc 等无用文件 22 | RUN rm -rf ~/.cache/pip ~/.cache/uv /root/.cache \ 23 | && find /app -type d -name '__pycache__' -exec rm -rf {} + \ 24 | && find /app -type f -name '*.pyc' -delete 25 | 26 | # -------------- 运行阶段 (Runner Stage) -------------- 27 | # 使用同一个轻量的 Python 镜像 28 | FROM python:3.13.5-slim-bookworm 29 | 30 | # 设置工作目录 31 | WORKDIR /app 32 | 33 | # 创建安全用户运行服务 34 | RUN useradd --create-home appuser 35 | # 将工作目录的所有权交给 appuser 36 | RUN chown -R appuser:appuser /app 37 | 38 | # 从 builder 拷贝已经包含代码和虚拟环境的整个 /app 目录 39 | COPY --from=builder --chown=appuser:appuser /app /app 40 | 41 | # 创建持久化文件夹(如上传目录,日志目录,本地sqlite数据库目录等等) 42 | #RUN mkdir -p /app/uploads && chown -R appuser:appuser /app/uploads 43 | 44 | # 切换到非特权用户 45 | USER appuser 46 | 47 | # 将虚拟环境的 bin 目录添加到 PATH 环境变量中 48 | ENV PATH="/app/.venv/bin:$PATH" 49 | 50 | # 暴露 FastAPI 将要使用的端口 51 | EXPOSE 8000 52 | 53 | 54 | # 默认启动命令(虽然在 docker-compose 中会被覆盖,但这是一个好习惯) 55 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] 56 | -------------------------------------------------------------------------------- /alembic/versions/42022bfd0269_intergrate_hero_with_dateimemixin.py: -------------------------------------------------------------------------------- 1 | """Intergrate Hero with DateimeMixin 2 | 3 | Revision ID: 42022bfd0269 4 | Revises: 902aac14d239 5 | Create Date: 2025-08-13 14:12:38.906404 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '42022bfd0269' 16 | down_revision: Union[str, Sequence[str], None] = '902aac14d239' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.add_column('heroes', sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False)) 25 | op.add_column('heroes', sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False)) 26 | op.create_index(op.f('ix_heroes_created_at'), 'heroes', ['created_at'], unique=False) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade() -> None: 31 | """Downgrade schema.""" 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_index(op.f('ix_heroes_created_at'), table_name='heroes') 34 | op.drop_column('heroes', 'updated_at') 35 | op.drop_column('heroes', 'created_at') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /app/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # app/core/exceptions.py 2 | from fastapi import FastAPI, HTTPException, Request, status 3 | from fastapi.responses import JSONResponse 4 | from loguru import logger 5 | 6 | # ------------------ 业务异常 ------------------ 7 | class NotFoundException(HTTPException): 8 | def __init__(self, detail: str = "Resource not found"): 9 | super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail) 10 | 11 | class AlreadyExistsException(HTTPException): 12 | def __init__(self, detail: str = "Resource already exists"): 13 | super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) 14 | 15 | class UnauthorizedException(HTTPException): 16 | def __init__(self, detail: str = "Unauthorized access"): 17 | super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) 18 | 19 | class ForbiddenException(HTTPException): 20 | def __init__(self, detail: str = "Access forbidden"): 21 | super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) 22 | 23 | # ------------------ 全局兜底 ------------------ 24 | async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: 25 | logger.exception(f"Unhandled exception at {request.url.path}: {exc}") 26 | return JSONResponse( 27 | status_code=500, 28 | content={"detail": "Internal server error"}, 29 | ) 30 | 31 | 32 | # 👇 新增一个专门用于注册的函数 33 | def register_exception_handlers(app: FastAPI) -> None: 34 | """向 FastAPI app 实例注册全局异常处理器。""" 35 | app.add_exception_handler(Exception, global_exception_handler) 36 | 37 | # 如果未来有其他需要注册的,都加在这里 38 | # app.add_exception_handler(SomeOtherLibraryError, handle_other_error) -------------------------------------------------------------------------------- /app/schemas/heroes.py: -------------------------------------------------------------------------------- 1 | # app/schemas/heroes.py 2 | from pydantic import BaseModel, ConfigDict 3 | from typing import Literal 4 | 5 | 6 | class OrderByRule(BaseModel): 7 | field: str 8 | dir: Literal["asc", "desc"] = "asc" 9 | 10 | 11 | # 基础模型,包含所有用户共有的字段 12 | class HeroBase(BaseModel): 13 | name: str 14 | alias: str 15 | powers: str | None = None 16 | 17 | 18 | # 新增一个用于创建英雄的模型 19 | class HeroCreate(HeroBase): 20 | pass 21 | 22 | 23 | class HeroUpdate(BaseModel): 24 | name: str | None = None 25 | alias: str | None = None 26 | powers: str | None = None 27 | 28 | 29 | # 从数据库读取并返回给客户端的模型 30 | class HeroResponse(HeroBase): 31 | id: int 32 | 33 | # Pydantic V2 的新配置方式 34 | model_config = ConfigDict( 35 | from_attributes=True 36 | ) # 告诉 Pydantic 模型可以从 ORM 对象属性中读取数据 37 | 38 | 39 | # 新增一个用于返回带故事的英雄信息的模型 40 | class HeroStoryResponse(HeroResponse): 41 | story: str 42 | 43 | 44 | # 新增用于「分页、排序、过滤/搜索」三合一查询的模型 45 | # 目前只有 Hero 在用这些模型,暂时写在这同一个文件里 46 | # 后续可以将分页、排序、过滤/搜索的逻辑抽离出来 47 | 48 | 49 | # 1.分页 50 | class Pagination(BaseModel): 51 | currentPage: int 52 | totalPages: int 53 | totalItems: int 54 | limit: int 55 | hasMore: bool 56 | previousPage: int | None 57 | nextPage: int | None 58 | 59 | 60 | # 2.排序 61 | # class Sort(BaseModel): 62 | # field: str 63 | # direction: str # asc | desc 64 | 65 | 66 | class Sort(BaseModel): 67 | fields: list[OrderByRule] 68 | 69 | 70 | # 3.过滤/搜索 71 | class Filters(BaseModel): 72 | search: str | None 73 | 74 | 75 | # 4.包装返回结构 76 | class HeroListResponse(BaseModel): 77 | data: list[HeroResponse] 78 | pagination: Pagination 79 | sort: Sort 80 | filters: Filters 81 | -------------------------------------------------------------------------------- /app/domains/users/user_repository.py: -------------------------------------------------------------------------------- 1 | # app/domains/users/user_repository.py 2 | from sqlalchemy import select 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from loguru import logger 5 | 6 | from app.core.exceptions import AlreadyExistsException, NotFoundException 7 | 8 | from app.core.security import get_password_hash 9 | from app.models.users import User 10 | from app.schemas.users import UserCreate 11 | 12 | 13 | class UserRepository: 14 | def __init__(self, session: AsyncSession): 15 | self.session = session 16 | 17 | async def create(self, user_data: UserCreate) -> User: 18 | # Check if user exists 19 | existing_user = await self.get_by_username(user_data.username) 20 | if existing_user: 21 | raise AlreadyExistsException("Username already registered") 22 | # Create user 23 | new_user = User( 24 | username=user_data.username, 25 | password_hash=get_password_hash(user_data.password), # 加密密码 26 | ) 27 | self.session.add(new_user) 28 | await self.session.commit() 29 | await self.session.refresh(new_user) 30 | logger.info(f"Created user: {new_user.username}") 31 | return new_user 32 | 33 | async def get_by_id(self, user_id: int) -> User: 34 | query = select(User).where(User.id == user_id) 35 | result = await self.session.scalars(query) 36 | user = result.one_or_none() 37 | if not user: 38 | raise NotFoundException("User not found") 39 | return user 40 | 41 | async def get_by_username(self, username: str) -> User | None: 42 | query = select(User).where(User.username == username) 43 | result = await self.session.scalars(query) 44 | user = result.one_or_none() 45 | return user 46 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # compose.yaml 2 | 3 | 4 | # 定义我们整个应用栈的名称 5 | name: 'fastapi-demo-project' 6 | 7 | services: 8 | # 1. PostgreSQL 数据库服务 9 | postgresql: 10 | image: bitnami/postgresql:latest 11 | # ports: 12 | # - "5432:5432" 13 | environment: 14 | - POSTGRESQL_USERNAME=postgres 15 | - POSTGRESQL_PASSWORD=postgres 16 | - POSTGRESQL_DATABASE=mirror 17 | volumes: 18 | - postgresql_data:/bitnami/postgresql 19 | healthcheck: 20 | test: ["CMD", "pg_isready", "-U", "postgres"] 21 | interval: 10s 22 | timeout: 10s 23 | retries: 10 24 | start_period: 60s 25 | 26 | # 2. Redis 服务 27 | redis: 28 | image: bitnami/redis:latest 29 | # ports: 30 | # - "6379:6379" 31 | environment: 32 | - ALLOW_EMPTY_PASSWORD=yes 33 | volumes: 34 | - redis_data:/bitnami/redis/data 35 | healthcheck: 36 | test: ["CMD", "redis-cli", "ping"] 37 | interval: 10s 38 | timeout: 5s 39 | retries: 5 40 | 41 | # 4. FastAPI 后端应用服务 42 | app: 43 | image: fastapi-demo-project:latest 44 | build: 45 | context: . 46 | dockerfile: Dockerfile 47 | pull_policy: never 48 | ports: 49 | - "8000:8000" 50 | env_file: # 把 .env.dev 挂进来,如果不挂env,就要把下面的环境变量写全了 51 | - .env.dev 52 | # environment: 53 | # - DEMO_ENVIRONMENT=dev 54 | # - DEMO_DB_HOST=postgresql 55 | # - DEMO_DB_PORT=5432 56 | # - DEMO_DB_USER=postgres 57 | # - DEMO_DB_PASSWORD=postgres 58 | # - DEMO_DB_DB=tutorial 59 | # - DEMO_REDIS_HOST=redis:6379 60 | depends_on: 61 | postgresql: 62 | condition: service_healthy 63 | redis: 64 | condition: service_healthy 65 | 66 | 67 | # 定义具名卷,用于持久化存储数据 68 | volumes: 69 | postgresql_data: 70 | driver: local 71 | redis_data: 72 | driver: local 73 | -------------------------------------------------------------------------------- /app/api/v1/auth_route.py: -------------------------------------------------------------------------------- 1 | # app/api/v1/auth_route.py 2 | from fastapi import Depends, APIRouter, status 3 | from fastapi.security import OAuth2PasswordRequestForm 4 | from loguru import logger 5 | 6 | from app.domains.users.service_dependencies import get_user_service 7 | from app.domains.users.auth_dependencies import get_current_user 8 | from app.domains.users.user_service import UserService 9 | from app.schemas.auth import LoginData, Token 10 | from app.schemas.users import UserCreate, UserResponse 11 | 12 | 13 | router = APIRouter(prefix="/auth", tags=["Auth"]) 14 | 15 | 16 | @router.post("/login", response_model=Token) 17 | async def login( 18 | form_data: OAuth2PasswordRequestForm = Depends(), 19 | service: UserService = Depends(get_user_service), 20 | ) -> Token: 21 | """Authenticate user and return token.""" 22 | login_data = LoginData(username=form_data.username, password=form_data.password) 23 | logger.debug(f"Login attempt: {login_data.username}") 24 | login_session = await service.authenticate(login_data) 25 | return login_session 26 | 27 | 28 | @router.post( 29 | "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED 30 | ) 31 | async def register( 32 | user_data: UserCreate, service: UserService = Depends(get_user_service) 33 | ) -> UserResponse: 34 | logger.debug(f"Registering user: {user_data.username}") 35 | try: 36 | new_user = await service.create_user(user_data) 37 | logger.info(f"Created user {new_user.id}") 38 | return new_user 39 | except Exception as e: 40 | logger.error(f"Failed to create hero: {str(e)}") 41 | raise 42 | 43 | 44 | @router.get("/me", response_model=UserResponse) 45 | async def get_me(user: UserResponse = Depends(get_current_user)) -> UserResponse: 46 | """Get current authenticated user.""" 47 | return user 48 | -------------------------------------------------------------------------------- /app/models/collections.py: -------------------------------------------------------------------------------- 1 | # app/models/collections.py 2 | from typing import Optional 3 | 4 | from sqlalchemy import String, Integer, Text, UniqueConstraint, ForeignKey 5 | from sqlalchemy.orm import Mapped, mapped_column, relationship 6 | 7 | from app.models.base import Base 8 | from app.models.mixin import DateTimeMixin 9 | 10 | from typing import TYPE_CHECKING 11 | if TYPE_CHECKING: 12 | from app.models.users import User 13 | from app.models.heroes import Hero 14 | 15 | 16 | class Collection(Base, DateTimeMixin): 17 | __tablename__ = "collections" 18 | 19 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 20 | title: Mapped[str] = mapped_column( 21 | String(256), nullable=False 22 | ) 23 | description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) 24 | 25 | # 外键:关联到 User 表 26 | user_id: Mapped[int] = mapped_column( 27 | ForeignKey("users.id"), index=True, nullable=False 28 | ) 29 | # 多对一关系:Collection -> User 30 | owner: Mapped["User"] = relationship("User", back_populates="collections") 31 | 32 | # 多对多关系:Collection -> Hero 33 | heroes: Mapped[list["Hero"]] = relationship( 34 | "Hero", 35 | secondary="collection_hero", 36 | lazy="selectin", 37 | ) 38 | 39 | # 表级约束:确保每个用户的收藏标题唯一 40 | __table_args__ = ( 41 | UniqueConstraint("title", "user_id", name="unique_user_collections_title"), 42 | ) 43 | 44 | def __repr__(self) -> str: 45 | return f"" 46 | 47 | 48 | class CollectionHero(Base): 49 | __tablename__ = "collection_hero" 50 | 51 | collection_id: Mapped[int] = mapped_column( 52 | ForeignKey("collections.id"), primary_key=True 53 | ) 54 | hero_id: Mapped[int] = mapped_column(ForeignKey("heroes.id"), primary_key=True) 55 | -------------------------------------------------------------------------------- /app/core/dependencies.py: -------------------------------------------------------------------------------- 1 | # app/core/dependencies.py 2 | from typing import Type, TypeVar, Callable, Protocol, Any 3 | from fastapi import Depends 4 | from redis.asyncio import Redis 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from app.core.database import get_db 7 | from app.core.redis_db import get_cache_redis 8 | from app.domains.heroes.heroes_repo import HeroRepository 9 | from app.domains.heroes.heroes_serv import HeroService 10 | from app.domains.collections.collections_repository import CollectionRepository 11 | from app.domains.collections.collections_service import CollectionService 12 | 13 | 14 | # ---------- 协议 ---------- 15 | class _RepoProto(Protocol): 16 | """一个仓库类必须可以用 session 来初始化。""" 17 | 18 | def __init__(self, session: AsyncSession) -> None: ... 19 | 20 | 21 | class _ServiceProto(Protocol): 22 | """一个服务类必须可以用一个 repository 对象来初始化。""" 23 | 24 | def __init__(self, repository: Any) -> None: ... 25 | 26 | 27 | # ---------- 带 bound 的类型变量 ---------- 28 | T = TypeVar("T", bound=_ServiceProto) 29 | R = TypeVar("R", bound=_RepoProto) 30 | 31 | 32 | # ---------- 工厂 ---------- 33 | def get_service(service_cls: Type[T], repo_cls: Type[R]) -> Callable[..., T]: 34 | def _factory(session: AsyncSession = Depends(get_db)) -> T: 35 | return service_cls(repo_cls(session)) 36 | 37 | return _factory 38 | 39 | 40 | # ---------- 依赖项 ---------- 41 | 42 | # 专有的依赖项,不适用于通过工厂函数来构造,就单独写 43 | def get_hero_service( 44 | session: AsyncSession = Depends(get_db), redis: Redis = Depends(get_cache_redis) 45 | ) -> HeroService: 46 | """Dependency for getting HeroService instance.""" 47 | repository = HeroRepository(session) 48 | return HeroService(repository, redis) 49 | 50 | 51 | 52 | # 通用依赖项,适用于通过工厂函数来构造 53 | get_collection_service: Callable[..., CollectionService] = get_service( 54 | service_cls=CollectionService, repo_cls=CollectionRepository 55 | ) 56 | -------------------------------------------------------------------------------- /app/domains/users/user_service.py: -------------------------------------------------------------------------------- 1 | # app/domains/users/user_service.py 2 | from datetime import timedelta 3 | 4 | from loguru import logger 5 | 6 | from app.core.config import settings 7 | from app.core.exceptions import UnauthorizedException 8 | from app.core.security import create_access_token, verify_password 9 | from app.domains.users.user_repository import UserRepository 10 | from app.schemas.users import UserCreate, UserInDB, UserResponse 11 | from app.schemas.auth import LoginData, Token 12 | 13 | 14 | class UserService: 15 | """Service for handling user business logic.""" 16 | 17 | def __init__(self, repository: UserRepository): 18 | self.repository = repository 19 | 20 | async def create_user(self, user_data: UserCreate) -> UserResponse: 21 | """Create a new user.""" 22 | new_user = await self.repository.create(user_data) 23 | return UserResponse.model_validate(new_user) 24 | 25 | async def authenticate(self, login_data: LoginData) -> Token: 26 | """Authenticate user and return token.""" 27 | # Get user 28 | user = await self.repository.get_by_username(login_data.username) 29 | 30 | # Verify credentials 31 | if not user or not verify_password( 32 | login_data.password, str(user.password_hash) 33 | ): 34 | raise UnauthorizedException(detail="Incorrect username or password") 35 | 36 | # Create access token 37 | access_token = create_access_token( 38 | data={"sub": str(user.username)}, 39 | expires_delta=timedelta(minutes=settings.JWT_EXPIRATION), 40 | ) 41 | 42 | logger.info(f"User authenticated: {user.username}") 43 | return Token(access_token=access_token) 44 | 45 | async def get_user(self, user_id: int) -> UserResponse: 46 | """Get user by ID.""" 47 | user = await self.repository.get_by_id(user_id) 48 | return UserResponse.model_validate(user) 49 | 50 | async def get_user_by_username(self, username: str) -> UserInDB: 51 | """Get user by username.""" 52 | user = await self.repository.get_by_username(username) 53 | return UserInDB.model_validate(user) 54 | -------------------------------------------------------------------------------- /app/domains/collections/collections_repository.py: -------------------------------------------------------------------------------- 1 | # app/domains/collections/collections_repository.py 2 | from sqlalchemy import insert 3 | from sqlalchemy.exc import IntegrityError 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from app.core.repository import RepositoryBase 7 | from app.models.collections import Collection, CollectionHero 8 | from app.schemas.collections import CollectionCreate, CollectionUpdate 9 | from app.core.exceptions import AlreadyExistsException 10 | 11 | 12 | class CollectionRepository( 13 | RepositoryBase[Collection, CollectionCreate, CollectionUpdate] 14 | ): 15 | """ 16 | 一个纯粹的、无状态的仓库,只负责 Collection 模型的数据操作。 17 | 所有权逻辑将由服务层的代理来处理。 18 | """ 19 | 20 | def __init__(self, session: AsyncSession): 21 | """ 22 | 初始化 CollectionRepository。 23 | """ 24 | super().__init__(model=Collection, session=session) 25 | 26 | # get, create, update, delete, get_list 都会被直接继承 27 | # 我们只需要在这里定义 Collection 真正特殊的方法 28 | async def add_hero(self, *, collection_id: int, hero_id: int) -> None: 29 | """向 Collection 添加 Hero (多对多关系)。""" 30 | stmt = insert(CollectionHero).values( 31 | collection_id=collection_id, hero_id=hero_id 32 | ) 33 | try: 34 | await self.session.execute(stmt) 35 | # 注意:按照我们的 Unit of Work 模式,这里不应该 commit 36 | # 让上层的事务管理器来处理 37 | await self.session.flush() 38 | except IntegrityError: 39 | # 同样,rollback 也由上层处理 40 | raise AlreadyExistsException("Hero already in this collection") 41 | 42 | # 我们仍然可以重写 get_list 来提供搜索策略,但不再需要 user_id 43 | async def get_list( 44 | self, 45 | *, 46 | limit: int | None, 47 | offset: int | None, 48 | search: str | None = None, 49 | order_by: list[str] | None = None, 50 | **filters, 51 | ) -> tuple[int, list[Collection]]: 52 | """获取 Collection 列表,并定义搜索策略。""" 53 | return await super().get_list( 54 | limit=limit, 55 | offset=offset, 56 | search=search, 57 | search_fields=["title", "description"], 58 | order_by=order_by, 59 | **filters, 60 | ) 61 | -------------------------------------------------------------------------------- /fill_fake_heroes.py: -------------------------------------------------------------------------------- 1 | # /scripts/fill_fake_heroes.py 2 | import asyncio 3 | 4 | from app.models import Hero 5 | from app.core.database import setup_database_connection, get_session_factory, close_database_connection 6 | 7 | 8 | 9 | # 2. 假数据池 10 | NAMES = [ 11 | "Peter Parker", "Tony Stark", "Steve Rogers", "Bruce Banner", "Natasha Romanoff", 12 | "Clark Kent", "Bruce Wayne", "Diana Prince", "Barry Allen", "Arthur Curry", 13 | "Reed Richards", "Sue Storm", "Johnny Storm", "Ben Grimm", "Ororo Munroe" 14 | ] 15 | ALIASES = [ 16 | "Spider-Man", "Iron Man", "Captain America", "Hulk", "Black Widow", 17 | "Superman", "Batman", "Wonder Woman", "Flash", "Aquaman", 18 | "Mr. Fantastic", "Invisible Woman", "Human Torch", "The Thing", "Storm" 19 | ] 20 | POWERS = [ 21 | "Wall-crawling, super strength, spider-sense", 22 | "Genius-level intellect, powered armor suit", 23 | "Peak human condition, vibranium shield mastery", 24 | "Limitless strength, durability increases with anger", 25 | "Master spy & assassin, slowed aging", 26 | "Flight, heat vision, invulnerability", 27 | "World's greatest detective, peak human conditioning", 28 | "Super strength, flight, lasso of truth", 29 | "Speed Force, time travel via running", 30 | "Atlantean physiology, hydrokinesis", 31 | "Elasticity, genius intellect", 32 | "Invisibility, force-field projection", 33 | "Pyrokinesis, flight", 34 | "Super strength & durability, rock-like hide", 35 | "Weather manipulation, flight" 36 | ] 37 | 38 | # 3. 生成并插入 39 | async def fill_fake_data(): 40 | await setup_database_connection() # 初始化全局 engine & factory 41 | factory = get_session_factory() 42 | async with factory() as session: 43 | 44 | # 1. 造数据 45 | heroes = [ 46 | Hero( 47 | name=name, 48 | alias=alias, 49 | powers=powers 50 | ) 51 | for name, alias, powers in zip(NAMES, ALIASES, POWERS) 52 | ] 53 | 54 | # 2. 提交 55 | session.add_all(heroes) 56 | await session.commit() 57 | print("✅ 成功插入", len(heroes), "条英雄记录") 58 | 59 | await close_database_connection() # 优雅关闭 60 | 61 | if __name__ == "__main__": 62 | asyncio.run(fill_fake_data()) -------------------------------------------------------------------------------- /alembic/versions/6185a991de0c_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: 6185a991de0c 4 | Revises: 5 | Create Date: 2025-08-12 10:52:22.272249 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '6185a991de0c' 16 | down_revision: Union[str, Sequence[str], None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table('heroes', 25 | sa.Column('id', sa.Integer(), nullable=False), 26 | sa.Column('name', sa.String(length=100), nullable=False), 27 | sa.Column('alias', sa.String(length=100), nullable=False), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_heroes_alias'), 'heroes', ['alias'], unique=True) 31 | op.create_index(op.f('ix_heroes_id'), 'heroes', ['id'], unique=False) 32 | op.create_table('users', 33 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 34 | sa.Column('username', sa.String(length=64), nullable=False), 35 | sa.Column('password_hash', sa.String(length=256), nullable=True), 36 | sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 37 | sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False) 41 | op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade() -> None: 46 | """Downgrade schema.""" 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_index(op.f('ix_users_username'), table_name='users') 49 | op.drop_index(op.f('ix_users_created_at'), table_name='users') 50 | op.drop_table('users') 51 | op.drop_index(op.f('ix_heroes_id'), table_name='heroes') 52 | op.drop_index(op.f('ix_heroes_alias'), table_name='heroes') 53 | op.drop_table('heroes') 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /app/domains/heroes/heroes_repo.py: -------------------------------------------------------------------------------- 1 | # app/domains/heroes/heroes_repo.py 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from app.core.repository import RepositoryBase 6 | from app.models.heroes import Hero 7 | from app.schemas.heroes import HeroCreate, HeroUpdate 8 | from app.core.exceptions import AlreadyExistsException 9 | 10 | 11 | class HeroRepository(RepositoryBase[Hero, HeroCreate, HeroUpdate]): 12 | """ 13 | HeroRepository 继承自通用的 RepositoryBase, 14 | 并提供了针对 Hero 模型的特定实现和策略。 15 | """ 16 | 17 | def __init__(self, session: AsyncSession): 18 | """ 19 | 初始化 HeroRepository。 20 | """ 21 | super().__init__(model=Hero, session=session) 22 | self.session = session 23 | 24 | async def create(self, *, obj_in: HeroCreate) -> Hero: 25 | """ 26 | 创建一个新的 Hero。 27 | 28 | 重写此方法以提供针对 'alias' 字段的、更具体的唯一性冲突错误信息。 29 | """ 30 | try: 31 | # 直接调用父类的 create 方法,它包含了核心的创建逻辑 32 | return await super().create(obj_in=obj_in) 33 | except IntegrityError: 34 | # 这里不再需要手动回滚。 35 | # 外层的 `async with session.begin():` 会在捕获到异常时自动处理回滚。 36 | # 我们在这里的唯一职责,就是将数据库异常“翻译”成我们的业务异常。 37 | raise AlreadyExistsException(f"Hero with alias '{obj_in.alias}' already exists") 38 | # 如果不打算抛出这个精确的异常,可以使用父类的通用异常,你甚至都不需要重写这个方法。 39 | 40 | async def get_list( 41 | self, 42 | *, 43 | limit: int | None, 44 | offset: int | None, 45 | search: str | None = None, 46 | order_by: list[str] | None = None, 47 | ) -> tuple[int, list[Hero]]: 48 | """ 49 | 获取 Hero 列表,并定义此仓库的特定搜索策略。 50 | 51 | 此方法调用父类的通用列表查询,并返回一个包含 (总数, Hero ORM 对象列表) 的元组。 52 | """ 53 | # 调用父类的 get_list 方法,并传入本模型允许被搜索的字段列表 54 | return await super().get_list( 55 | limit=limit, 56 | offset=offset, 57 | search=search, 58 | # 👇 这就是注入 Hero 模型的搜索策略,每个模型搜索策略不同,这个方法必须重写以便定制 59 | search_fields=["name", "alias", "powers"], # 策略定义 60 | order_by=order_by, 61 | ) 62 | 63 | # get, update, delete 方法被直接继承,无需在此定义。 64 | # - get: 基类已处理 "Not Found" 异常。 65 | # - update: 基类已处理 schema 到 dict 的转换。 66 | # - delete: 基类已处理 "Not Found" 异常。 -------------------------------------------------------------------------------- /alembic/versions/f321df2cf6a5_add_collections_table.py: -------------------------------------------------------------------------------- 1 | """add collections table 2 | 3 | Revision ID: f321df2cf6a5 4 | Revises: 42022bfd0269 5 | Create Date: 2025-08-18 15:09:18.923603 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'f321df2cf6a5' 16 | down_revision: Union[str, Sequence[str], None] = '42022bfd0269' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table('collections', 25 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 26 | sa.Column('title', sa.String(length=256), nullable=False), 27 | sa.Column('description', sa.Text(), nullable=True), 28 | sa.Column('user_id', sa.Integer(), nullable=False), 29 | sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 30 | sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 31 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 32 | sa.PrimaryKeyConstraint('id'), 33 | sa.UniqueConstraint('title', 'user_id', name='unique_user_collections_title') 34 | ) 35 | op.create_index(op.f('ix_collections_created_at'), 'collections', ['created_at'], unique=False) 36 | op.create_index(op.f('ix_collections_user_id'), 'collections', ['user_id'], unique=False) 37 | op.create_table('collection_hero', 38 | sa.Column('collection_id', sa.Integer(), nullable=False), 39 | sa.Column('hero_id', sa.Integer(), nullable=False), 40 | sa.ForeignKeyConstraint(['collection_id'], ['collections.id'], ), 41 | sa.ForeignKeyConstraint(['hero_id'], ['heroes.id'], ), 42 | sa.PrimaryKeyConstraint('collection_id', 'hero_id') 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade() -> None: 48 | """Downgrade schema.""" 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table('collection_hero') 51 | op.drop_index(op.f('ix_collections_user_id'), table_name='collections') 52 | op.drop_index(op.f('ix_collections_created_at'), table_name='collections') 53 | op.drop_table('collections') 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | # app/main.py 2 | from loguru import logger 3 | from fastapi import Depends, FastAPI, Response 4 | from contextlib import asynccontextmanager 5 | from sqlalchemy import text 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from fastapi_pagination import add_pagination 8 | 9 | # 从 config 模块导入 get_settings 函数和 get_project_version 函数 10 | from app.core.config import Settings, get_settings, get_project_version, settings 11 | 12 | # 从 core.database 模块导入 setup_database_connection 和 close_database_connection 函数 13 | from app.core.database import ( 14 | setup_database_connection, 15 | close_database_connection, 16 | get_db, 17 | ) 18 | # from app.core.exceptions import global_exception_handler 19 | from app.core.exceptions import register_exception_handlers 20 | from app.api.v1 import heroes_route, auth_route, collections_route,new_hero_route 21 | from app.lifespan import lifespan 22 | 23 | 24 | # Lifespan: 在应用启动时调用 get_settings,触发配置加载和缓存 25 | # 当 Lifspan 开始变得臃肿,可以考虑拆分成多个文件 26 | # @asynccontextmanager 27 | # async def lifespan(app: FastAPI): 28 | # # 应用启动时执行 29 | # get_settings() 30 | # await setup_database_connection() 31 | 32 | # logger.info("🚀 应用启动,数据库已连接。") 33 | # yield 34 | # # 应用关闭时执行 35 | # await close_database_connection() 36 | # logger.info("应用关闭,数据库连接已释放。") 37 | 38 | 39 | app = FastAPI( 40 | title=settings.APP_NAME, 41 | description="这是一个 FastAPI 演示项目", 42 | # 动态从 pyproject.toml 读取版本号 43 | version=get_project_version(), 44 | lifespan=lifespan, 45 | ) 46 | 47 | # 全局异常处理,所有未被自定义异常处理的异常都会触发 48 | # app.add_exception_handler(Exception, global_exception_handler) 49 | # 更新为通过注册函数调用全局异常处理 50 | register_exception_handlers(app) 51 | 52 | 53 | # 路由引入 54 | app.include_router(auth_route.router, prefix="/api/v1") 55 | app.include_router(heroes_route.router, prefix="/api/v1") 56 | app.include_router(collections_route.router, prefix="/api/v1") 57 | # 改造后的 Heroes 路由 58 | app.include_router(new_hero_route.router, prefix="/api/v1") 59 | 60 | # 添加分页支持 61 | add_pagination(app) 62 | 63 | 64 | @app.get("/health") 65 | async def health_check(response: Response): 66 | response.status_code = 200 67 | return {"status": "ok 👍 "} 68 | 69 | 70 | @app.get("/") 71 | def read_root( 72 | # 使用 FastAPI 的依赖注入系统来获取配置实例 73 | # FastAPI 会自动调用 get_settings(),由于缓存的存在,这几乎没有开销 74 | settings: Settings = Depends(get_settings), 75 | ): 76 | """ 77 | 一个示例端点,演示如何访问配置。 78 | """ 79 | return { 80 | "message": f"Hello from the {settings.APP_NAME}!", 81 | "environment": settings.ENVIRONMENT, 82 | "debug_mode": settings.DEBUG, 83 | # 演示如何访问嵌套的配置项 84 | "database_host": settings.DB.HOST, 85 | # 演示如何使用在模型中动态计算的属性 86 | "database_url_hidden_password": settings.DB.DATABASE_URL.replace( 87 | settings.DB.PASSWORD, "****" 88 | ), 89 | "app_version": get_project_version(), 90 | } 91 | 92 | 93 | @app.get("/db-check") 94 | async def db_check(db: AsyncSession = Depends(get_db)): 95 | """ 96 | 一个简单的端点,用于检查数据库连接是否正常工作。 97 | """ 98 | try: 99 | # 执行一个简单的查询来验证连接 100 | result = await db.execute(text("SELECT 1")) 101 | if result.scalar_one() == 1: 102 | return {"status": "ok", "message": "数据库连接成功!"} 103 | except Exception as e: 104 | return {"status": "error", "message": f"数据库连接失败: {e}"} 105 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | # /fastapi-demo-project/app/core/config.py 2 | import os 3 | from functools import lru_cache 4 | from typing import Literal 5 | 6 | # 使用 Python 3.8+ 内置的 importlib.metadata 7 | from importlib import metadata 8 | 9 | from loguru import logger 10 | from pydantic import computed_field 11 | from pydantic_settings import BaseSettings, SettingsConfigDict 12 | 13 | 14 | # --- 动态版本号获取 --- 15 | def get_project_version() -> str: 16 | """从 pyproject.toml 文件中动态读取项目版本号。""" 17 | try: 18 | # "fastapi-demo-project" 必须与你在 pyproject.toml 中定义的 [project] name 完全匹配 19 | return metadata.version("fastapi-demo-project") 20 | except metadata.PackageNotFoundError: 21 | # 如果包没有被 "安装" (例如,在 Docker 构建的早期阶段或非标准环境中) 22 | # 提供一个合理的回退值。 23 | return "0.1.0-dev" 24 | 25 | 26 | # --- 嵌套配置模型 --- 27 | # 这是一个最佳实践,它将相关的配置分组,使结构更清晰。 28 | 29 | 30 | class DatabaseSettings(BaseSettings): 31 | """数据库相关配置""" 32 | 33 | HOST: str = "localhost" 34 | PORT: int = 5432 35 | USER: str = "postgres" 36 | PASSWORD: str = "postgres" 37 | DB: str = "tutorial" 38 | 39 | POOL_SIZE: int = 20 40 | MAX_OVERFLOW: int = 10 41 | POOL_TIMEOUT: int = 30 42 | POOL_RECYCLE: int = 3600 43 | ECHO: bool = False 44 | 45 | # 使用 @computed_field,可以在模型内部根据其他字段动态生成新字段 46 | # 这比在模型外部手动拼接字符串要优雅得多。 47 | @computed_field 48 | @property 49 | def DATABASE_URL(self) -> str: 50 | """生成异步 PostgreSQL 连接字符串。""" 51 | return f"postgresql+asyncpg://{self.USER}:{self.PASSWORD}@{self.HOST}:{self.PORT}/{self.DB}" 52 | 53 | # model_config 的设置在这里同样适用,用于 Pydantic 如何加载这些设置 54 | model_config = SettingsConfigDict(env_prefix="DEMO_DB_") 55 | 56 | 57 | class Settings(BaseSettings): 58 | """主配置类,汇集所有配置项。""" 59 | 60 | # 环境配置: 'dev' 或 'prod' 61 | # Literal 类型确保了 ENVIRONMENT 只能是指定的值之一,增加了类型安全 62 | ENVIRONMENT: Literal["dev", "prod"] = "dev" 63 | 64 | DEBUG: bool = False 65 | APP_NAME: str = "FastAPI Demo Project" 66 | 67 | # 安全配置 68 | JWT_SECRET: str 69 | JWT_ALGORITHM: str 70 | JWT_EXPIRATION: int = 30 71 | 72 | # Redis 配置 73 | REDIS_HOST: str = "localhost:6379" 74 | 75 | # --- 嵌套配置 --- 76 | # 将 DatabaseSettings 作为主 Settings 的一个字段。 77 | # Pydantic 会自动处理带有 'DEMO_DB_' 前缀的环境变量,并填充到这个模型中。 78 | DB: DatabaseSettings = DatabaseSettings() 79 | 80 | # Pydantic-settings 的核心配置 81 | model_config = SettingsConfigDict( 82 | # 从 .env 文件加载环境变量,这里不写,放到动态加载了 83 | env_file_encoding="utf-8", 84 | # 为顶层配置项设置前缀 85 | env_prefix="DEMO_", 86 | # 允许大小写不敏感的环境变量 87 | case_sensitive=False, 88 | ) 89 | 90 | 91 | # --- 缓存与依赖注入 --- 92 | # 这是整个配置系统的关键入口点。 93 | @lru_cache 94 | def get_settings() -> Settings: 95 | """ 96 | 创建并返回一个配置实例。 97 | 98 | 使用 @lru_cache 装饰器实现以下目标: 99 | 1. 性能: 配置只在应用启动时被加载和解析一次,而不是在每个请求中。 100 | 2. 一致性 (单例): 应用的任何部分调用此函数都将获得完全相同的配置对象实例。 101 | 102 | 这意味着,如果你在应用运行时更改了 .env 文件,你需要重启应用才能使更改生效。 103 | """ 104 | logger.info("正在加载配置...") # 这条消息只会在应用首次启动时打印一次 105 | 106 | # 根据 ENVIRONMENT 环境变量来决定加载哪个 .env 文件 107 | # 这是一个非常灵活的模式 108 | env = os.getenv("ENVIRONMENT", "dev") 109 | 110 | env_file = f".env.{env}" 111 | 112 | # 动态创建 Settings 实例,并指定正确的 env_file 113 | 114 | settings = Settings(_env_file=env_file) # type: ignore 115 | 116 | logger.info(f"成功加载 '{env}' 环境配置 for {settings.APP_NAME}") 117 | return settings 118 | 119 | 120 | # 在应用启动时就创建一个实例,方便在非 FastAPI 上下文中使用 121 | settings = get_settings() 122 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | 8 | from alembic import context 9 | 10 | import os 11 | import sys 12 | from pathlib import Path 13 | 14 | # 把项目根目录(fastapi-demo-project/)加入 Python path 15 | project_root = Path(__file__).resolve().parent.parent 16 | sys.path.insert(0, str(project_root)) 17 | 18 | # 根据当前环境变量决定加载哪个 .env 文件 19 | ENV = os.getenv("ENVIRONMENT", "dev") 20 | dotenv_file = project_root / f".env.{ENV}" 21 | 22 | # 在导入 Settings 之前手动把 dotenv 加载进来 23 | from dotenv import load_dotenv 24 | load_dotenv(dotenv_file, override=True) 25 | 26 | # 现在可以安全地导入你的 Settings 27 | from app.core.config import Settings # 注意:不要调用 get_settings(),否则会被缓存 28 | 29 | # 创建 Settings 实例 30 | settings = Settings() # 使用默认的 _env_file=None,因为上面已经 load_dotenv 31 | 32 | from app.models import Base # 这会触发 models 的 __init__.py,进而触发所有模型文件 33 | 34 | 35 | # this is the Alembic Config object, which provides 36 | # access to the values within the .ini file in use. 37 | config = context.config 38 | 39 | # 把生成的 DATABASE_URL 注入到 Alembic 配置里 40 | config.set_main_option("sqlalchemy.url", settings.DB.DATABASE_URL) 41 | # Interpret the config file for Python logging. 42 | # This line sets up loggers basically. 43 | if config.config_file_name is not None: 44 | fileConfig(config.config_file_name) 45 | 46 | # add your model's MetaData object here 47 | # for 'autogenerate' support 48 | # from myapp import mymodel 49 | # target_metadata = mymodel.Base.metadata 50 | target_metadata = Base.metadata 51 | 52 | # other values from the config, defined by the needs of env.py, 53 | # can be acquired: 54 | # my_important_option = config.get_main_option("my_important_option") 55 | # ... etc. 56 | 57 | 58 | def run_migrations_offline() -> None: 59 | """Run migrations in 'offline' mode. 60 | 61 | This configures the context with just a URL 62 | and not an Engine, though an Engine is acceptable 63 | here as well. By skipping the Engine creation 64 | we don't even need a DBAPI to be available. 65 | 66 | Calls to context.execute() here emit the given string to the 67 | script output. 68 | 69 | """ 70 | url = config.get_main_option("sqlalchemy.url") 71 | context.configure( 72 | url=url, 73 | target_metadata=target_metadata, 74 | literal_binds=True, 75 | dialect_opts={"paramstyle": "named"}, 76 | ) 77 | 78 | with context.begin_transaction(): 79 | context.run_migrations() 80 | 81 | 82 | def do_run_migrations(connection: Connection) -> None: 83 | context.configure(connection=connection, target_metadata=target_metadata) 84 | 85 | with context.begin_transaction(): 86 | context.run_migrations() 87 | 88 | 89 | async def run_async_migrations() -> None: 90 | """In this scenario we need to create an Engine 91 | and associate a connection with the context. 92 | 93 | """ 94 | 95 | connectable = async_engine_from_config( 96 | config.get_section(config.config_ini_section, {}), 97 | prefix="sqlalchemy.", 98 | poolclass=pool.NullPool, 99 | ) 100 | 101 | async with connectable.connect() as connection: 102 | await connection.run_sync(do_run_migrations) 103 | 104 | await connectable.dispose() 105 | 106 | 107 | def run_migrations_online() -> None: 108 | """Run migrations in 'online' mode.""" 109 | 110 | asyncio.run(run_async_migrations()) 111 | 112 | 113 | if context.is_offline_mode(): 114 | run_migrations_offline() 115 | else: 116 | run_migrations_online() 117 | -------------------------------------------------------------------------------- /app/api/v1/new_hero_route.py: -------------------------------------------------------------------------------- 1 | # app/api/v1/heroes_route.py 2 | 3 | from fastapi import APIRouter, Depends, status, Query 4 | from loguru import logger 5 | 6 | # 导入我们标准化的响应模型和所有需要的 Schema 7 | from app.schemas.response import DetailResponse, ListResponse 8 | from app.schemas.heroes import HeroCreate, HeroUpdate, HeroResponse, HeroStoryResponse 9 | 10 | # 导入服务层和依赖项 11 | from app.domains.heroes.heroes_serv import HeroService 12 | from app.core.dependencies import get_hero_service 13 | 14 | 15 | router = APIRouter( 16 | prefix="/new_heroes", 17 | tags=["New Heroes"], 18 | # 这是一个公开路由,所以我们不在 router 级别添加 `get_current_user` 依赖 19 | ) 20 | 21 | 22 | @router.post( 23 | "", 24 | response_model=DetailResponse[HeroResponse], 25 | status_code=status.HTTP_201_CREATED, 26 | summary="创建一个新的英雄" 27 | ) 28 | async def create_hero( 29 | data: HeroCreate, 30 | service: HeroService = Depends(get_hero_service), 31 | ): 32 | """ 33 | 创建一条新的英雄记录。 34 | - **name**: 英雄的真实姓名 35 | - **alias**: 英雄的别名/代号 (必须唯一) 36 | """ 37 | created_hero = await service.create_hero(obj_in=data) 38 | logger.info(f"Created hero {created_hero.data.id}") 39 | return created_hero 40 | 41 | 42 | @router.get( 43 | "", 44 | response_model=ListResponse[HeroResponse], 45 | summary="获取英雄列表" 46 | ) 47 | async def list_heroes( 48 | search: str | None = Query(None, description="按姓名、别名、能力进行模糊搜索", max_length=100), 49 | order_by: list[str] | None = Query(None, description="排序字段,如 -name,alias (-表示倒序)", example=["-name"]), 50 | page: int = Query(1, ge=1, description="页码"), 51 | size: int = Query(10, ge=1, le=100, description="每页数量"), 52 | service: HeroService = Depends(get_hero_service), 53 | ): 54 | """ 55 | 获取英雄列表,支持分页、排序和搜索。 56 | """ 57 | offset = (page - 1) * size 58 | 59 | heroes_list = await service.get_heroes( 60 | limit=size, 61 | offset=offset, 62 | search=search, 63 | order_by=order_by, 64 | ) 65 | return heroes_list 66 | 67 | 68 | @router.get( 69 | "/{hero_id}", 70 | response_model=DetailResponse[HeroResponse], 71 | summary="获取英雄详情" 72 | ) 73 | async def get_hero( 74 | hero_id: int, 75 | service: HeroService = Depends(get_hero_service), 76 | ): 77 | """ 78 | 根据 ID 获取单个英雄的详细信息。 79 | """ 80 | hero_detail = await service.get_hero(hero_id=hero_id) 81 | return hero_detail 82 | 83 | 84 | @router.patch( 85 | "/{hero_id}", 86 | response_model=DetailResponse[HeroResponse], 87 | summary="更新英雄信息" 88 | ) 89 | async def update_hero( 90 | hero_id: int, 91 | data: HeroUpdate, 92 | service: HeroService = Depends(get_hero_service), 93 | ): 94 | """ 95 | 局部更新一个英雄的信息。 96 | """ 97 | updated_hero = await service.update_hero(hero_id=hero_id, obj_in=data) 98 | logger.info(f"Updated hero {hero_id}") 99 | return updated_hero 100 | 101 | 102 | @router.delete( 103 | "/{hero_id}", 104 | status_code=status.HTTP_204_NO_CONTENT, 105 | summary="删除一个英雄" 106 | ) 107 | async def delete_hero( 108 | hero_id: int, 109 | service: HeroService = Depends(get_hero_service), 110 | ): 111 | """ 112 | 根据 ID 删除一个英雄。 113 | """ 114 | await service.delete_hero(hero_id=hero_id) 115 | logger.info(f"Deleted hero {hero_id}") 116 | # 204 状态码不返回任何响应体 117 | 118 | 119 | @router.get( 120 | "/{hero_id}/story", 121 | response_model=HeroStoryResponse, # 👈 注意:这里直接使用业务特定的响应模型 122 | summary="获取英雄的背景故事" 123 | ) 124 | async def get_hero_story( 125 | hero_id: int, 126 | service: HeroService = Depends(get_hero_service), 127 | ): 128 | """ 129 | 一个特殊的业务端点,返回英雄信息和一段动态生成的背景故事。 130 | 这个端点展示了为什么服务层处理业务逻辑很重要。 131 | """ 132 | # 它的返回结构不符合通用的 DetailResponse,所以我们直接返回服务层构建的特殊模型 133 | hero_story = await service.get_hero_with_story(hero_id=hero_id) 134 | return hero_story -------------------------------------------------------------------------------- /app/domains/users/auth_dependencies.py: -------------------------------------------------------------------------------- 1 | # app/domains/users/auth_dependencies.py 2 | # from fastapi import Depends, HTTPException, status 3 | # import jwt 4 | # from jwt.exceptions import PyJWTError 5 | # from app.core.config import settings 6 | # from app.core.security import oauth2_scheme 7 | # from app.domains.users.service_dependencies import get_user_service 8 | # from app.domains.users.user_service import UserService 9 | # from app.schemas.users import UserResponse 10 | 11 | 12 | # 将get_current_user函数进一步拆分 13 | # async def get_current_user( 14 | # token: str = Depends(oauth2_scheme), 15 | # service: UserService = Depends(get_user_service), 16 | # ) -> UserResponse: 17 | # credentials_exception = HTTPException( 18 | # status_code=status.HTTP_401_UNAUTHORIZED, 19 | # detail="Could not validate credentials", 20 | # headers={"WWW-Authenticate": "Bearer"}, 21 | # ) 22 | 23 | # try: 24 | # payload = jwt.decode( 25 | # token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] 26 | # ) 27 | # username: str = payload.get("sub") 28 | # if username is None: 29 | # raise credentials_exception 30 | # except PyJWTError: 31 | # raise credentials_exception 32 | 33 | # user = await service.get_user_by_username(username) 34 | # if user is None: 35 | # raise credentials_exception 36 | # return UserResponse.model_validate(user) 37 | 38 | 39 | from typing import Annotated 40 | 41 | import jwt 42 | from jwt.exceptions import PyJWTError 43 | from fastapi import Depends, HTTPException, status 44 | from pydantic import ValidationError 45 | from redis.asyncio import Redis 46 | 47 | from app.core.config import settings 48 | from app.core.security import oauth2_scheme 49 | from app.core.redis_db import get_auth_redis 50 | from app.domains.users.service_dependencies import get_user_service 51 | from app.domains.users.user_service import UserService 52 | from app.schemas.users import UserResponse 53 | 54 | 55 | # ---------- 配置 ---------- 56 | CACHE_TTL = 60 * 15 # 缓存过期时间 15 分钟 57 | 58 | 59 | # ---------- 辅助 ---------- 60 | async def _username_from_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str: 61 | """ 62 | 把 token 解包,直接返回 username;不合法就抛 401。 63 | """ 64 | credentials_exception = HTTPException( 65 | status_code=status.HTTP_401_UNAUTHORIZED, 66 | detail="Could not validate credentials", 67 | headers={"WWW-Authenticate": "Bearer"}, 68 | ) 69 | try: 70 | payload = jwt.decode( 71 | token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] 72 | ) 73 | username: str | None = payload.get("sub") 74 | if username is None: 75 | raise credentials_exception 76 | except (PyJWTError, ValidationError): 77 | raise credentials_exception 78 | return username 79 | 80 | 81 | # ---------- 主依赖 ---------- 82 | async def get_current_user( 83 | username: Annotated[str, Depends(_username_from_token)], 84 | redis: Annotated[Redis, Depends(get_auth_redis)], 85 | service: Annotated[UserService, Depends(get_user_service)], 86 | ) -> UserResponse: 87 | """ 88 | 1. 先查缓存 89 | 2. 缓存没命中 -> 查数据库 -> 写回缓存 90 | 3. 用户不存在 -> 401 91 | """ 92 | credentials_exception = HTTPException( 93 | status_code=status.HTTP_401_UNAUTHORIZED, 94 | detail="Could not validate credentials", 95 | headers={"WWW-Authenticate": "Bearer"}, 96 | ) 97 | # 查缓存 98 | key = f"user:{username}" 99 | cached = await redis.get(key) 100 | if cached: 101 | user = UserResponse.model_validate_json(cached) 102 | else: 103 | # 缓存没命中 -> 查数据库 104 | user_orm = await service.get_user_by_username(username) 105 | if user_orm is None: 106 | raise credentials_exception 107 | user = UserResponse.model_validate(user_orm) 108 | 109 | # 写回缓存 110 | await redis.set(key, user.model_dump_json(), ex=CACHE_TTL) 111 | 112 | return user 113 | -------------------------------------------------------------------------------- /app/core/database.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, AsyncGenerator 2 | from sqlalchemy.ext.asyncio import ( 3 | create_async_engine, 4 | async_sessionmaker, 5 | AsyncSession, 6 | AsyncEngine, 7 | ) 8 | from loguru import logger 9 | from app.core.config import settings 10 | from app.models.base import Base 11 | 12 | 13 | # --- 1. 全局变量定义 --- 14 | _engine: Optional[AsyncEngine] = None 15 | _SessionFactory: Optional[async_sessionmaker[AsyncSession]] = None 16 | 17 | 18 | def get_engine() -> AsyncEngine: 19 | if _engine is None: 20 | raise RuntimeError("数据库引擎未初始化. 请先调用 setup_database_connection") 21 | return _engine 22 | 23 | 24 | def get_session_factory() -> async_sessionmaker[AsyncSession]: 25 | if _SessionFactory is None: 26 | raise RuntimeError("会话工厂未初始化. 请先调用 setup_database_connection") 27 | return _SessionFactory 28 | 29 | 30 | # --- 2. 通用的数据库初始化和关闭函数 --- 31 | # 这些函数现在是通用的,可以在任何需要初始化数据库的地方调用。 32 | # 它们负责设置全局的 engine 和 SessionFactory。 33 | async def setup_database_connection(): 34 | """ 35 | 初始化全局的数据库引擎和会话工厂。 36 | 这是一个通用的设置函数,可以在 FastAPI 启动时调用。 37 | """ 38 | global _engine, _SessionFactory 39 | if _engine is not None: 40 | logger.info("数据库已初始化,跳过重复设置。") 41 | return 42 | 43 | _engine = create_async_engine( 44 | settings.DB.DATABASE_URL, 45 | pool_size=settings.DB.POOL_SIZE, 46 | max_overflow=settings.DB.MAX_OVERFLOW, 47 | pool_timeout=settings.DB.POOL_TIMEOUT, 48 | pool_recycle=settings.DB.POOL_RECYCLE, 49 | echo=settings.DB.ECHO, 50 | pool_pre_ping=True, 51 | ) 52 | _SessionFactory = async_sessionmaker( 53 | class_=AsyncSession, expire_on_commit=False, bind=_engine 54 | ) 55 | logger.info("数据库引擎和会话工厂已创建。") 56 | 57 | 58 | async def close_database_connection(): 59 | """ 60 | 关闭全局的数据库引擎连接池。 61 | 这是一个通用的关闭函数,可以在 FastAPI 关闭时调用。 62 | """ 63 | global _engine, _SessionFactory 64 | if _engine: 65 | await _engine.dispose() 66 | _engine = None # 清理引用 67 | _SessionFactory = None # 清理引用 68 | logger.info("数据库引擎连接池已关闭。") 69 | 70 | 71 | # --- 3. 依赖注入函数 --- 72 | # async def get_db() -> AsyncGenerator[AsyncSession, None]: 73 | # """ 74 | # 为每个请求或任务提供数据库会话。 75 | # 它现在依赖由 setup_database_connection 管理的全局 SessionFactory。 76 | # """ 77 | # if _SessionFactory is None: 78 | # # 这个错误通常不应该在正确配置的生产环境中出现 79 | # # 它表明 setup_database_connection 未在应用启动时调用 80 | # raise Exception("数据库未初始化。请检查 FastAPI 的 lifespan 启动配置。") 81 | 82 | # async with _SessionFactory() as session: 83 | # yield session 84 | 85 | 86 | # 新的工作单元模式的依赖注入函数 87 | async def get_db() -> AsyncGenerator[AsyncSession, None]: 88 | """ 89 | 依赖注入函数:为每个请求提供一个数据库会话, 90 | 并将其包裹在一个能自动提交/回滚的事务中(工作单元模式)。 91 | """ 92 | if _SessionFactory is None: 93 | raise Exception("数据库未初始化。请检查 FastAPI 的 lifespan 启动配置。") 94 | 95 | async with _SessionFactory() as session: 96 | # 👇 这是关键的升级:添加 session.begin() 上下文管理器 97 | async with session.begin(): 98 | try: 99 | # 在这个事务块中,将 session 提供给应用的其余部分 100 | yield session 101 | # ----------------------------------------------------------------- 102 | # 当路由函数成功执行完毕,离开这个 with 块时, 103 | # session.begin() 上下文管理器会自动为你调用 session.commit()。 104 | # ----------------------------------------------------------------- 105 | except Exception: 106 | # ----------------------------------------------------------------- 107 | # 如果在路由函数或其调用的任何代码中发生了异常, 108 | # session.begin() 会捕获它,自动调用 session.rollback(), 109 | # 然后再将异常重新抛出,以便 FastAPI 的全局处理器能够捕获。 110 | # ----------------------------------------------------------------- 111 | raise 112 | 113 | 114 | # --- 4. 数据库表创建工具 --- 115 | async def create_db_and_tables(): 116 | if not _engine: 117 | raise Exception("无法创建表,因为数据库引擎未初始化。") 118 | async with _engine.begin() as conn: 119 | await conn.run_sync(Base.metadata.create_all) 120 | logger.info("数据库表创建成功。") 121 | -------------------------------------------------------------------------------- /app/api/v1/collections_route.py: -------------------------------------------------------------------------------- 1 | # app/api/v1/heroes_route.py 2 | from fastapi import APIRouter, Depends, status, Query 3 | from loguru import logger 4 | 5 | # 导入我们标准化的响应模型 6 | from app.schemas.response import DetailResponse, ListResponse 7 | from app.schemas.collections import ( 8 | CollectionCreate, 9 | CollectionUpdate, 10 | CollectionResponse, 11 | CollectionResponseDetail, 12 | HeroAttachRequest, 13 | ) 14 | 15 | # 导入服务层和依赖项 16 | from app.core.dependencies import get_collection_service 17 | from app.domains.collections.collections_service import CollectionService 18 | from app.models.users import User 19 | from app.domains.users.auth_dependencies import get_current_user 20 | 21 | 22 | router = APIRouter( 23 | prefix="/collections", 24 | tags=["Collections"], 25 | # 👇 在这里统一应用依赖,所有此路由下的端点都将自动受其保护 26 | dependencies=[Depends(get_current_user)], 27 | ) 28 | 29 | 30 | 31 | @router.post( 32 | "", 33 | response_model=DetailResponse[CollectionResponse], # 👈 使用标准化后的 response_model 34 | status_code=status.HTTP_201_CREATED, 35 | summary="创建一个新的收藏集", 36 | ) 37 | async def create_collection( 38 | data: CollectionCreate, 39 | service: CollectionService = Depends(get_collection_service), 40 | current_user: User = Depends(get_current_user), # 在需要时单独获取 41 | ): 42 | # 👇 移除 try...except,让全局异常处理器接管 43 | # 👇 更新服务层调用,使用关键字参数 obj_in 44 | created_collection = await service.create_collection( 45 | obj_in=data, current_user=current_user 46 | ) 47 | logger.info( 48 | f"User {current_user.id} created collection {created_collection.data.id}" 49 | ) 50 | return created_collection 51 | 52 | 53 | @router.get( 54 | "", 55 | response_model=ListResponse[CollectionResponse], # 👈 使用标准化后的 response_model 56 | summary="获取当前用户的收藏集列表", 57 | ) 58 | async def list_collections( 59 | search: str | None = Query( 60 | None, description="按名称、描述进行模糊搜索", max_length=100 61 | ), 62 | order_by: list[str] | None = Query( 63 | None, description="排序字段,如 -title", example=["-title"] 64 | ), 65 | page: int = Query(1, ge=1, description="页码"), 66 | size: int = Query(10, ge=1, le=100, description="每页数量"), 67 | service: CollectionService = Depends(get_collection_service), 68 | current_user: User = Depends(get_current_user), 69 | ): 70 | # page/size 到 offset 的转换在路由层完成,非常正确! 71 | offset = (page - 1) * size 72 | 73 | collections = await service.get_collections( 74 | limit=size, # 👈 注意这里是 size 75 | offset=offset, 76 | search=search, 77 | order_by=order_by, 78 | current_user=current_user, 79 | ) 80 | return collections 81 | 82 | 83 | @router.get( 84 | "/{collection_id}", 85 | response_model=DetailResponse[CollectionResponseDetail], # 👈 修正 response_model 86 | summary="获取收藏集详情", 87 | ) 88 | async def get_collection( 89 | collection_id: int, 90 | service: CollectionService = Depends(get_collection_service), 91 | current_user: User = Depends(get_current_user), 92 | ): 93 | collection = await service.get_collection( 94 | collection_id=collection_id, current_user=current_user 95 | ) 96 | return collection 97 | 98 | 99 | @router.patch( 100 | "/{collection_id}", 101 | response_model=DetailResponse[CollectionResponse], # 👈 修正 response_model 102 | summary="更新收藏集", 103 | ) 104 | async def update_collection( 105 | collection_id: int, 106 | data: CollectionUpdate, 107 | service: CollectionService = Depends(get_collection_service), 108 | current_user: User = Depends(get_current_user), 109 | ): 110 | updated_collection = await service.update_collection( 111 | collection_id=collection_id, obj_in=data, current_user=current_user 112 | ) 113 | logger.info(f"User {current_user.id} updated collection {collection_id}") 114 | return updated_collection 115 | 116 | 117 | @router.delete( 118 | "/{collection_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除收藏集" 119 | ) 120 | async def delete_collection( 121 | collection_id: int, 122 | service: CollectionService = Depends(get_collection_service), 123 | current_user: User = Depends(get_current_user), 124 | ): 125 | await service.delete_collection( 126 | collection_id=collection_id, current_user=current_user 127 | ) 128 | logger.info(f"User {current_user.id} deleted collection {collection_id}") 129 | 130 | 131 | @router.post( 132 | "/{collection_id}/heroes", 133 | status_code=status.HTTP_204_NO_CONTENT, 134 | summary="把 hero 加入收藏集", 135 | ) 136 | async def add_hero_to_collection( 137 | collection_id: int, 138 | payload: HeroAttachRequest, 139 | service: CollectionService = Depends(get_collection_service), 140 | current_user: User = Depends(get_current_user), 141 | ): 142 | # 👇 更新服务层调用,使用关键字参数 143 | await service.add_hero_to_collection( 144 | collection_id=collection_id, hero_id=payload.hero_id, current_user=current_user 145 | ) 146 | -------------------------------------------------------------------------------- /app/domains/heroes/heroes_services.py: -------------------------------------------------------------------------------- 1 | # app/domains/heroes/heroes_services.py 2 | from fastapi_pagination import Page, Params, paginate 3 | 4 | from app.domains.heroes.heroes_repository import HeroRepository 5 | from app.schemas.heroes import HeroCreate, HeroUpdate, HeroResponse, HeroStoryResponse 6 | from app.schemas.heroes_filter import HeroFilter 7 | 8 | import asyncio 9 | from redis.asyncio import Redis 10 | 11 | 12 | 13 | CACHE_TTL = 60 * 15 # 15 分钟缓存 14 | 15 | 16 | class HeroService: 17 | def __init__(self, repository: HeroRepository, redis: Redis): 18 | """Service layer for hero operations.""" 19 | 20 | self.repository = repository 21 | self.redis = redis 22 | 23 | async def create_hero(self, data: HeroCreate) -> HeroResponse: 24 | new_hero = await self.repository.create(data) 25 | return HeroResponse.model_validate(new_hero) 26 | 27 | # async def get_hero(self, hero_id: int) -> HeroResponse: 28 | # hero = await self.repository.get_by_id(hero_id) 29 | # return HeroResponse.model_validate(hero) 30 | 31 | 32 | async def get_hero(self, hero_id: int) -> HeroResponse: 33 | key = f"hero:{hero_id}" 34 | cached = await self.redis.get(key) 35 | if cached: 36 | # 命中缓存,直接返回 37 | return HeroResponse.model_validate_json(cached) 38 | 39 | # 模拟慢查询:睡 5 秒 40 | await asyncio.sleep(5) 41 | 42 | hero = await self.repository.get_by_id(hero_id) 43 | dto = HeroResponse.model_validate(hero) 44 | 45 | # 写回缓存 46 | await self.redis.set(key, dto.model_dump_json(), ex=CACHE_TTL) 47 | return dto 48 | 49 | # 单字段排序 50 | # async def get_heroes( 51 | # self, 52 | # *, 53 | # search: str | None, 54 | # order_by: str, #单字段排序参数定义 55 | # direction: str, 56 | # limit: int, 57 | # offset: int, 58 | # ) -> tuple[int, list[HeroResponse]]: 59 | # total, heroes_orm = await self.repository.get_all( 60 | # search=search, 61 | # order_by=order_by, 62 | # direction=direction, 63 | # limit=limit, 64 | # offset=offset, 65 | # ) 66 | # heroes_schema = [HeroResponse.model_validate(h) for h in heroes_orm] 67 | # return total, heroes_schema # 👈 返回 tuple[int, list[HeroResponse]] 68 | 69 | # 多字段排序 70 | # async def get_heroes( 71 | # self, 72 | # *, 73 | # search: str | None = None, 74 | # order_by: list[str] | None = None, # 多字段排序参数定义 75 | # limit: int = 10, 76 | # offset: int = 0, 77 | # ) -> tuple[int, list[HeroResponse]]: 78 | # total, heroes_orm = await self.repository.get_all( 79 | # search=search, 80 | # order_by=order_by, 81 | # limit=limit, 82 | # offset=offset, 83 | # ) 84 | # heroes_schema = [HeroResponse.model_validate(h) for h in heroes_orm] 85 | # return total, heroes_schema 86 | 87 | # 使用fastapi-filter实现,仅修改参数 88 | # async def get_heroes( 89 | # self, 90 | # *, 91 | # hero_filter: HeroFilter, # 使用fastapi-filter,过滤、排序参数合并 92 | # limit: int = 10, 93 | # offset: int = 0, 94 | # ) -> tuple[int, list[HeroResponse]]: 95 | # total, heroes_orm = await self.repository.get_all( 96 | # hero_filter=hero_filter, 97 | # limit=limit, 98 | # offset=offset, 99 | # ) 100 | # return total, [HeroResponse.model_validate(h) for h in heroes_orm] 101 | 102 | # 使用分页库 fastapi-pagination 实现 103 | async def get_heroes( 104 | self, 105 | *, 106 | search: str | None = None, 107 | order_by: list[str] | None = None, # 多字段排序参数定义 108 | params: Params, # 分页参数(limit/offset/page/size) 109 | ) -> Page[HeroResponse]: 110 | # 1. 从仓库获取原始 ORM 列表 111 | heroes_list = await self.repository.get_all( 112 | search=search, 113 | order_by=order_by, 114 | ) 115 | 116 | # 2. 映射成 DTO 117 | heroes_dto = [HeroResponse.model_validate(h) for h in heroes_list] 118 | 119 | # 3. 使用 fastapi-pagination 对内存列表分页 120 | return paginate(heroes_dto, params) 121 | 122 | async def update_hero(self, data: HeroUpdate, hero_id: int) -> HeroResponse: 123 | hero = await self.repository.update(data, hero_id) 124 | return HeroResponse.model_validate(hero) 125 | 126 | async def delete_hero(self, hero_id: int) -> None: 127 | await self.repository.delete(hero_id) 128 | 129 | async def get_hero_with_story(self, hero_id: int) -> HeroStoryResponse: 130 | """ 131 | 获取英雄信息,并动态生成一段背景故事。 132 | 这个方法完美展示了服务层的业务逻辑处理能力。 133 | """ 134 | # 1. 调用仓库层,获取最原始的数据库数据 135 | hero_orm = await self.repository.get_by_id(hero_id) 136 | # 2. 在服务层中应用“业务逻辑” 137 | # 这里的逻辑是:根据英雄的名字和别名,虚构一段故事 138 | story_template = ( 139 | f"在繁华的都市背后,流传着一个传说……那就是“{hero_orm.alias}”!" 140 | f"很少有人知道,这位在暗夜中守护光明的英雄,其真实身份是 {hero_orm.name}。" 141 | f"每一个被TA拯救的人,都会在心中默默记下这个名字。" 142 | ) 143 | # 3. 构造并返回一个新的、带有附加信息的数据模型 144 | return HeroStoryResponse( 145 | id=hero_orm.id, 146 | name=hero_orm.name, 147 | alias=hero_orm.alias, 148 | story=story_template, 149 | ) 150 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts. 5 | # this is typically a path given in POSIX (e.g. forward slashes) 6 | # format, relative to the token %(here)s which refers to the location of this 7 | # ini file 8 | script_location = %(here)s/alembic 9 | 10 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 11 | # Uncomment the line below if you want the files to be prepended with date and time 12 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 13 | # for all available tokens 14 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 15 | 16 | # sys.path path, will be prepended to sys.path if present. 17 | # defaults to the current working directory. for multiple paths, the path separator 18 | # is defined by "path_separator" below. 19 | prepend_sys_path = . 20 | 21 | # timezone to use when rendering the date within the migration file 22 | # as well as the filename. 23 | # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. 24 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 25 | # string value is passed to ZoneInfo() 26 | # leave blank for localtime 27 | # timezone = 28 | 29 | # max length of characters to apply to the "slug" field 30 | # truncate_slug_length = 40 31 | 32 | # set to 'true' to run the environment during 33 | # the 'revision' command, regardless of autogenerate 34 | # revision_environment = false 35 | 36 | # set to 'true' to allow .pyc and .pyo files without 37 | # a source .py file to be detected as revisions in the 38 | # versions/ directory 39 | # sourceless = false 40 | 41 | # version location specification; This defaults 42 | # to /versions. When using multiple version 43 | # directories, initial revisions must be specified with --version-path. 44 | # The path separator used here should be the separator specified by "path_separator" 45 | # below. 46 | # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions 47 | 48 | # path_separator; This indicates what character is used to split lists of file 49 | # paths, including version_locations and prepend_sys_path within configparser 50 | # files such as alembic.ini. 51 | # The default rendered in new alembic.ini files is "os", which uses os.pathsep 52 | # to provide os-dependent path splitting. 53 | # 54 | # Note that in order to support legacy alembic.ini files, this default does NOT 55 | # take place if path_separator is not present in alembic.ini. If this 56 | # option is omitted entirely, fallback logic is as follows: 57 | # 58 | # 1. Parsing of the version_locations option falls back to using the legacy 59 | # "version_path_separator" key, which if absent then falls back to the legacy 60 | # behavior of splitting on spaces and/or commas. 61 | # 2. Parsing of the prepend_sys_path option falls back to the legacy 62 | # behavior of splitting on spaces, commas, or colons. 63 | # 64 | # Valid values for path_separator are: 65 | # 66 | # path_separator = : 67 | # path_separator = ; 68 | # path_separator = space 69 | # path_separator = newline 70 | # 71 | # Use os.pathsep. Default configuration used for new projects. 72 | path_separator = os 73 | 74 | 75 | # set to 'true' to search source files recursively 76 | # in each "version_locations" directory 77 | # new in Alembic version 1.10 78 | # recursive_version_locations = false 79 | 80 | # the output encoding used when revision files 81 | # are written from script.py.mako 82 | # output_encoding = utf-8 83 | 84 | # database URL. This is consumed by the user-maintained env.py script only. 85 | # other means of configuring database URLs may be customized within the env.py 86 | # file. 87 | sqlalchemy.url = driver://user:pass@localhost/dbname 88 | 89 | 90 | [post_write_hooks] 91 | # post_write_hooks defines scripts or Python functions that are run 92 | # on newly generated revision scripts. See the documentation for further 93 | # detail and examples 94 | 95 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 96 | # hooks = black 97 | # black.type = console_scripts 98 | # black.entrypoint = black 99 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 100 | 101 | # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module 102 | # hooks = ruff 103 | # ruff.type = module 104 | # ruff.module = ruff 105 | # ruff.options = check --fix REVISION_SCRIPT_FILENAME 106 | 107 | # Alternatively, use the exec runner to execute a binary found on your PATH 108 | # hooks = ruff 109 | # ruff.type = exec 110 | # ruff.executable = ruff 111 | # ruff.options = check --fix REVISION_SCRIPT_FILENAME 112 | 113 | # Logging configuration. This is also consumed by the user-maintained 114 | # env.py script only. 115 | [loggers] 116 | keys = root,sqlalchemy,alembic 117 | 118 | [handlers] 119 | keys = console 120 | 121 | [formatters] 122 | keys = generic 123 | 124 | [logger_root] 125 | level = WARNING 126 | handlers = console 127 | qualname = 128 | 129 | [logger_sqlalchemy] 130 | level = WARNING 131 | handlers = 132 | qualname = sqlalchemy.engine 133 | 134 | [logger_alembic] 135 | level = INFO 136 | handlers = 137 | qualname = alembic 138 | 139 | [handler_console] 140 | class = StreamHandler 141 | args = (sys.stderr,) 142 | level = NOTSET 143 | formatter = generic 144 | 145 | [formatter_generic] 146 | format = %(levelname)-5.5s [%(name)s] %(message)s 147 | datefmt = %H:%M:%S 148 | -------------------------------------------------------------------------------- /app/core/repository.py: -------------------------------------------------------------------------------- 1 | # app/repository/base.py 2 | from typing import Generic, TypeVar, Type, Any 3 | from pydantic import BaseModel 4 | 5 | from sqlalchemy import select, func, desc, asc, or_ 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.exc import IntegrityError 8 | 9 | from app.models.base import Base 10 | from app.core.exceptions import NotFoundException, AlreadyExistsException 11 | 12 | # 为 SQLAlchemy 模型、Pydantic 创建/更新 Schema 定义类型变量 13 | ModelType = TypeVar("ModelType", bound=Base) 14 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 15 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 16 | 17 | 18 | # 👇 将 CreateSchemaType 和 UpdateSchemaType 添加到泛型签名中 19 | class RepositoryBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 20 | def __init__(self, model: Type[ModelType], session: AsyncSession): 21 | self.model = model 22 | self.session = session 23 | 24 | async def get(self, *, id: Any) -> ModelType: 25 | """ 26 | 根据 ID 高效获取单个记录。 27 | 如果未找到,则直接抛出 NotFoundException 异常。 28 | """ 29 | db_obj = await self.session.get(self.model, id) 30 | if not db_obj: 31 | raise NotFoundException(f"{self.model.__name__} with id {id} not found") 32 | return db_obj 33 | 34 | async def create(self, *, obj_in: CreateSchemaType, **extra_data: Any) -> ModelType: 35 | """ 36 | 从 Pydantic Schema 创建一个新对象,并处理唯一性约束异常。 37 | """ 38 | # 👇 逻辑内聚:将 .model_dump() 封装在基类内部 39 | obj_in_data = obj_in.model_dump() 40 | # **extra_data 会覆盖 obj_in_data 中的同名字段,确保服务器逻辑优先 41 | final_data = {**obj_in_data, **extra_data} 42 | db_obj = self.model(**final_data) 43 | self.session.add(db_obj) 44 | try: 45 | await self.session.flush() 46 | await self.session.refresh(db_obj) 47 | return db_obj 48 | except IntegrityError: 49 | raise AlreadyExistsException( 50 | f"{self.model.__name__} with conflicting data already exists." 51 | ) 52 | 53 | async def update(self, *, db_obj: ModelType, obj_in: UpdateSchemaType) -> ModelType: 54 | """ 55 | 从 Pydantic Schema 更新一个已存在的对象。 56 | """ 57 | # 👇 逻辑内聚:将 .model_dump(exclude_unset=True) 封装在基类内部 58 | update_data = obj_in.model_dump(exclude_unset=True) 59 | 60 | if not update_data: 61 | return db_obj # 没有字段需要更新,直接返回原对象 62 | 63 | for field, value in update_data.items(): 64 | setattr(db_obj, field, value) 65 | 66 | self.session.add(db_obj) 67 | await self.session.flush() 68 | await self.session.refresh(db_obj) 69 | return db_obj 70 | 71 | async def delete(self, *, id: Any) -> ModelType: 72 | """删除一个对象""" 73 | # 👇 优化:get 方法已经处理了 Not Found,所以这里的检查是多余的 74 | obj = await self.get(id=id) 75 | await self.session.delete(obj) 76 | await self.session.flush() 77 | return obj 78 | 79 | async def get_list( 80 | self, 81 | *, 82 | limit: int | None, 83 | offset: int | None, 84 | search: str | None = None, 85 | search_fields: list[str] | None = None, 86 | order_by: list[str] | None = None, 87 | **filters, 88 | ) -> tuple[int, list[ModelType]]: 89 | """ 90 | 获取记录列表,返回 (总数, 项目列表) 的元组。 91 | 支持可选的分页、搜索、排序和任意数量的精确过滤。 92 | """ 93 | 94 | query = select(self.model) 95 | 96 | # 1. 应用任意数量的精确过滤条件 (例如: user_id=1, is_active=True) 97 | if filters: 98 | query = query.filter_by(**filters) 99 | 100 | # 2. 应用模糊搜索条件 101 | if search and search_fields: 102 | search_clauses = [ 103 | getattr(self.model, field).ilike(f"%{search}%") 104 | for field in search_fields 105 | if hasattr(self.model, field) 106 | ] 107 | if search_clauses: 108 | query = query.where(or_(*search_clauses)) 109 | 110 | # 3. 应用排序 111 | ordering_clauses = [] 112 | 113 | # 首先处理用户指定的排序 114 | if order_by: 115 | for field in order_by: 116 | is_desc = field.startswith("-") 117 | field_name = field.lstrip("-") 118 | if hasattr(self.model, field_name): 119 | # 依然使用 getattr 保证类型安全 120 | column = getattr(self.model, field_name) 121 | ordering_clauses.append(desc(column) if is_desc else asc(column)) 122 | 123 | # 兜底稳定排序:检查用户的原始输入,如果未指定按 id 排序,则追加 124 | if hasattr(self.model, "id") and not any( 125 | field.lstrip("-") == "id" for field in (order_by or []) 126 | ): 127 | id_column = getattr(self.model, "id") 128 | ordering_clauses.append(asc(id_column)) 129 | 130 | # 最后,如果列表不为空,则只调用一次 .order_by() 131 | if ordering_clauses: 132 | query = query.order_by(*ordering_clauses) 133 | 134 | # 4. 获取总数 (必须在分页之前) 135 | count_query = select(func.count()).select_from(query.subquery()) 136 | total = (await self.session.scalar(count_query)) or 0 137 | 138 | # 5. 应用分页 139 | if offset is not None: 140 | query = query.offset(offset) 141 | if limit is not None: 142 | query = query.limit(limit) 143 | 144 | # 6. 执行查询 145 | result = await self.session.scalars(query) 146 | items = list(result.all()) 147 | 148 | return total, items 149 | -------------------------------------------------------------------------------- /app/domains/collections/collections_service.py: -------------------------------------------------------------------------------- 1 | # app/domains/collections/collections_service.py 2 | import math 3 | 4 | from app.core.exceptions import ForbiddenException 5 | from app.domains.collections.collections_repository import CollectionRepository 6 | from app.schemas.collections import ( 7 | CollectionCreate, 8 | CollectionResponse, 9 | CollectionUpdate, 10 | CollectionResponseDetail, 11 | ) 12 | from app.schemas.response import DetailResponse, ListResponse, Meta, PaginationInfo 13 | from app.models.users import User 14 | from app.models.collections import Collection 15 | 16 | 17 | class _OwnerScopedRepo: 18 | """ 19 | 一个内部的、轻量级的仓库代理。 20 | 它的职责是在调用真正的仓库方法之前,动态地注入所有权逻辑。 21 | """ 22 | 23 | def __init__(self, repository: CollectionRepository, user: User): 24 | self._repo = repository 25 | self._user = user 26 | 27 | async def create(self, *, obj_in: CollectionCreate) -> Collection: 28 | return await self._repo.create(obj_in=obj_in, user_id=self._user.id) 29 | 30 | async def get(self, *, id: int) -> Collection: 31 | # 获取后检查所有权 32 | db_obj = await self._repo.get(id=id) 33 | if db_obj.user_id != self._user.id: 34 | raise ForbiddenException("Access denied.") 35 | return db_obj 36 | 37 | async def get_list(self, **kwargs) -> tuple[int, list[Collection]]: 38 | # 自动注入 user_id 过滤器 39 | kwargs["user_id"] = self._user.id 40 | return await self._repo.get_list(**kwargs) 41 | 42 | async def update(self, *, id: int, obj_in: CollectionUpdate) -> Collection: 43 | # 先用带权限的 get 检查所有权 44 | db_obj = await self.get(id=id) 45 | # 再调用无权限的 repo.update 46 | return await self._repo.update(db_obj=db_obj, obj_in=obj_in) 47 | 48 | async def delete(self, *, id: int) -> None: 49 | # 先用带权限的 get 检查所有权 50 | await self.get(id=id) 51 | # 再调用无权限的 repo.delete 52 | await self._repo.delete(id=id) 53 | 54 | async def add_hero(self, *, collection_id: int, hero_id: int) -> None: 55 | # 确保 collection 属于当前用户 56 | await self.get(id=collection_id) 57 | # 调用原始 repo 的特殊方法 58 | await self._repo.add_hero(collection_id=collection_id, hero_id=hero_id) 59 | 60 | 61 | class CollectionService: 62 | def __init__(self, repository: CollectionRepository): 63 | self.repository = repository # 注入的是无作用域的、纯粹的仓库 64 | 65 | def _get_scoped_repo(self, current_user: User) -> _OwnerScopedRepo: 66 | """一个内部辅助方法,用于获取一个临时的、带用户作用域的仓库代理。""" 67 | return _OwnerScopedRepo(self.repository, current_user) 68 | 69 | async def create_collection( 70 | self, *, obj_in: CollectionCreate, current_user: User 71 | ) -> DetailResponse[CollectionResponse]: 72 | scoped_repo = self._get_scoped_repo(current_user) 73 | new_collection_orm = await scoped_repo.create(obj_in=obj_in) 74 | collection_dto = CollectionResponse.model_validate(new_collection_orm) 75 | return DetailResponse(data=collection_dto) 76 | 77 | async def get_collection( 78 | self, *, collection_id: int, current_user: User 79 | ) -> DetailResponse[CollectionResponseDetail]: 80 | scoped_repo = self._get_scoped_repo(current_user) 81 | collection_orm = await scoped_repo.get(id=collection_id) 82 | collection_dto = CollectionResponseDetail.model_validate(collection_orm) 83 | return DetailResponse(data=collection_dto) 84 | 85 | async def get_collections( 86 | self, 87 | *, 88 | limit: int | None, 89 | offset: int | None, 90 | current_user: User, 91 | search: str | None = None, 92 | order_by: list[str] | None = None, 93 | ) -> ListResponse[CollectionResponse]: 94 | scoped_repo = self._get_scoped_repo(current_user) 95 | total, collections_orm = await scoped_repo.get_list( 96 | limit=limit, offset=offset, search=search, order_by=order_by 97 | ) 98 | items_dto = [CollectionResponse.model_validate(c) for c in collections_orm] 99 | # ... (组装 ListResponse 的逻辑保持不变) ... 100 | pagination_info = None 101 | if limit is not None and offset is not None: 102 | size = limit 103 | page = offset // size + 1 104 | pages = math.ceil(total / size) if size > 0 else 0 105 | pagination_info = PaginationInfo( 106 | total=total, page=page, size=size, pages=pages 107 | ) 108 | return ListResponse(data=items_dto, meta=Meta(pagination=pagination_info)) 109 | 110 | async def update_collection( 111 | self, *, collection_id: int, obj_in: CollectionUpdate, current_user: User 112 | ) -> DetailResponse[CollectionResponse]: 113 | scoped_repo = self._get_scoped_repo(current_user) 114 | updated_collection_orm = await scoped_repo.update( 115 | id=collection_id, obj_in=obj_in 116 | ) 117 | collection_dto = CollectionResponse.model_validate(updated_collection_orm) 118 | return DetailResponse(data=collection_dto) 119 | 120 | async def delete_collection( 121 | self, *, collection_id: int, current_user: User 122 | ) -> None: 123 | scoped_repo = self._get_scoped_repo(current_user) 124 | await scoped_repo.delete(id=collection_id) 125 | 126 | async def add_hero_to_collection( 127 | self, *, collection_id: int, hero_id: int, current_user: User 128 | ) -> None: 129 | scoped_repo = self._get_scoped_repo(current_user) 130 | await scoped_repo.add_hero(collection_id=collection_id, hero_id=hero_id) 131 | -------------------------------------------------------------------------------- /app/domains/heroes/heroes_serv.py: -------------------------------------------------------------------------------- 1 | # app/domains/heroes/heroes_services.py 2 | 3 | import math 4 | from redis.asyncio import Redis 5 | 6 | # 导入我们的标准化响应模型 7 | from app.schemas.response import DetailResponse, ListResponse, Meta, PaginationInfo 8 | from app.schemas.heroes import HeroCreate, HeroUpdate, HeroResponse, HeroStoryResponse 9 | from app.domains.heroes.heroes_repo import HeroRepository 10 | 11 | CACHE_TTL = 60 * 15 # 15分钟缓存 12 | 13 | 14 | class HeroService: 15 | """服务层,负责处理英雄相关的业务逻辑。""" 16 | 17 | def __init__(self, repository: HeroRepository, redis: Redis): 18 | """ 19 | 初始化服务层。 20 | 21 | Args: 22 | repository: HeroRepository 的实例。 23 | redis: Redis 客户端的异步实例。 24 | """ 25 | self.repository = repository 26 | self.redis = redis 27 | 28 | async def create_hero(self, *, obj_in: HeroCreate) -> DetailResponse[HeroResponse]: 29 | """ 30 | 创建一个新的 Hero。 31 | 32 | Args: 33 | obj_in: 创建 Hero 所需的数据。 34 | 35 | Returns: 36 | 包含新创建 Hero 数据的标准响应体。 37 | """ 38 | # 调用仓库层的 create 方法,它现在是类型安全的 39 | new_hero_orm = await self.repository.create(obj_in=obj_in) 40 | 41 | # 将 ORM 对象转换为 Pydantic Schema 42 | hero_dto = HeroResponse.model_validate(new_hero_orm) 43 | 44 | # 使用标准响应模型包装返回 45 | return DetailResponse(data=hero_dto) 46 | 47 | async def get_hero(self, *, hero_id: int) -> DetailResponse[HeroResponse]: 48 | """ 49 | 获取单个 Hero,优先从缓存中读取。 50 | 51 | Args: 52 | hero_id: Hero 的 ID。 53 | 54 | Returns: 55 | 包含 Hero 数据的标准响应体。 56 | """ 57 | key = f"hero:{hero_id}" 58 | cached = await self.redis.get(key) 59 | 60 | if cached: 61 | # 命中缓存,直接验证并包装返回 62 | hero_dto = HeroResponse.model_validate_json(cached) 63 | return DetailResponse(data=hero_dto) 64 | 65 | # 缓存未命中,从数据库查询 66 | hero_orm = await self.repository.get(id=hero_id) 67 | hero_dto = HeroResponse.model_validate(hero_orm) 68 | 69 | # 写回缓存 70 | await self.redis.set(key, hero_dto.model_dump_json(), ex=CACHE_TTL) 71 | 72 | return DetailResponse(data=hero_dto) 73 | 74 | async def get_heroes( 75 | self, 76 | *, 77 | limit: int | None, 78 | offset: int | None, 79 | search: str | None = None, 80 | order_by: list[str] | None = None, 81 | ) -> ListResponse[HeroResponse]: 82 | """ 83 | 获取 Hero 列表。 84 | 85 | 此方法接收 limit 和 offset,并负责将仓库层返回的原始数据 86 | 组装成标准的 ListResponse 响应体。 87 | """ 88 | # 1. 从仓库层获取原始数据元组 (总数, ORM对象列表) 89 | total, heroes_orm = await self.repository.get_list( 90 | limit=limit, 91 | offset=offset, 92 | search=search, 93 | order_by=order_by, 94 | ) 95 | 96 | # 2. 将列表中的 ORM 对象 (Hero) 转换为 Pydantic Schema (HeroResponse) 97 | items_dto = [HeroResponse.model_validate(h) for h in heroes_orm] 98 | 99 | # 3. 在服务层组装最终的 ListResponse 对象 100 | pagination_info = None 101 | if limit is not None and offset is not None: 102 | size = limit 103 | # 👇 从 limit 和 offset 反向计算出 page,用于在响应中返回 104 | page = offset // size + 1 105 | pages = math.ceil(total / size) if size > 0 else 0 106 | pagination_info = PaginationInfo( 107 | total=total, page=page, size=size, pages=pages 108 | ) 109 | 110 | return ListResponse( 111 | data=items_dto, 112 | meta=Meta(pagination=pagination_info) 113 | ) 114 | 115 | async def update_hero(self, *, hero_id: int, obj_in: HeroUpdate) -> DetailResponse[HeroResponse]: 116 | """ 117 | 更新一个 Hero,并清除相关缓存。 118 | 119 | Args: 120 | hero_id: 要更新的 Hero 的 ID。 121 | obj_in: 更新所需的数据。 122 | """ 123 | # 步骤 1: 先通过 id 获取要更新的数据库对象。 124 | # 仓库的 get 方法已经内置了 "Not Found" 异常处理。 125 | db_obj_to_update = await self.repository.get(id=hero_id) 126 | 127 | # (这里是执行额外业务逻辑的最佳位置, 例如权限检查) 128 | # if db_obj_to_update.owner_id != current_user.id: 129 | # raise PermissionDeniedException(...) 130 | 131 | # 步骤 2: 将获取到的对象和更新数据传递给 update 方法。 132 | updated_hero_orm = await self.repository.update( 133 | db_obj=db_obj_to_update, 134 | obj_in=obj_in 135 | ) 136 | 137 | # 将更新后的 ORM 对象转换为 Pydantic Schema 138 | hero_dto = HeroResponse.model_validate(updated_hero_orm) 139 | 140 | # 关键:执行写操作后,必须让缓存失效! 141 | key = f"hero:{hero_id}" 142 | await self.redis.delete(key) 143 | 144 | # 使用标准响应模型包装返回 145 | return DetailResponse(data=hero_dto) 146 | 147 | async def delete_hero(self, *, hero_id: int) -> None: 148 | """ 149 | 删除一个 Hero,并清除相关缓存。 150 | """ 151 | # 调用仓库层的 delete 方法 152 | await self.repository.delete(id=hero_id) 153 | 154 | # ---------------------------------------------------- 155 | # 关键:执行写操作后,必须让缓存失效! 156 | key = f"hero:{hero_id}" 157 | await self.redis.delete(key) 158 | # ---------------------------------------------------- 159 | 160 | async def get_hero_with_story(self, hero_id: int) -> HeroStoryResponse: 161 | """ 162 | 获取英雄信息,并动态生成一段背景故事。 163 | 这个方法完美展示了服务层的业务逻辑处理能力。 164 | """ 165 | # 1. 调用仓库层,获取最原始的数据库数据 166 | hero_orm = await self.get_hero(hero_id=hero_id) 167 | # 2. 在服务层中应用“业务逻辑” 168 | # 这里的逻辑是:根据英雄的名字和别名,虚构一段故事 169 | story_template = ( 170 | f"在繁华的都市背后,流传着一个传说……那就是“{hero_orm.data.alias}”!" 171 | f"很少有人知道,这位在暗夜中守护光明的英雄,其真实身份是 {hero_orm.data.name}。" 172 | f"每一个被TA拯救的人,都会在心中默默记下这个名字。" 173 | ) 174 | # 3. 构造并返回一个新的、带有附加信息的数据模型 175 | return HeroStoryResponse( 176 | id=hero_orm.data.id, 177 | name=hero_orm.data.name, 178 | alias=hero_orm.data.alias, 179 | powers=hero_orm.data.powers, 180 | story=story_template, 181 | ) 182 | -------------------------------------------------------------------------------- /app/domains/heroes/heroes_repository.py: -------------------------------------------------------------------------------- 1 | # app/domains/heroes/heroes_repository.py 2 | from sqlalchemy import select, func, or_, desc, asc 3 | from sqlalchemy.exc import IntegrityError 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from app.core.exceptions import AlreadyExistsException, NotFoundException 7 | from app.models.heroes import Hero 8 | from app.schemas.heroes import HeroCreate, HeroUpdate 9 | from app.schemas.heroes_filter import HeroFilter 10 | 11 | 12 | class HeroRepository: 13 | """Repository for handling hero database operations.""" 14 | 15 | def __init__(self, session: AsyncSession): 16 | self.session = session 17 | 18 | async def create(self, hero_data: HeroCreate) -> Hero: 19 | """Create a new hero.""" 20 | hero = Hero(**hero_data.model_dump()) 21 | try: 22 | self.session.add(hero) 23 | await self.session.commit() 24 | await self.session.refresh(hero) 25 | return hero 26 | except IntegrityError: 27 | await self.session.rollback() 28 | raise AlreadyExistsException( 29 | f"Hero with alias {hero_data.alias} already exists" 30 | ) 31 | 32 | async def get_by_id(self, hero_id: int) -> Hero: 33 | """Fetch a hero by id.""" 34 | hero = await self.session.get(Hero, hero_id) 35 | if not hero: 36 | raise NotFoundException(f"Hero with id {hero_id} not found") 37 | return hero 38 | 39 | 40 | # 第一版:单字段排序 41 | # async def get_all( 42 | # self, 43 | # *, 44 | # search: str | None = None, 45 | # order_by: str = "id", 46 | # direction: str = "asc", 47 | # limit: int = 10, 48 | # offset: int = 0, 49 | # ) -> tuple[int, list[Hero]]: 50 | # query = select(Hero) 51 | 52 | # # 1. 搜索 53 | # if search: 54 | # query = query.where( 55 | # or_( 56 | # Hero.name.ilike(f"%{search}%"), 57 | # Hero.alias.ilike(f"%{search}%"), 58 | # Hero.powers.ilike(f"%{search}%"), 59 | # ) 60 | # ) 61 | 62 | # # 2. 排序 63 | # order_column = getattr(Hero, order_by, Hero.id) 64 | # query = query.order_by( 65 | # desc(order_column) if direction == "desc" else asc(order_column) 66 | # ) 67 | 68 | # # 3. 总数 69 | # count_query = select(func.count()).select_from(query.subquery()) 70 | # total = (await self.session.scalar(count_query)) or 0 71 | 72 | # # 4. 分页 73 | # paginated_query = query.offset(offset).limit(limit) 74 | # items = list(await self.session.scalars(paginated_query)) 75 | 76 | # return total, items 77 | 78 | 79 | 80 | # 第二版:手动多字段排序 81 | # async def get_all( 82 | # self, 83 | # *, 84 | # search: str | None = None, 85 | # order_by: list[str] | None = None, 86 | # limit: int = 10, 87 | # offset: int = 0, 88 | # ) -> tuple[int, list[Hero]]: 89 | # query = select(Hero) 90 | 91 | # # 1. 搜索 92 | # if search: 93 | # query = query.where( 94 | # or_( 95 | # Hero.name.ilike(f"%{search}%"), 96 | # Hero.alias.ilike(f"%{search}%"), 97 | # Hero.powers.ilike(f"%{search}%"), 98 | # ) 99 | # ) 100 | 101 | # # 2. 排序 102 | # ordering_clauses = [] 103 | 104 | # if order_by: 105 | # for field in order_by: 106 | # is_desc = field.startswith("-") 107 | # field_name = field.lstrip("-") 108 | # if not hasattr(Hero, field_name): 109 | # continue # 跳过非法字段 110 | # column = getattr(Hero, field_name) 111 | # ordering_clauses.append(desc(column) if is_desc else asc(column)) 112 | 113 | # # 自动追加默认次排序字段 (name ASC) 如果没指定 name 114 | # if not any(field.lstrip("-") == "name" for field in (order_by or [])): 115 | # ordering_clauses.append(asc(Hero.name)) 116 | 117 | # # 固定追加 id ASC 以保证排序稳定 118 | # ordering_clauses.append(asc(Hero.id)) 119 | 120 | # # 应用排序 121 | # query = query.order_by(*ordering_clauses) 122 | 123 | # # 3. 总数 124 | # count_query = select(func.count()).select_from(query.subquery()) 125 | # total = (await self.session.scalar(count_query)) or 0 126 | 127 | # # 4. 分页 128 | # paginated_query = query.offset(offset).limit(limit) 129 | # items = list(await self.session.scalars(paginated_query)) 130 | 131 | # return total, items 132 | 133 | 134 | ''' 135 | 我现在是明白为什么 FastAPI 里的排序过滤库和分页库用的人少了 136 | 分页库 1.4k star 说明用的人还多点,省去了自己封装的步骤 137 | 过滤库才 279 star 说明用的人很少,而且影响了自动文档的生成 138 | 另外这个库使用的方式已经太老了,很久也不维护,跟不上新的 SQLAlchemy 2.0 的实现了 139 | 我之前的全手动实现又灵活又方便,果然还是得手动搞 140 | 作为教程代码,这些都留在这把,文章都已经写了,这里建议看到的大家还是用手动实现吧。 141 | 最多懒得搞分页模型封装,那就用下 fastapi-pagination 这个库 142 | 但是我感觉他这个库最后的返回结构也不如我之前的手动实现给的数据丰富 143 | 像我这个已经做了模型的,直接用自己的就好。 144 | 查了些资料,确实日常有分页库就足够了,写好 Response 模型,用分页库包一下就完事。 145 | ''' 146 | 147 | # 第三版:使用了 fastapi-filter 库的仓库层函数 148 | # async def get_all( 149 | # self, 150 | # *, 151 | # hero_filter: HeroFilter, 152 | # limit: int = 10, 153 | # offset: int = 0, 154 | # ) -> tuple[int, list[Hero]]: 155 | # # 1. 构造初始查询 156 | # query = select(Hero) 157 | 158 | # # 2. 搜索 + 排序(链式) 159 | # query = hero_filter.filter(query) # -> 调用 filter 160 | # query = hero_filter.sort(query) # -> 调用 sort 161 | 162 | # print(type(query)) 163 | 164 | # # 3. 总数 165 | # count_query = select(func.count()).select_from(query.subquery()) 166 | # total = (await self.session.scalar(count_query)) or 0 167 | 168 | # # 4. 分页 169 | # paginated_query = query.offset(offset).limit(limit) 170 | # items = list(await self.session.scalars(paginated_query)) 171 | 172 | # return total, items 173 | 174 | 175 | # 第四版:使用了 fastapi-pagination 库的仓库层函数 176 | async def get_all( 177 | self, 178 | *, 179 | search: str | None = None, 180 | order_by: list[str] | None = None, 181 | limit: int | None = None, 182 | offset: int | None = None, 183 | ) -> list[Hero]: 184 | 185 | query = select(Hero) 186 | 187 | # 1. 搜索 188 | if search: 189 | query = query.where( 190 | or_( 191 | Hero.name.ilike(f"%{search}%"), 192 | Hero.alias.ilike(f"%{search}%"), 193 | Hero.powers.ilike(f"%{search}%"), 194 | ) 195 | ) 196 | 197 | # 2. 排序 198 | ordering_clauses = [] 199 | 200 | if order_by: 201 | for field in order_by: 202 | is_desc = field.startswith("-") 203 | field_name = field.lstrip("-") 204 | if not hasattr(Hero, field_name): 205 | continue # 跳过非法字段 206 | column = getattr(Hero, field_name) 207 | ordering_clauses.append(desc(column) if is_desc else asc(column)) 208 | 209 | # 自动追加默认次排序字段 (name ASC) 如果没指定 name 210 | # 这个自动追加 name 字段为次选字段是基于我们这个 Hero 模型的业务考虑 211 | # 以 name 稳定排序更美观,但是作为通用排序逻辑的话是不需的,只要有 id 兜底就行 212 | # if not any(field.lstrip("-") == "name" for field in (order_by or [])): 213 | # ordering_clauses.append(asc(Hero.name)) 214 | 215 | # 固定追加 id ASC 以保证排序稳定 216 | ordering_clauses.append(asc(Hero.id)) 217 | 218 | # 应用排序 219 | query = query.order_by(*ordering_clauses) 220 | 221 | # 3. 分页 222 | if offset is not None: 223 | query = query.offset(offset) 224 | if limit is not None: 225 | query = query.limit(limit) 226 | 227 | # 4. 执行查询 228 | result = await self.session.scalars(query) 229 | 230 | return list(result) 231 | 232 | 233 | 234 | async def update(self, hero_data: HeroUpdate, hero_id: int) -> Hero: 235 | """Update an existing hero.""" 236 | hero = await self.session.get(Hero, hero_id) 237 | if not hero: 238 | raise NotFoundException(f"Hero with id {hero_id} not found") 239 | 240 | update_data = hero_data.model_dump(exclude_unset=True) 241 | if not update_data: 242 | raise ValueError("No fields to update") 243 | for key, value in update_data.items(): 244 | setattr(hero, key, value) 245 | await self.session.commit() 246 | await self.session.refresh(hero) 247 | return hero 248 | 249 | async def delete(self, hero_id: int) -> None: 250 | """Delete a hero.""" 251 | hero = await self.session.get(Hero, hero_id) 252 | if not hero: 253 | raise NotFoundException(f"Hero with id {hero_id} not found") 254 | 255 | await self.session.delete(hero) 256 | await self.session.commit() 257 | -------------------------------------------------------------------------------- /app/api/v1/heroes_route.py: -------------------------------------------------------------------------------- 1 | # app/api/v1/heroes_route.py 2 | from loguru import logger 3 | from fastapi import APIRouter, Depends, status, Query 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from fastapi_filter import FilterDepends 6 | from fastapi_pagination import Page, Params 7 | 8 | from app.core.database import get_db 9 | from app.domains.heroes.heroes_repository import HeroRepository 10 | from app.domains.heroes.heroes_services import HeroService 11 | from app.schemas.heroes import ( 12 | HeroCreate, 13 | HeroUpdate, 14 | HeroResponse, 15 | HeroStoryResponse, 16 | HeroListResponse, 17 | Pagination, 18 | Sort, 19 | Filters, 20 | OrderByRule, 21 | ) 22 | from app.schemas.heroes_filter import HeroFilter 23 | from app.domains.users.auth_dependencies import get_current_user 24 | from redis.asyncio import Redis 25 | from app.core.redis_db import get_cache_redis 26 | 27 | 28 | router = APIRouter(prefix="/heroes", tags=["Heroes"], dependencies=[Depends(get_current_user)]) 29 | 30 | 31 | def get_hero_service(session: AsyncSession = Depends(get_db),redis: Redis = Depends(get_cache_redis)) -> HeroService: 32 | """Dependency for getting HeroService instance.""" 33 | repository = HeroRepository(session) 34 | return HeroService(repository, redis) 35 | 36 | 37 | @router.post("", response_model=HeroResponse, status_code=status.HTTP_201_CREATED) 38 | async def create_hero( 39 | data: HeroCreate, service: HeroService = Depends(get_hero_service) 40 | ) -> HeroResponse: 41 | """Create new hero.""" 42 | try: 43 | created_hero = await service.create_hero(data=data) 44 | logger.info(f"Created hero {created_hero.id}") 45 | return created_hero 46 | except Exception as e: 47 | logger.error(f"Failed to create hero: {str(e)}") 48 | raise 49 | 50 | 51 | # 单字段排序路由层 52 | # @router.get("", response_model=HeroListResponse) 53 | # async def list_heroes( 54 | # search: str | None = Query(None, description="按名称、别名、能力进行模糊搜索"), 55 | # order_by: str = Query("id", description="排序字段:name, alias, id"), 56 | # direction: str = Query("asc", description="排序方向", regex="^(asc|desc)$"), 57 | # page: int = Query(1, ge=1, description="页码"), 58 | # limit: int = Query(10, ge=1, le=100, description="每页数量"), 59 | # service: HeroService = Depends(get_hero_service), 60 | # ) -> HeroListResponse: 61 | # try: 62 | # offset = (page - 1) * limit 63 | # total, heroes = await service.get_heroes( 64 | # search=search, 65 | # order_by=order_by, 66 | # direction=direction, 67 | # limit=limit, 68 | # offset=offset, 69 | # ) 70 | # total_pages = (total + limit - 1) // limit 71 | 72 | # return HeroListResponse( 73 | # data=heroes, 74 | # pagination=Pagination( 75 | # currentPage=page, 76 | # totalPages=total_pages, 77 | # totalItems=total, 78 | # limit=limit, 79 | # hasMore=page < total_pages, 80 | # previousPage=page - 1 if page > 1 else None, 81 | # nextPage=page + 1 if page < total_pages else None, 82 | # ), 83 | # sort=Sort(field=order_by, direction=direction), 84 | # filters=Filters(search=search), 85 | # ) 86 | # except Exception as e: 87 | # logger.error(f"Failed to fetch heroes: {e}") 88 | # raise 89 | 90 | 91 | # 手动多字段排序路由层 92 | # @router.get("", response_model=HeroListResponse) 93 | # async def list_heroes( 94 | # search: str | None = Query( 95 | # None, 96 | # description="按名称、别名、能力进行模糊搜索", 97 | # max_length=100, 98 | # ), 99 | # order_by: list[str] | None = Query( 100 | # None, 101 | # description="排序字段,如 -name,alias,powers(-表示倒序,默认正序)", 102 | # example=["-name", "alias"], 103 | # ), 104 | # page: int = Query(1, ge=1, description="页码"), 105 | # limit: int = Query(10, ge=1, le=100, description="每页数量"), 106 | # service: HeroService = Depends(get_hero_service), 107 | # ) -> HeroListResponse: 108 | # try: 109 | # offset = (page - 1) * limit 110 | # # 1. 将原始的字符串列表 ['-name', 'alias'] 直接传给服务层 111 | # total, heroes = await service.get_heroes( 112 | # search=search, 113 | # order_by=order_by, 114 | # limit=limit, 115 | # offset=offset, 116 | # ) 117 | # total_pages = (total + limit - 1) // limit 118 | 119 | # # 2. 将字符串列表转换为结构化的 OrderByRule 列表,用于最终返回 120 | # order_rules = [ 121 | # OrderByRule(field=field.lstrip("-"), dir="desc" if field.startswith("-") else "asc") 122 | # for field in (order_by or []) 123 | # ] 124 | # # 3. 组装最终响应 125 | # return HeroListResponse( 126 | # data=heroes, 127 | # pagination=Pagination( 128 | # currentPage=page, 129 | # totalPages=total_pages, 130 | # totalItems=total, 131 | # limit=limit, 132 | # hasMore=page < total_pages, 133 | # previousPage=page - 1 if page > 1 else None, 134 | # nextPage=page + 1 if page < total_pages else None, 135 | # ), 136 | # sort=Sort(fields=order_rules), 137 | # filters=Filters(search=search), 138 | # ) 139 | # except Exception as e: 140 | # logger.error(f"Failed to fetch heroes: {e}") 141 | # raise 142 | 143 | 144 | # 使用了 fastapi-filter 库的路由 145 | # @router.get("", response_model=HeroListResponse) 146 | # async def list_heroes( 147 | # hero_filter: HeroFilter = FilterDepends(HeroFilter), # 核心一行 148 | # page: int = Query(1, ge=1, description="页码"), 149 | # limit: int = Query(10, ge=1, le=100, description="每页数量"), 150 | # service: HeroService = Depends(get_hero_service), 151 | # ) -> HeroListResponse: 152 | # try: 153 | # offset = (page - 1) * limit 154 | # total, heroes = await service.get_heroes( 155 | # hero_filter=hero_filter, 156 | # limit=limit, 157 | # offset=offset, 158 | # ) 159 | # total_pages = (total + limit - 1) // limit 160 | 161 | # # 为了返回给前端,把字符串列表转成 OrderByRule 162 | # order_rules = [ 163 | # OrderByRule(field=f.lstrip("-"), dir="desc" if f.startswith("-") else "asc") 164 | # for f in (hero_filter.order_by or []) 165 | # ] 166 | 167 | # return HeroListResponse( 168 | # data=heroes, 169 | # pagination=Pagination( 170 | # currentPage=page, 171 | # totalPages=total_pages, 172 | # totalItems=total, 173 | # limit=limit, 174 | # hasMore=page < total_pages, 175 | # previousPage=page - 1 if page > 1 else None, 176 | # nextPage=page + 1 if page < total_pages else None, 177 | # ), 178 | # sort=Sort(fields=order_rules), 179 | # filters=Filters(search=hero_filter.search), 180 | # ) 181 | # except Exception as e: 182 | # logger.error(f"Failed to fetch heroes: {e}") 183 | # raise 184 | 185 | 186 | # 使用了 fastapi-pagination 分页库的路由层 187 | @router.get("", response_model=Page[HeroResponse]) 188 | async def list_heroes( 189 | search: str | None = Query( 190 | None, 191 | description="按名称、别名、能力进行模糊搜索", 192 | max_length=100, 193 | ), 194 | order_by: list[str] | None = Query( 195 | None, 196 | description="排序字段,如 -name,alias (-表示倒序,默认正序)", 197 | example=["-name", "alias"], 198 | ), 199 | params: Params = Depends(), # fastapi-paginate 提供分页参数 200 | service: HeroService = Depends(get_hero_service), 201 | ) -> Page[HeroResponse]: 202 | # 调用服务层获取分页结果 203 | heroes_page = await service.get_heroes( 204 | search=search, 205 | order_by=order_by, 206 | params=params, 207 | ) 208 | 209 | return heroes_page 210 | 211 | 212 | @router.get("/{hero_id}", response_model=HeroResponse) 213 | async def get_hero( 214 | hero_id: int, 215 | service: HeroService = Depends(get_hero_service), 216 | ) -> HeroResponse: 217 | """Get hero by id.""" 218 | try: 219 | hero = await service.get_hero(hero_id=hero_id) 220 | logger.info(f"Retrieved hero {hero_id}") 221 | return hero 222 | except Exception as e: 223 | logger.error(f"Failed to get hero {hero_id}: {str(e)}") 224 | raise 225 | 226 | 227 | @router.patch("/{hero_id}", response_model=HeroResponse, status_code=status.HTTP_200_OK) 228 | async def update_hero( 229 | data: HeroUpdate, 230 | hero_id: int, 231 | service: HeroService = Depends(get_hero_service), 232 | ) -> HeroResponse: 233 | """Update hero.""" 234 | try: 235 | updated_hero = await service.update_hero(data=data, hero_id=hero_id) 236 | logger.info(f"Updated hero {hero_id}") 237 | return updated_hero 238 | except Exception as e: 239 | logger.error(f"Failed to update hero {hero_id}: {str(e)}") 240 | raise 241 | 242 | 243 | @router.delete("/{hero_id}", status_code=status.HTTP_204_NO_CONTENT) 244 | async def delete_hero( 245 | hero_id: int, 246 | service: HeroService = Depends(get_hero_service), 247 | ) -> None: 248 | """Delete hero.""" 249 | try: 250 | await service.delete_hero(hero_id=hero_id) 251 | logger.info(f"Deleted hero {hero_id}") 252 | except Exception as e: 253 | logger.error(f"Failed to delete hero {hero_id}: {str(e)}") 254 | raise 255 | 256 | 257 | @router.get("/{hero_id}/story", response_model=HeroStoryResponse) 258 | async def generate_hero_story( 259 | hero_id: int, 260 | service: HeroService = Depends(get_hero_service), 261 | ) -> HeroResponse: 262 | """Generate hero story.""" 263 | try: 264 | story = await service.get_hero_with_story(hero_id=hero_id) 265 | logger.info(f"Generated story for hero {hero_id}") 266 | return story 267 | except Exception as e: 268 | logger.error(f"Failed to generate hero's story. {hero_id}: {str(e)}") 269 | raise 270 | --------------------------------------------------------------------------------