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.
- fastapp_cli/__init__.py +3 -0
- fastapp_cli/create.py +157 -0
- fastapp_cli/main.py +33 -0
- fastapp_cli/naming.py +39 -0
- fastapp_cli/prompts.py +35 -0
- fastapp_cli/render.py +130 -0
- fastapp_cli/templates/__init__.py +1 -0
- fastapp_cli/templates/project/.env.development.example.j2 +21 -0
- fastapp_cli/templates/project/.env.example.j2 +29 -0
- fastapp_cli/templates/project/.env.j2 +27 -0
- fastapp_cli/templates/project/.gitignore +178 -0
- fastapp_cli/templates/project/.pre-commit-config.yaml.j2 +80 -0
- fastapp_cli/templates/project/.python-version.j2 +1 -0
- fastapp_cli/templates/project/Dockerfile.j2 +17 -0
- fastapp_cli/templates/project/Makefile.j2 +31 -0
- fastapp_cli/templates/project/README.md.j2 +68 -0
- fastapp_cli/templates/project/alembic/env.py.j2 +84 -0
- fastapp_cli/templates/project/alembic/script.py.mako +28 -0
- fastapp_cli/templates/project/alembic/versions/.gitkeep +0 -0
- fastapp_cli/templates/project/alembic.ini.j2 +50 -0
- fastapp_cli/templates/project/app/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/deps.py.j2 +33 -0
- fastapp_cli/templates/project/app/api/v1/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/health.py.j2 +33 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/items.py.j2 +90 -0
- fastapp_cli/templates/project/app/api/v1/router.py.j2 +9 -0
- fastapp_cli/templates/project/app/core/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/core/celery_app.py.j2 +62 -0
- fastapp_cli/templates/project/app/core/config.py.j2 +105 -0
- fastapp_cli/templates/project/app/core/context_var.py.j2 +13 -0
- fastapp_cli/templates/project/app/core/database.py.j2 +50 -0
- fastapp_cli/templates/project/app/core/exceptions.py.j2 +175 -0
- fastapp_cli/templates/project/app/core/logging.py.j2 +125 -0
- fastapp_cli/templates/project/app/core/middleware.py.j2 +39 -0
- fastapp_cli/templates/project/app/crud/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/crud/base.py.j2 +229 -0
- fastapp_cli/templates/project/app/crud/item.py.j2 +10 -0
- fastapp_cli/templates/project/app/main.py.j2 +118 -0
- fastapp_cli/templates/project/app/models/__init__.py.j2 +10 -0
- fastapp_cli/templates/project/app/models/base.py.j2 +59 -0
- fastapp_cli/templates/project/app/models/item.py.j2 +22 -0
- fastapp_cli/templates/project/app/schemas/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/schemas/common.py.j2 +81 -0
- fastapp_cli/templates/project/app/schemas/item.py.j2 +35 -0
- fastapp_cli/templates/project/app/services/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/services/base.py.j2 +79 -0
- fastapp_cli/templates/project/app/services/item_service.py.j2 +10 -0
- fastapp_cli/templates/project/app/tasks/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/tasks/sample_tasks.py.j2 +28 -0
- fastapp_cli/templates/project/app/utils/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/docs/SQLModel/345/256/232/344/271/211/347/244/272/344/276/213.md +400 -0
- fastapp_cli/templates/project/pm2.config.json.j2 +47 -0
- fastapp_cli/templates/project/pyproject.toml.j2 +195 -0
- fastapp_cli/templates/project/scripts/celery_beat.sh.j2 +9 -0
- fastapp_cli/templates/project/scripts/celery_flower.sh.j2 +22 -0
- fastapp_cli/templates/project/scripts/celery_worker.sh.j2 +15 -0
- fastapp_cli/templates/project/scripts/start.sh.j2 +17 -0
- fastapp_cli/templates/project/tests/api/test_health.py.j2 +15 -0
- fastapp_cli/templates/project/tests/api/test_items.py.j2 +61 -0
- fastapp_cli/templates/project/tests/conftest.py.j2 +61 -0
- fastapp_cli/templates/project/tests/services/test_item_service.py.j2 +44 -0
- fastapp_cli-0.1.0.dist-info/METADATA +102 -0
- fastapp_cli-0.1.0.dist-info/RECORD +67 -0
- fastapp_cli-0.1.0.dist-info/WHEEL +4 -0
- 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,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,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 @@
|
|
|
1
|
+
"""app · tasks"""
|