fastapp-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. fastapp_cli/__init__.py +3 -0
  2. fastapp_cli/create.py +157 -0
  3. fastapp_cli/main.py +33 -0
  4. fastapp_cli/naming.py +39 -0
  5. fastapp_cli/prompts.py +35 -0
  6. fastapp_cli/render.py +130 -0
  7. fastapp_cli/templates/__init__.py +1 -0
  8. fastapp_cli/templates/project/.env.development.example.j2 +21 -0
  9. fastapp_cli/templates/project/.env.example.j2 +29 -0
  10. fastapp_cli/templates/project/.env.j2 +27 -0
  11. fastapp_cli/templates/project/.gitignore +178 -0
  12. fastapp_cli/templates/project/.pre-commit-config.yaml.j2 +80 -0
  13. fastapp_cli/templates/project/.python-version.j2 +1 -0
  14. fastapp_cli/templates/project/Dockerfile.j2 +17 -0
  15. fastapp_cli/templates/project/Makefile.j2 +31 -0
  16. fastapp_cli/templates/project/README.md.j2 +68 -0
  17. fastapp_cli/templates/project/alembic/env.py.j2 +84 -0
  18. fastapp_cli/templates/project/alembic/script.py.mako +28 -0
  19. fastapp_cli/templates/project/alembic/versions/.gitkeep +0 -0
  20. fastapp_cli/templates/project/alembic.ini.j2 +50 -0
  21. fastapp_cli/templates/project/app/__init__.py.j2 +1 -0
  22. fastapp_cli/templates/project/app/api/__init__.py.j2 +1 -0
  23. fastapp_cli/templates/project/app/api/deps.py.j2 +33 -0
  24. fastapp_cli/templates/project/app/api/v1/__init__.py.j2 +1 -0
  25. fastapp_cli/templates/project/app/api/v1/endpoints/__init__.py.j2 +1 -0
  26. fastapp_cli/templates/project/app/api/v1/endpoints/health.py.j2 +33 -0
  27. fastapp_cli/templates/project/app/api/v1/endpoints/items.py.j2 +90 -0
  28. fastapp_cli/templates/project/app/api/v1/router.py.j2 +9 -0
  29. fastapp_cli/templates/project/app/core/__init__.py.j2 +1 -0
  30. fastapp_cli/templates/project/app/core/celery_app.py.j2 +62 -0
  31. fastapp_cli/templates/project/app/core/config.py.j2 +105 -0
  32. fastapp_cli/templates/project/app/core/context_var.py.j2 +13 -0
  33. fastapp_cli/templates/project/app/core/database.py.j2 +50 -0
  34. fastapp_cli/templates/project/app/core/exceptions.py.j2 +175 -0
  35. fastapp_cli/templates/project/app/core/logging.py.j2 +125 -0
  36. fastapp_cli/templates/project/app/core/middleware.py.j2 +39 -0
  37. fastapp_cli/templates/project/app/crud/__init__.py.j2 +1 -0
  38. fastapp_cli/templates/project/app/crud/base.py.j2 +229 -0
  39. fastapp_cli/templates/project/app/crud/item.py.j2 +10 -0
  40. fastapp_cli/templates/project/app/main.py.j2 +118 -0
  41. fastapp_cli/templates/project/app/models/__init__.py.j2 +10 -0
  42. fastapp_cli/templates/project/app/models/base.py.j2 +59 -0
  43. fastapp_cli/templates/project/app/models/item.py.j2 +22 -0
  44. fastapp_cli/templates/project/app/schemas/__init__.py.j2 +1 -0
  45. fastapp_cli/templates/project/app/schemas/common.py.j2 +81 -0
  46. fastapp_cli/templates/project/app/schemas/item.py.j2 +35 -0
  47. fastapp_cli/templates/project/app/services/__init__.py.j2 +1 -0
  48. fastapp_cli/templates/project/app/services/base.py.j2 +79 -0
  49. fastapp_cli/templates/project/app/services/item_service.py.j2 +10 -0
  50. fastapp_cli/templates/project/app/tasks/__init__.py.j2 +1 -0
  51. fastapp_cli/templates/project/app/tasks/sample_tasks.py.j2 +28 -0
  52. fastapp_cli/templates/project/app/utils/__init__.py.j2 +1 -0
  53. fastapp_cli/templates/project/docs/SQLModel/345/256/232/344/271/211/347/244/272/344/276/213.md +400 -0
  54. fastapp_cli/templates/project/pm2.config.json.j2 +47 -0
  55. fastapp_cli/templates/project/pyproject.toml.j2 +195 -0
  56. fastapp_cli/templates/project/scripts/celery_beat.sh.j2 +9 -0
  57. fastapp_cli/templates/project/scripts/celery_flower.sh.j2 +22 -0
  58. fastapp_cli/templates/project/scripts/celery_worker.sh.j2 +15 -0
  59. fastapp_cli/templates/project/scripts/start.sh.j2 +17 -0
  60. fastapp_cli/templates/project/tests/api/test_health.py.j2 +15 -0
  61. fastapp_cli/templates/project/tests/api/test_items.py.j2 +61 -0
  62. fastapp_cli/templates/project/tests/conftest.py.j2 +61 -0
  63. fastapp_cli/templates/project/tests/services/test_item_service.py.j2 +44 -0
  64. fastapp_cli-0.1.0.dist-info/METADATA +102 -0
  65. fastapp_cli-0.1.0.dist-info/RECORD +67 -0
  66. fastapp_cli-0.1.0.dist-info/WHEEL +4 -0
  67. fastapp_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,229 @@
1
+ """CRUD 泛型基类."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any, cast
7
+
8
+ from fastapi_filter.contrib.sqlalchemy import Filter
9
+ from fastapi_pagination.ext.sqlmodel import paginate
10
+ from pydantic import BaseModel
11
+ from sqlmodel import Session, SQLModel, func, inspect, select
12
+ from sqlmodel.sql.expression import Select, SelectOfScalar
13
+
14
+ from app.core.exceptions import NotFoundError
15
+ from app.schemas.common import PageResponse
16
+
17
+
18
+ class CRUDBase[ModelType: SQLModel]:
19
+ """CRUD 基类,提供通用的增删改查方法.
20
+
21
+ 所有子类只需传入对应的 SQLModel 类,即可获得基础能力.
22
+ 复杂的查询逻辑请在子类中扩展.
23
+ """
24
+
25
+ def __init__(self, model: type[ModelType]) -> None:
26
+ """初始化.
27
+
28
+ :param model: SQLModel 实体类
29
+ """
30
+ self.model = model
31
+
32
+ def get(self, db: Session, obj_id: Any) -> ModelType | None:
33
+ """按主键查询单个对象."""
34
+ return db.get(self.model, obj_id)
35
+
36
+ def get_or_404(self, db: Session, obj_id: Any) -> ModelType:
37
+ """按主键查询单个对象,若不存在则抛出 404 异常."""
38
+ obj = self.get(db, obj_id)
39
+ if not obj:
40
+ raise NotFoundError()
41
+ return obj
42
+
43
+ def get_by_field(self, db: Session, field: str, value: Any) -> ModelType | None:
44
+ """按字段查询单个对象."""
45
+ if not hasattr(self.model, field):
46
+ raise ValueError(f"{self.model.__name__} 不存在字段 {field}")
47
+ statement = select(self.model).where(getattr(self.model, field) == value)
48
+ return db.exec(statement).first()
49
+
50
+ def get_page_multi(
51
+ self,
52
+ db: Session,
53
+ *,
54
+ filter_params: Filter | None = None,
55
+ ) -> PageResponse[Any]:
56
+ """分页查询列表."""
57
+ statement = select(self.model)
58
+ if filter_params is not None:
59
+ statement = filter_params.sort(filter_params.filter(statement))
60
+ return paginate(db, statement) # type: ignore[arg-type]
61
+
62
+ def get_multi(
63
+ self,
64
+ db: Session,
65
+ *,
66
+ filter_params: Filter | None = None,
67
+ ) -> Sequence[ModelType]:
68
+ """查询列表."""
69
+ statement = select(self.model)
70
+ if filter_params is not None:
71
+ statement = filter_params.sort(filter_params.filter(statement))
72
+ return db.exec(statement).all()
73
+
74
+ def count(self, db: Session, query: Select | SelectOfScalar | None = None) -> int:
75
+ """返回记录总数."""
76
+ if query is not None:
77
+ statement = query.with_only_columns(func.count()).select_from(self.model).order_by(None).offset(None).limit(None)
78
+ else:
79
+ statement = select(func.count()).select_from(self.model)
80
+ return db.exec(cast(SelectOfScalar, statement)).one()
81
+
82
+ def create(self, db: Session, *, obj_in: BaseModel | dict[str, Any]) -> ModelType:
83
+ """创建记录."""
84
+ data = obj_in if isinstance(obj_in, dict) else obj_in.model_dump()
85
+ db_obj = self.model(**data)
86
+ db.add(db_obj)
87
+ db.commit()
88
+ db.refresh(db_obj)
89
+ return db_obj
90
+
91
+ def create_multi(self, db: Session, *, objs_in: list[BaseModel | dict[str, Any]]) -> list[ModelType]:
92
+ """批量创建记录."""
93
+ items = [obj_in if isinstance(obj_in, dict) else obj_in.model_dump() for obj_in in objs_in]
94
+ db_objs = [self.model(**item) for item in items]
95
+ db.add_all(db_objs)
96
+ db.commit()
97
+ for db_obj in db_objs:
98
+ db.refresh(db_obj)
99
+ return db_objs
100
+
101
+ def update(
102
+ self,
103
+ db: Session,
104
+ *,
105
+ db_obj: ModelType,
106
+ obj_in: BaseModel | dict[str, Any],
107
+ ) -> ModelType:
108
+ """更新记录."""
109
+ update_data = obj_in if isinstance(obj_in, dict) else obj_in.model_dump(exclude_unset=True)
110
+ for field, value in update_data.items():
111
+ setattr(db_obj, field, value)
112
+ db.add(db_obj)
113
+ db.commit()
114
+ db.refresh(db_obj)
115
+ return db_obj
116
+
117
+ def _get_by_unique_fields(
118
+ self,
119
+ db: Session,
120
+ data: dict[str, Any],
121
+ unique_fields: Sequence[str],
122
+ ) -> ModelType | None:
123
+ """按指定的唯一字段组合查询单个对象."""
124
+ if not unique_fields:
125
+ raise ValueError("unique_fields 不能为空")
126
+ statement = select(self.model)
127
+ for field in unique_fields:
128
+ if not hasattr(self.model, field):
129
+ raise ValueError(f"{self.model.__name__} 不存在字段 {field}")
130
+ if field not in data:
131
+ raise ValueError(f"obj_in 中缺少唯一字段 {field}")
132
+ statement = statement.where(getattr(self.model, field) == data[field])
133
+ return db.exec(statement).first()
134
+
135
+ def _resolve_unique_fields(
136
+ self,
137
+ unique_fields: str | Sequence[str] | None,
138
+ ) -> list[str]:
139
+ """规范化 unique_fields 参数,未指定时使用模型主键."""
140
+ if unique_fields is None:
141
+ primary_keys = cast(Sequence[Any], inspect(self.model).primary_key) # type: ignore[union-attr]
142
+ return [pk.name for pk in primary_keys]
143
+ if isinstance(unique_fields, str):
144
+ return [unique_fields]
145
+ return list(unique_fields)
146
+
147
+ def create_or_update(
148
+ self,
149
+ db: Session,
150
+ *,
151
+ obj_in: BaseModel | dict[str, Any],
152
+ unique_fields: str | Sequence[str] | None = None,
153
+ ) -> ModelType:
154
+ """根据唯一字段创建或更新记录.
155
+
156
+ :param db: 数据库会话
157
+ :param obj_in: 入参,BaseModel 或 dict
158
+ :param unique_fields: 唯一字段名或字段名列表,未指定时使用模型主键
159
+ :return: 创建或更新后的模型实例
160
+ """
161
+ fields = self._resolve_unique_fields(unique_fields)
162
+ data = obj_in if isinstance(obj_in, dict) else obj_in.model_dump()
163
+ db_obj = self._get_by_unique_fields(db, data, fields)
164
+ if db_obj is None:
165
+ db_obj = self.model(**data)
166
+ db.add(db_obj)
167
+ else:
168
+ for field, value in data.items():
169
+ setattr(db_obj, field, value)
170
+ db.add(db_obj)
171
+ db.commit()
172
+ db.refresh(db_obj)
173
+ return db_obj
174
+
175
+ def create_or_update_multi(
176
+ self,
177
+ db: Session,
178
+ *,
179
+ objs_in: list[BaseModel | dict[str, Any]],
180
+ unique_fields: str | Sequence[str] | None = None,
181
+ ) -> list[ModelType]:
182
+ """批量根据唯一字段创建或更新记录.
183
+
184
+ 在同一事务中提交,减少数据库往返.
185
+
186
+ :param db: 数据库会话
187
+ :param objs_in: 入参列表,BaseModel 或 dict
188
+ :param unique_fields: 唯一字段名或字段名列表,未指定时使用模型主键
189
+ :return: 创建或更新后的模型实例列表
190
+ """
191
+ fields = self._resolve_unique_fields(unique_fields)
192
+ db_objs: list[ModelType] = []
193
+ for obj_in in objs_in:
194
+ data = obj_in if isinstance(obj_in, dict) else obj_in.model_dump()
195
+ db_obj = self._get_by_unique_fields(db, data, fields)
196
+ if db_obj is None:
197
+ db_obj = self.model(**data)
198
+ else:
199
+ for field, value in data.items():
200
+ setattr(db_obj, field, value)
201
+ db.add(db_obj)
202
+ db_objs.append(db_obj)
203
+ db.commit()
204
+ for db_obj in db_objs:
205
+ db.refresh(db_obj)
206
+ return db_objs
207
+
208
+ def remove(self, db: Session, *, obj_id: int) -> ModelType:
209
+ """按主键删除记录."""
210
+ obj = self.get_or_404(db, obj_id)
211
+ db.delete(obj)
212
+ db.commit()
213
+ return obj
214
+
215
+ def remove_multi(self, db: Session, *, obj_ids: list[Any]) -> list[ModelType]:
216
+ """批量删除记录."""
217
+ primary_key = cast(Any, list(inspect(self.model).primary_key)[0]) # type: ignore[union-attr]
218
+ statement = select(self.model).where(
219
+ primary_key.in_(obj_ids)
220
+ )
221
+ objs = db.exec(statement).all()
222
+ for obj in objs:
223
+ db.delete(obj)
224
+ db.commit()
225
+ return list(objs)
226
+
227
+ def exists(self, db: Session, *, obj_id: Any) -> bool:
228
+ """判断记录是否存在."""
229
+ return self.get(db, obj_id) is not None
@@ -0,0 +1,10 @@
1
+ """Item CRUD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.crud.base import CRUDBase
6
+ from app.models.item import Item
7
+
8
+
9
+ class CRUDItem(CRUDBase[Item]):
10
+ pass
@@ -0,0 +1,118 @@
1
+ """FastAPI 应用入口."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi_pagination import add_pagination
11
+ from loguru import logger
12
+ from sqlalchemy import text
13
+
14
+ from app.api.v1.router import api_router
15
+ from app.core.celery_app import celery_app, degrade_to_memory
16
+ from app.core.config import settings
17
+ from app.core.database import engine
18
+ from app.core.exceptions import register_exception_handlers
19
+ from app.core.logging import setup_logging
20
+ from app.core.middleware import TraceIDMiddleware
21
+
22
+
23
+ def _probe_database() -> None:
24
+ """启动时探测数据库连通性(带超时),缺席降级不阻塞启动."""
25
+ try:
26
+ with engine.connect() as conn:
27
+ conn.execute(text("SELECT 1"))
28
+ logger.info("数据库连接正常: {}", settings.DATABASE_URI)
29
+ except Exception as exc: # pylint: disable=broad-except
30
+ logger.warning("数据库未就绪,数据接口将返回 500,请配置 .env: {}", exc)
31
+
32
+
33
+ def _probe_redis() -> None:
34
+ """启动时探测 redis(带超时),不可达时 Celery 自动降级为 memory broker."""
35
+ broker_url = settings.CELERY_BROKER_URL
36
+ if broker_url.startswith("memory"):
37
+ logger.info("Celery broker 已配置为 memory://,跳过 redis 探测")
38
+ return
39
+ try:
40
+ import redis
41
+
42
+ client = redis.Redis.from_url(
43
+ broker_url,
44
+ socket_connect_timeout=1,
45
+ socket_timeout=1,
46
+ )
47
+ client.ping()
48
+ client.close()
49
+ logger.info("redis 连接正常: {}", broker_url)
50
+ except Exception as exc: # pylint: disable=broad-except
51
+ degrade_to_memory(celery_app)
52
+ logger.warning("redis 未就绪,Celery 已降级为 memory broker: {}", exc)
53
+
54
+
55
+ @asynccontextmanager
56
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
57
+ """应用生命周期.
58
+
59
+ - 启动:初始化日志、探测数据库 / redis(缺席降级,不阻塞启动)
60
+ - 关闭:优雅释放资源
61
+ """
62
+ setup_logging()
63
+ logger.info("{} starting | env={} | debug={}", settings.APP_NAME, settings.APP_ENV, settings.DEBUG)
64
+ _probe_database()
65
+ _probe_redis()
66
+
67
+ yield
68
+
69
+ logger.info("{} stopped", settings.APP_NAME)
70
+
71
+
72
+ def create_app() -> FastAPI:
73
+ """工厂函数:创建 FastAPI 应用实例."""
74
+ app_ = FastAPI(
75
+ title=settings.APP_NAME,
76
+ # 注意:固定 False。DEBUG 环境变量仅控制日志级别与 SQL echo;
77
+ # 若传 True,Starlette 会绕过统一异常处理器返回原始堆栈,违反统一响应规范
78
+ debug=False,
79
+ version="0.1.0",
80
+ openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
81
+ docs_url=f"{settings.API_V1_PREFIX}/docs",
82
+ redoc_url=f"{settings.API_V1_PREFIX}/redoc",
83
+ lifespan=lifespan,
84
+ )
85
+
86
+ app_.add_middleware(
87
+ CORSMiddleware,
88
+ allow_origins=settings.ALLOWED_ORIGINS,
89
+ allow_credentials=True,
90
+ allow_methods=["*"],
91
+ allow_headers=["*"],
92
+ )
93
+
94
+ # 注意:TraceID 中间件需在 CORS 之后添加,以保证其处于最外层(Starlette 中间件后注册先执行),
95
+ # 从而为所有后续逻辑提供 trace_id 上下文
96
+ app_.add_middleware(TraceIDMiddleware)
97
+
98
+ register_exception_handlers(app_)
99
+ app_.include_router(api_router, prefix=settings.API_V1_PREFIX)
100
+
101
+ # 添加分页插件
102
+ add_pagination(app_)
103
+
104
+ return app_
105
+
106
+
107
+ app = create_app()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ import uvicorn
112
+
113
+ uvicorn.run(
114
+ "app.main:app",
115
+ host=settings.HOST,
116
+ port=settings.PORT,
117
+ reload=settings.DEBUG,
118
+ )
@@ -0,0 +1,10 @@
1
+ """SQLModel 数据库模型."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.models.item import Item
6
+
7
+ # 新增表一定要在这里添加
8
+ __all__ = [
9
+ "Item",
10
+ ]
@@ -0,0 +1,59 @@
1
+ """SQLModel 基类,定义公共字段."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated
7
+
8
+ from pydantic import PlainSerializer
9
+ from sqlalchemy import DateTime, func
10
+ from sqlmodel import Field, SQLModel
11
+
12
+ # 定义一个可复用的序列化类型
13
+ FormattedDatetime = Annotated[
14
+ datetime | None,
15
+ PlainSerializer(
16
+ lambda v: v.strftime("%Y-%m-%d %H:%M:%S") if v else None,
17
+ return_type=str,
18
+ when_used="json", # 仅 JSON 序列化时生效;数据库仍存 datetime
19
+ ),
20
+ ]
21
+
22
+
23
+ def utcnow() -> datetime:
24
+ """获取当前本地时间."""
25
+ return datetime.now()
26
+
27
+
28
+ class TimestampMixin(SQLModel):
29
+ """公共时间戳字段.
30
+
31
+ - create_time: 创建时间,数据库自动生成
32
+ - update_time: 更新时间,每次更新自动刷新
33
+
34
+ 这里使用 ``sa_type`` + ``sa_column_kwargs`` 的方式声明列参数,
35
+ 每个继承此 Mixin 的表都会生成独立的 Column 实例,避免 SQLAlchemy
36
+ `Column already assigned to Table` 报错. PyCharm 的误报用 # type: ignore
37
+ """
38
+
39
+ create_time: FormattedDatetime = Field(
40
+ default_factory=utcnow,
41
+ sa_type=DateTime(timezone=True), # type: ignore
42
+ sa_column_kwargs={
43
+ "server_default": func.now(),
44
+ },
45
+ )
46
+ update_time: FormattedDatetime = Field(
47
+ default_factory=utcnow,
48
+ sa_type=DateTime(timezone=True), # type: ignore
49
+ sa_column_kwargs={
50
+ "server_default": func.now(),
51
+ "onupdate": func.now(),
52
+ },
53
+ )
54
+
55
+
56
+ class IDMixin(SQLModel):
57
+ """主键字段."""
58
+
59
+ id: int = Field(..., primary_key=True, index=True)
@@ -0,0 +1,22 @@
1
+ """Item 模型(demo 业务域,可替换为实际业务模型)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlmodel import Field
6
+
7
+ from app.models.base import IDMixin, TimestampMixin
8
+
9
+
10
+ class ItemBase(TimestampMixin):
11
+ """Item 公共字段."""
12
+
13
+ title: str = Field(max_length=128, description="标题")
14
+ description: str = Field(default="", max_length=2000, description="描述")
15
+ price: float = Field(default=0, description="价格")
16
+ is_active: bool = Field(default=True, description="是否上架")
17
+
18
+
19
+ class Item(IDMixin, ItemBase, table=True):
20
+ """Item 表 ORM 模型."""
21
+
22
+ __tablename__ = "t_item"
@@ -0,0 +1 @@
1
+ """app · schemas"""
@@ -0,0 +1,81 @@
1
+ """通用 Schema - 分页、统一响应."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any, Self
7
+
8
+ from fastapi import Query
9
+ from fastapi_pagination import Params
10
+ from fastapi_pagination.bases import AbstractPage, AbstractParams
11
+ from pydantic import BaseModel, model_validator
12
+
13
+ from app.core.context_var import get_trace_id
14
+
15
+
16
+ class Response[T](BaseModel):
17
+ """统一响应结构.
18
+
19
+ 所有接口统一返回该结构,包含业务状态码、消息、数据三个字段:
20
+
21
+ :param code: 业务状态码,0 表示成功,其他值表示业务异常
22
+ :param message: 描述信息
23
+ :param data: 业务数据载荷
24
+ :param trace_id: 调用链 ID,未显式指定时会自动从请求上下文注入
25
+ """
26
+
27
+ code: int = 0
28
+ message: str | None = "ok"
29
+ data: T | None = None
30
+ trace_id: str | None = None
31
+
32
+ @model_validator(mode="after")
33
+ def _inject_trace_id(self) -> Self:
34
+ """构造完成后自动注入当前请求上下文中的 trace_id(若未显式提供)."""
35
+ if self.trace_id is None:
36
+ self.trace_id = get_trace_id()
37
+ return self
38
+
39
+
40
+ class PageParams(Params):
41
+ """分页查询参数."""
42
+
43
+ page: int = Query(1, ge=1, description="页码")
44
+ size: int = Query(10, ge=1, le=1000, alias="page_size", description="每页条数")
45
+
46
+
47
+ class PageResult[T](BaseModel):
48
+ """分页返回结构."""
49
+
50
+ total: int
51
+ page: int
52
+ page_size: int
53
+ content: Sequence[T]
54
+
55
+
56
+ class PageResponse[T](Response[PageResult[T]], AbstractPage[T]):
57
+ """统一分页响应结构."""
58
+
59
+ __params_type__ = PageParams
60
+
61
+ @classmethod
62
+ def create(
63
+ cls,
64
+ items: Sequence[T],
65
+ params: AbstractParams,
66
+ *,
67
+ total: int | None = None,
68
+ **kwargs: Any,
69
+ ) -> Self:
70
+ if not isinstance(params, PageParams):
71
+ raise TypeError(f"params must be an instance of PageParams, got {type(params).__name__}")
72
+ if total is None:
73
+ raise ValueError("total is required for PageResponse.create")
74
+ return cls(
75
+ data=PageResult(
76
+ total=total,
77
+ page=params.page,
78
+ page_size=params.size,
79
+ content=items,
80
+ )
81
+ )
@@ -0,0 +1,35 @@
1
+ """Item Schema:入参 / 出参 / 过滤器."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi_filter.contrib.sqlalchemy import Filter
6
+ from pydantic import Field
7
+
8
+ from app.models.item import Item, ItemBase
9
+
10
+
11
+ class ItemCreate(ItemBase):
12
+ """创建 Item 入参."""
13
+
14
+
15
+ class ItemUpdate(ItemBase):
16
+ """更新 Item 入参(exclude_unset 语义下仅更新显式传入的字段)."""
17
+
18
+
19
+ class ItemRead(ItemBase):
20
+ """Item 返回结构."""
21
+
22
+ id: int
23
+
24
+
25
+ class ItemFilter(Filter):
26
+ """Item 搜索查询."""
27
+
28
+ title__ilike: str | None = Field(default=None, alias="title", description="标题模糊查询")
29
+ is_active: bool | None = Field(default=None, description="按上架状态过滤")
30
+ price__gte: float | None = Field(default=None, alias="price_min", description="最低价格")
31
+ price__lte: float | None = Field(default=None, alias="price_max", description="最高价格")
32
+ order_by: list[str] = Field(default=["-id"], description="排序字段")
33
+
34
+ class Constants(Filter.Constants):
35
+ model = Item
@@ -0,0 +1 @@
1
+ """app · services"""
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from fastapi_filter.contrib.sqlalchemy import Filter
7
+ from pydantic import BaseModel
8
+ from sqlmodel import Session
9
+
10
+ from app.core.exceptions import NotFoundError
11
+ from app.crud.base import CRUDBase
12
+ from app.schemas.common import PageResponse
13
+
14
+
15
+ class ServiceBase[CRUDType: CRUDBase]:
16
+ """Service 基类:业务逻辑层,封装对 CRUD 的调用.
17
+
18
+ 方法返回值使用 Any 标注:具体模型类型由子类按业务模型收窄,
19
+ 避免方法级泛型 TypeVar 无法从入参推断导致的静态检查噪音。
20
+ """
21
+
22
+ def __init__(self, db: Session, crud: CRUDType):
23
+ self.db = db
24
+ self.crud = crud
25
+
26
+ def create(self, obj_in: BaseModel | dict[str, Any]) -> Any:
27
+ """创建."""
28
+ return self.crud.create(self.db, obj_in=obj_in)
29
+
30
+ def create_multi(self, objs_in: list[BaseModel | dict[str, Any]]) -> list[Any]:
31
+ """批量创建."""
32
+ return self.crud.create_multi(self.db, objs_in=objs_in)
33
+
34
+ def create_or_update(
35
+ self,
36
+ obj_in: BaseModel | dict[str, Any],
37
+ unique_fields: str | Sequence[str] | None = None,
38
+ ) -> Any:
39
+ """根据唯一字段创建或更新."""
40
+ return self.crud.create_or_update(
41
+ self.db,
42
+ obj_in=obj_in,
43
+ unique_fields=unique_fields,
44
+ )
45
+
46
+ def create_or_update_multi(
47
+ self,
48
+ objs_in: list[BaseModel | dict[str, Any]],
49
+ unique_fields: str | Sequence[str] | None = None,
50
+ ) -> list[Any]:
51
+ """批量根据唯一字段创建或更新."""
52
+ return self.crud.create_or_update_multi(
53
+ self.db,
54
+ objs_in=objs_in,
55
+ unique_fields=unique_fields,
56
+ )
57
+
58
+ def update(self, obj_id: int, obj_in: BaseModel | dict[str, Any]) -> Any:
59
+ """更新."""
60
+ db_obj = self.crud.get(self.db, obj_id)
61
+ if db_obj is None:
62
+ raise NotFoundError(message="待更新的数据不存在")
63
+ return self.crud.update(self.db, db_obj=db_obj, obj_in=obj_in)
64
+
65
+ def get(self, obj_id: int) -> Any:
66
+ """获取单个."""
67
+ return self.crud.get_or_404(self.db, obj_id=obj_id)
68
+
69
+ def delete(self, obj_id: int) -> Any:
70
+ """删除."""
71
+ return self.crud.remove(self.db, obj_id=obj_id)
72
+
73
+ def list_objs(self, filter_params: Filter | None = None) -> Sequence[Any]:
74
+ """获取过滤列表."""
75
+ return self.crud.get_multi(self.db, filter_params=filter_params)
76
+
77
+ def list_page_objs(self, filter_params: Filter | None = None) -> PageResponse[Any]:
78
+ """分页获取过滤列表."""
79
+ return self.crud.get_page_multi(self.db, filter_params=filter_params)
@@ -0,0 +1,10 @@
1
+ """Item 业务逻辑."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.crud.item import CRUDItem
6
+ from app.services.base import ServiceBase
7
+
8
+
9
+ class ItemService(ServiceBase[CRUDItem]):
10
+ pass
@@ -0,0 +1 @@
1
+ """app · tasks"""