fastapi-augment 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.
- fastapi_augment/__init__.py +24 -0
- fastapi_augment/common/__init__.py +61 -0
- fastapi_augment/common/constants.py +26 -0
- fastapi_augment/common/exception_handlers.py +178 -0
- fastapi_augment/common/exceptions.py +162 -0
- fastapi_augment/common/utils/__init__.py +5 -0
- fastapi_augment/common/utils/strings.py +175 -0
- fastapi_augment/config/__init__.py +8 -0
- fastapi_augment/config/settings.py +104 -0
- fastapi_augment/db/__init__.py +5 -0
- fastapi_augment/db/sqlalchemy/__init__.py +20 -0
- fastapi_augment/db/sqlalchemy/alembic/__init__.py +5 -0
- fastapi_augment/db/sqlalchemy/alembic/env.py +141 -0
- fastapi_augment/db/sqlalchemy/base.py +9 -0
- fastapi_augment/db/sqlalchemy/crud_base.py +426 -0
- fastapi_augment/db/sqlalchemy/engine.py +238 -0
- fastapi_augment/db/sqlalchemy/migrate.py +356 -0
- fastapi_augment/db/sqlalchemy/mixins/__init__.py +18 -0
- fastapi_augment/db/sqlalchemy/mixins/audit.py +61 -0
- fastapi_augment/db/sqlalchemy/mixins/soft_delete.py +80 -0
- fastapi_augment/db/sqlalchemy/mixins/timestamp.py +48 -0
- fastapi_augment/db/sqlalchemy/model_base.py +47 -0
- fastapi_augment/db/sqlalchemy/session.py +160 -0
- fastapi_augment/factory.py +238 -0
- fastapi_augment/health/__init__.py +34 -0
- fastapi_augment/health/checker.py +101 -0
- fastapi_augment/health/checkers.py +109 -0
- fastapi_augment/health/router.py +87 -0
- fastapi_augment/lifespan.py +450 -0
- fastapi_augment/log/__init__.py +26 -0
- fastapi_augment/log/config.py +201 -0
- fastapi_augment/log/factory.py +32 -0
- fastapi_augment/log/filters.py +23 -0
- fastapi_augment/log/handlers.py +81 -0
- fastapi_augment/middlewares/__init__.py +20 -0
- fastapi_augment/middlewares/base.py +79 -0
- fastapi_augment/middlewares/request_id.py +82 -0
- fastapi_augment/openapi.py +110 -0
- fastapi_augment/py.typed +0 -0
- fastapi_augment/schemas/__init__.py +29 -0
- fastapi_augment/schemas/base.py +32 -0
- fastapi_augment/schemas/pagination.py +46 -0
- fastapi_augment/schemas/request.py +28 -0
- fastapi_augment/schemas/response.py +139 -0
- fastapi_augment/schemas/types.py +11 -0
- fastapi_augment-0.1.0.dist-info/METADATA +654 -0
- fastapi_augment-0.1.0.dist-info/RECORD +50 -0
- fastapi_augment-0.1.0.dist-info/WHEEL +5 -0
- fastapi_augment-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_augment-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : zarkhan
|
|
3
|
+
@CreateDate : 2026/9/5
|
|
4
|
+
@Description: 数据库迁移脚本
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
from os import environ
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from alembic import command
|
|
14
|
+
from alembic.config import Config
|
|
15
|
+
|
|
16
|
+
_logger = getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# ===================== alembic.ini 模板 =====================
|
|
19
|
+
|
|
20
|
+
_ALEMBIC_INI_TEMPLATE = """\
|
|
21
|
+
# A generic, single database configuration.
|
|
22
|
+
|
|
23
|
+
[alembic]
|
|
24
|
+
# path to migration scripts.
|
|
25
|
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
|
26
|
+
# format, relative to the token %(here)s which refers to the location of this
|
|
27
|
+
# ini file
|
|
28
|
+
script_location = fastapi_augment.db.sqlalchemy:alembic
|
|
29
|
+
version_locations = %(here)s/migrations/versions
|
|
30
|
+
|
|
31
|
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
32
|
+
# Uncomment the line below if you want the files to be prepended with date and time
|
|
33
|
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
34
|
+
# for all available tokens
|
|
35
|
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
36
|
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
|
37
|
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
|
38
|
+
|
|
39
|
+
# sys.path path, will be prepended to sys.path if present.
|
|
40
|
+
# defaults to the current working directory. for multiple paths, the path separator
|
|
41
|
+
# is defined by "path_separator" below.
|
|
42
|
+
prepend_sys_path = .
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# timezone to use when rendering the date within the migration file
|
|
46
|
+
# as well as the filename.
|
|
47
|
+
# If specified, requires the tzdata library which can be installed by adding
|
|
48
|
+
# `alembic[tz]` to the pip requirements.
|
|
49
|
+
# string value is passed to ZoneInfo()
|
|
50
|
+
# leave blank for localtime
|
|
51
|
+
# timezone =
|
|
52
|
+
|
|
53
|
+
# max length of characters to apply to the "slug" field
|
|
54
|
+
# truncate_slug_length = 40
|
|
55
|
+
|
|
56
|
+
# set to 'true' to run the environment during
|
|
57
|
+
# the 'revision' command, regardless of autogenerate
|
|
58
|
+
# revision_environment = false
|
|
59
|
+
|
|
60
|
+
# set to 'true' to allow .pyc and .pyo files without
|
|
61
|
+
# a source .py file to be detected as revisions in the
|
|
62
|
+
# versions/ directory
|
|
63
|
+
# sourceless = false
|
|
64
|
+
|
|
65
|
+
# version location specification; This defaults
|
|
66
|
+
# to <script_location>/versions. When using multiple version
|
|
67
|
+
# directories, initial revisions must be specified with --version-path.
|
|
68
|
+
# The path separator used here should be the separator specified by "path_separator"
|
|
69
|
+
# below.
|
|
70
|
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
|
71
|
+
|
|
72
|
+
# path_separator; This indicates what character is used to split lists of file
|
|
73
|
+
# paths, including version_locations and prepend_sys_path within configparser
|
|
74
|
+
# files such as alembic.ini.
|
|
75
|
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
|
76
|
+
# to provide os-dependent path splitting.
|
|
77
|
+
#
|
|
78
|
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
|
79
|
+
# take place if path_separator is not present in alembic.ini. If this
|
|
80
|
+
# option is omitted entirely, fallback logic is as follows:
|
|
81
|
+
#
|
|
82
|
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
|
83
|
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
|
84
|
+
# behavior of splitting on spaces and/or commas.
|
|
85
|
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
|
86
|
+
# behavior of splitting on spaces, commas, or colons.
|
|
87
|
+
#
|
|
88
|
+
# Valid values for path_separator are:
|
|
89
|
+
#
|
|
90
|
+
# path_separator = :
|
|
91
|
+
# path_separator = ;
|
|
92
|
+
# path_separator = space
|
|
93
|
+
# path_separator = newline
|
|
94
|
+
#
|
|
95
|
+
# Use os.pathsep. Default configuration used for new projects.
|
|
96
|
+
path_separator = os
|
|
97
|
+
|
|
98
|
+
# set to 'true' to search source files recursively
|
|
99
|
+
# in each "version_locations" directory
|
|
100
|
+
# new in Alembic version 1.10
|
|
101
|
+
# recursive_version_locations = false
|
|
102
|
+
|
|
103
|
+
# the output encoding used when revision files
|
|
104
|
+
# are written from script.py.mako
|
|
105
|
+
# output_encoding = utf-8
|
|
106
|
+
|
|
107
|
+
# database URL. This is consumed by the user-maintained env.py script only.
|
|
108
|
+
# other means of configuring database URLs may be customized within the env.py
|
|
109
|
+
# file.
|
|
110
|
+
sqlalchemy.url = {db_url}
|
|
111
|
+
|
|
112
|
+
# set the version table name
|
|
113
|
+
version_table = migration_version
|
|
114
|
+
|
|
115
|
+
[post_write_hooks]
|
|
116
|
+
# post_write_hooks defines scripts or Python functions that are run
|
|
117
|
+
# on newly generated revision scripts. See the documentation for further
|
|
118
|
+
# detail and examples
|
|
119
|
+
|
|
120
|
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
121
|
+
# hooks = black
|
|
122
|
+
# black.type = console_scripts
|
|
123
|
+
# black.entrypoint = black
|
|
124
|
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
125
|
+
|
|
126
|
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
|
127
|
+
# hooks = ruff
|
|
128
|
+
# ruff.type = module
|
|
129
|
+
# ruff.module = ruff
|
|
130
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
131
|
+
|
|
132
|
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
|
133
|
+
# hooks = ruff
|
|
134
|
+
# ruff.type = exec
|
|
135
|
+
# ruff.executable = ruff
|
|
136
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
137
|
+
|
|
138
|
+
# Logging configuration. This is also consumed by the user-maintained
|
|
139
|
+
# env.py script only.
|
|
140
|
+
[loggers]
|
|
141
|
+
keys = root,sqlalchemy,alembic
|
|
142
|
+
|
|
143
|
+
[handlers]
|
|
144
|
+
keys = console
|
|
145
|
+
|
|
146
|
+
[formatters]
|
|
147
|
+
keys = generic
|
|
148
|
+
|
|
149
|
+
[logger_root]
|
|
150
|
+
level = WARNING
|
|
151
|
+
handlers = console
|
|
152
|
+
qualname =
|
|
153
|
+
|
|
154
|
+
[logger_sqlalchemy]
|
|
155
|
+
level = WARNING
|
|
156
|
+
handlers =
|
|
157
|
+
qualname = sqlalchemy.engine
|
|
158
|
+
|
|
159
|
+
[logger_alembic]
|
|
160
|
+
level = INFO
|
|
161
|
+
handlers =
|
|
162
|
+
qualname = alembic
|
|
163
|
+
|
|
164
|
+
[handler_console]
|
|
165
|
+
class = StreamHandler
|
|
166
|
+
args = (sys.stderr,)
|
|
167
|
+
level = NOTSET
|
|
168
|
+
formatter = generic
|
|
169
|
+
|
|
170
|
+
[formatter_generic]
|
|
171
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
172
|
+
datefmt = %H:%M:%S
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ===================== 内部工具 =====================
|
|
177
|
+
|
|
178
|
+
def _resolve_alembic_config(db_url: str | None = None, project_dir: Path | None = None) -> Config:
|
|
179
|
+
"""构造 Alembic Config 对象,读取项目根目录的 alembic.ini
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
db_url: 数据库连接URL(可选,不传则从 alembic.ini 读取)
|
|
183
|
+
project_dir: 项目根目录(默认当前工作目录)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Alembic 配置对象
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
FileNotFoundError: 若根目录 alembic.ini 不存在,提示用户先执行 init
|
|
190
|
+
"""
|
|
191
|
+
here = project_dir or Path.cwd()
|
|
192
|
+
root_ini = here / 'alembic.ini'
|
|
193
|
+
|
|
194
|
+
if not root_ini.exists():
|
|
195
|
+
raise FileNotFoundError(
|
|
196
|
+
f'未找到 {root_ini},请先执行: fastapi-augment-migrate init --db-url "<数据库URL>"'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
cfg = Config(str(root_ini))
|
|
200
|
+
if db_url:
|
|
201
|
+
cfg.set_main_option('sqlalchemy.url', db_url)
|
|
202
|
+
return cfg
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ===================== init =====================
|
|
206
|
+
|
|
207
|
+
def init_project(db_url: str, project_dir: Path | None = None) -> None:
|
|
208
|
+
"""初始化项目迁移环境
|
|
209
|
+
|
|
210
|
+
在项目根目录生成 alembic.ini 和 alembic/versions/ 目录。
|
|
211
|
+
若 alembic.ini 已存在则跳过,不覆盖。
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
db_url: 数据库连接URL
|
|
215
|
+
project_dir: 项目根目录
|
|
216
|
+
"""
|
|
217
|
+
here = project_dir or Path.cwd()
|
|
218
|
+
alembic_ini = here / 'alembic.ini'
|
|
219
|
+
versions_dir = here / 'migrations' / 'versions'
|
|
220
|
+
|
|
221
|
+
if alembic_ini.exists():
|
|
222
|
+
_logger.info('%s 已存在,跳过(不会覆盖)', alembic_ini)
|
|
223
|
+
else:
|
|
224
|
+
ini_content = _ALEMBIC_INI_TEMPLATE.format(db_url=db_url)
|
|
225
|
+
alembic_ini.write_text(ini_content)
|
|
226
|
+
_logger.info('已创建 %s', alembic_ini)
|
|
227
|
+
|
|
228
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
_logger.info('已确保 %s 存在', versions_dir)
|
|
230
|
+
_logger.info('初始化完成,现在可以使用 generate 生成迁移了')
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ===================== upgrade / downgrade =====================
|
|
234
|
+
|
|
235
|
+
def upgrade(db_url: str | None = None, revision: str = 'head', project_dir: Path | None = None) -> None:
|
|
236
|
+
"""升级数据库
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
db_url: 数据库连接URL(可选,不传则从 alembic.ini 读取)
|
|
240
|
+
revision: 目标修订版本
|
|
241
|
+
project_dir: 项目根目录
|
|
242
|
+
"""
|
|
243
|
+
cfg = _resolve_alembic_config(db_url, project_dir)
|
|
244
|
+
command.upgrade(cfg, revision)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def downgrade(db_url: str | None = None, revision: str = '-1', project_dir: Path | None = None) -> None:
|
|
248
|
+
"""降级数据库
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
db_url: 数据库连接URL(可选,不传则从 alembic.ini 读取)
|
|
252
|
+
revision: 目标修订版本
|
|
253
|
+
project_dir: 项目根目录
|
|
254
|
+
"""
|
|
255
|
+
cfg = _resolve_alembic_config(db_url, project_dir)
|
|
256
|
+
command.downgrade(cfg, revision)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ===================== generate =====================
|
|
260
|
+
|
|
261
|
+
def generate_migration(message: str, models: str, project_dir: Path | None = None) -> None:
|
|
262
|
+
"""生成迁移文件
|
|
263
|
+
|
|
264
|
+
调用 alembic revision --autogenerate 生成迁移脚本。
|
|
265
|
+
需要项目根目录已存在 alembic.ini(通过 init 命令创建)。
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
message: 迁移描述信息
|
|
269
|
+
models: 模型模块,多个用逗号分隔 (如 myapp.models)
|
|
270
|
+
project_dir: 项目根目录
|
|
271
|
+
"""
|
|
272
|
+
here = project_dir or Path.cwd()
|
|
273
|
+
alembic_ini = here / 'alembic.ini'
|
|
274
|
+
versions_dir = here / 'migrations' / 'versions'
|
|
275
|
+
|
|
276
|
+
# 检查 alembic.ini 是否存在(不自动创建,由 init 负责)
|
|
277
|
+
if not alembic_ini.exists():
|
|
278
|
+
raise FileNotFoundError(
|
|
279
|
+
f'未找到 {alembic_ini},请先执行: fastapi-augment-migrate init --db-url "<数据库URL>"'
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 确保 versions 目录存在
|
|
283
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
|
|
285
|
+
# 设置环境变量,让库内的 env.py 能加载用户模型
|
|
286
|
+
env = environ.copy()
|
|
287
|
+
env['FASTAPI_AUGMENT_MODELS'] = models
|
|
288
|
+
|
|
289
|
+
# 先 stamp head,同步数据库版本标记,
|
|
290
|
+
# 避免 "Target database is not up to date" 错误
|
|
291
|
+
stamp_cmd = [
|
|
292
|
+
sys.executable, '-m', 'alembic',
|
|
293
|
+
'-c', str(alembic_ini),
|
|
294
|
+
'stamp', 'head'
|
|
295
|
+
]
|
|
296
|
+
subprocess.run(stamp_cmd, env=env, cwd=str(here))
|
|
297
|
+
|
|
298
|
+
# 调用 alembic 生成迁移
|
|
299
|
+
cmd = [
|
|
300
|
+
sys.executable, '-m', 'alembic',
|
|
301
|
+
'-c', str(alembic_ini),
|
|
302
|
+
'revision', '--autogenerate', '-m', message
|
|
303
|
+
]
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run(cmd, env=env, cwd=str(here), check=True)
|
|
306
|
+
_logger.info('迁移脚本已生成于 %s', versions_dir)
|
|
307
|
+
except subprocess.CalledProcessError as e:
|
|
308
|
+
raise RuntimeError(f'生成迁移失败 (返回码 {e.returncode})') from e
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ===================== CLI 入口 =====================
|
|
312
|
+
|
|
313
|
+
def cli_main() -> None:
|
|
314
|
+
"""数据库迁移工具 (fastapi-augment)"""
|
|
315
|
+
parser = argparse.ArgumentParser(description='数据库迁移工具 (fastapi-augment)')
|
|
316
|
+
subparsers = parser.add_subparsers(dest='command', required=True, help='子命令')
|
|
317
|
+
|
|
318
|
+
# --- init ---
|
|
319
|
+
init_parser = subparsers.add_parser('init', help='初始化项目迁移环境')
|
|
320
|
+
init_parser.add_argument('--db-url', default='sqlite:///app.db', help='数据库连接 URL')
|
|
321
|
+
init_parser.add_argument('--project-dir', type=Path, default=None, help='项目根目录(默认为当前工作目录)')
|
|
322
|
+
|
|
323
|
+
# --- upgrade ---
|
|
324
|
+
up_parser = subparsers.add_parser('upgrade', help='执行数据库迁移')
|
|
325
|
+
up_parser.add_argument('--db-url', help='数据库连接 URL(不传则从 alembic.ini 读取)')
|
|
326
|
+
up_parser.add_argument('--revision', default='head', help='目标版本 (默认: head)')
|
|
327
|
+
up_parser.add_argument('--downgrade', action='store_true', help='降级而非升级')
|
|
328
|
+
up_parser.add_argument('--project-dir', type=Path, default=None, help='项目根目录(默认为当前工作目录)')
|
|
329
|
+
|
|
330
|
+
# --- generate ---
|
|
331
|
+
gen_parser = subparsers.add_parser('generate', help='生成迁移脚本')
|
|
332
|
+
gen_parser.add_argument('--message', required=True, help='迁移描述信息')
|
|
333
|
+
gen_parser.add_argument('--models', required=True, help='模型模块,多个用逗号分隔 (如 myapp.models)')
|
|
334
|
+
gen_parser.add_argument('--project-dir', type=Path, default=None, help='项目根目录(默认为当前工作目录)')
|
|
335
|
+
|
|
336
|
+
args = parser.parse_args()
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
if args.command == 'init':
|
|
340
|
+
init_project(args.db_url, args.project_dir)
|
|
341
|
+
elif args.command == 'upgrade':
|
|
342
|
+
if args.downgrade:
|
|
343
|
+
downgrade(args.db_url, args.revision, args.project_dir)
|
|
344
|
+
else:
|
|
345
|
+
upgrade(args.db_url, args.revision, args.project_dir)
|
|
346
|
+
elif args.command == 'generate':
|
|
347
|
+
generate_migration(args.message, args.models, args.project_dir)
|
|
348
|
+
else:
|
|
349
|
+
parser.print_help()
|
|
350
|
+
except (FileNotFoundError, RuntimeError) as e:
|
|
351
|
+
_logger.error('%s', e)
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
cli_main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/8/31
|
|
4
|
+
@Description : 通用模型混入类 — 时间戳、软删除、审计,均支持细粒度组合。
|
|
5
|
+
"""
|
|
6
|
+
from .audit import CreatedByMixin, UpdatedByMixin, AuditMixin
|
|
7
|
+
from .soft_delete import SoftDeleteMixin, SoftDeleteAuditMixin
|
|
8
|
+
from .timestamp import CreatedAtMixin, TimestampMixin
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'CreatedByMixin',
|
|
12
|
+
'UpdatedByMixin',
|
|
13
|
+
'AuditMixin',
|
|
14
|
+
'SoftDeleteMixin',
|
|
15
|
+
'SoftDeleteAuditMixin',
|
|
16
|
+
'CreatedAtMixin',
|
|
17
|
+
'TimestampMixin',
|
|
18
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/1
|
|
4
|
+
@Description : 审计混入类,可整体或细粒度组合 created_by / updated_by 列。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import String
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CreatedByMixin:
|
|
13
|
+
"""创建人混入类
|
|
14
|
+
|
|
15
|
+
仅添加 created_by 一列,记录创建操作人ID,
|
|
16
|
+
默认对齐 ModelBase 的 ULID 主键长度(String(26))。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
created_by: Mapped[str | None] = mapped_column(
|
|
20
|
+
String(26),
|
|
21
|
+
default=None,
|
|
22
|
+
comment='创建人ID',
|
|
23
|
+
sort_order=901
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UpdatedByMixin:
|
|
28
|
+
"""更新人混入类
|
|
29
|
+
|
|
30
|
+
仅添加 updated_by 一列,记录最近更新操作人ID。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
updated_by: Mapped[str | None] = mapped_column(
|
|
34
|
+
String(26),
|
|
35
|
+
default=None,
|
|
36
|
+
comment='更新人ID',
|
|
37
|
+
sort_order=911
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuditMixin(CreatedByMixin, UpdatedByMixin):
|
|
42
|
+
"""审计混入类
|
|
43
|
+
|
|
44
|
+
组合 CreatedByMixin + UpdatedByMixin,即常见的
|
|
45
|
+
created_by / updated_by 成对出现;只要其中一列时,
|
|
46
|
+
直接继承对应的细粒度混入类::
|
|
47
|
+
|
|
48
|
+
class Article(CreatedByMixin, ModelBase): # 只记录创建人
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
两列均为可空:系统任务(无登录用户上下文)写入时留空;
|
|
52
|
+
由 service 层从当前请求上下文取用户ID填充::
|
|
53
|
+
|
|
54
|
+
await crud.create(
|
|
55
|
+
session,
|
|
56
|
+
User(name='alice', created_by=current_user.id),
|
|
57
|
+
)
|
|
58
|
+
await crud.update_by_id(
|
|
59
|
+
session, uid, updated_by=current_user.id, **changes,
|
|
60
|
+
)
|
|
61
|
+
"""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/1
|
|
4
|
+
@Description : 软删除混入类,提供 is_deleted / deleted_at 通用列。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import Boolean, DateTime, ColumnExpressionArgument, String
|
|
11
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SoftDeleteMixin:
|
|
15
|
+
"""软删除混入类
|
|
16
|
+
|
|
17
|
+
为模型添加 is_deleted / deleted_at 两列,配合 CrudBase 使用::
|
|
18
|
+
|
|
19
|
+
# 标记删除(service 层负责)
|
|
20
|
+
await crud.update_by_id(
|
|
21
|
+
session, uid,
|
|
22
|
+
is_deleted=True,
|
|
23
|
+
deleted_at=datetime.now(timezone.utc),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# 只查询未删除数据
|
|
27
|
+
await crud.list(session, expressions=(User.not_deleted(),))
|
|
28
|
+
|
|
29
|
+
注意:软删除只是应用层约定,数据库的唯一约束、外键等
|
|
30
|
+
不会感知软删状态(软删行仍占用唯一键),需在业务层处理。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
is_deleted: Mapped[bool] = mapped_column(
|
|
34
|
+
Boolean,
|
|
35
|
+
default=False,
|
|
36
|
+
index=True,
|
|
37
|
+
comment='是否已删除',
|
|
38
|
+
sort_order=920
|
|
39
|
+
)
|
|
40
|
+
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
41
|
+
DateTime(timezone=True),
|
|
42
|
+
default=None,
|
|
43
|
+
comment='删除时间',
|
|
44
|
+
sort_order=921
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def not_deleted(cls) -> ColumnExpressionArgument[bool]:
|
|
49
|
+
"""未删除的过滤条件"""
|
|
50
|
+
return cls.is_deleted.is_(False)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def only_deleted(cls) -> ColumnExpressionArgument[bool]:
|
|
54
|
+
"""已删除的过滤条件"""
|
|
55
|
+
return cls.is_deleted.is_(True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SoftDeleteAuditMixin(SoftDeleteMixin):
|
|
59
|
+
"""带操作人审计的软删除扩展 Mixin
|
|
60
|
+
|
|
61
|
+
在基础软删之上增加 deleted_by,记录是谁执行软删除。
|
|
62
|
+
需要审计删除人的模型再继承本类,不要全局滥用。
|
|
63
|
+
|
|
64
|
+
使用示例::
|
|
65
|
+
await crud.update_by_id(
|
|
66
|
+
session,
|
|
67
|
+
uid,
|
|
68
|
+
is_deleted=True,
|
|
69
|
+
deleted_at=datetime.now(timezone.utc),
|
|
70
|
+
deleted_by=operator_user_id,
|
|
71
|
+
)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# 根据你的主键类型调整:ULID / str / int,这里示例用 str,可改成 ULID
|
|
75
|
+
deleted_by: Mapped[str | None] = mapped_column(
|
|
76
|
+
String(26),
|
|
77
|
+
default=None,
|
|
78
|
+
comment='执行软删除操作人ID',
|
|
79
|
+
sort_order=922
|
|
80
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/9/1
|
|
4
|
+
@Description : 时间戳混入类,可整体或细粒度组合 created_at / updated_at 列。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import DateTime, func
|
|
11
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CreatedAtMixin:
|
|
15
|
+
"""创建时间混入类
|
|
16
|
+
|
|
17
|
+
仅添加 created_at 一列,插入时由数据库时钟生成,之后不再变化。
|
|
18
|
+
适用于只增不改的表(日志、流水、快照等)。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
22
|
+
DateTime(timezone=True),
|
|
23
|
+
server_default=func.now(),
|
|
24
|
+
comment='创建时间',
|
|
25
|
+
sort_order=900
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TimestampMixin(CreatedAtMixin):
|
|
30
|
+
"""时间戳混入类
|
|
31
|
+
|
|
32
|
+
继承 CreatedAtMixin ,即常见的
|
|
33
|
+
created_at / updated_at 成对出现;只要其中一列时,
|
|
34
|
+
直接继承对应的细粒度混入类::
|
|
35
|
+
|
|
36
|
+
class Log(CreatedAtMixin, ModelBase): # 只要创建时间
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
class User(TimestampMixin, ModelBase): # 两列都要
|
|
40
|
+
...
|
|
41
|
+
"""
|
|
42
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
43
|
+
DateTime(timezone=True),
|
|
44
|
+
server_default=func.now(),
|
|
45
|
+
onupdate=func.now(),
|
|
46
|
+
comment='更新时间',
|
|
47
|
+
sort_order=910
|
|
48
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@Author : hangu
|
|
3
|
+
@CreateDate : 2026/8/31
|
|
4
|
+
@Description : SQLAlchemy 模型基础类
|
|
5
|
+
"""
|
|
6
|
+
from sqlalchemy import String
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
from ulid import ULID
|
|
9
|
+
|
|
10
|
+
from .base import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _generate_ulid() -> str:
|
|
14
|
+
"""生成ULID
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
ULID字符串
|
|
18
|
+
"""
|
|
19
|
+
return str(ULID()).lower()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelBase(Base):
|
|
23
|
+
"""SQLAlchemy模型基础类
|
|
24
|
+
|
|
25
|
+
仅提供通用的 ULID 主键字段;时间戳、软删除、审计等
|
|
26
|
+
通用列通过 mixins 按需组合,成对的列可整体使用,
|
|
27
|
+
也可用细粒度混入类只取其中一列:
|
|
28
|
+
|
|
29
|
+
from .mixins import (
|
|
30
|
+
CreatedAtMixin, SoftDeleteMixin, TimestampMixin,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
class User(TimestampMixin, SoftDeleteMixin, ModelBase):
|
|
34
|
+
... # created_at + updated_at
|
|
35
|
+
|
|
36
|
+
class Log(CreatedAtMixin, ModelBase): # 只要创建时间
|
|
37
|
+
...
|
|
38
|
+
"""
|
|
39
|
+
__abstract__ = True
|
|
40
|
+
|
|
41
|
+
id: Mapped[str] = mapped_column(
|
|
42
|
+
String(26),
|
|
43
|
+
default=_generate_ulid,
|
|
44
|
+
primary_key=True,
|
|
45
|
+
comment='主键ID',
|
|
46
|
+
sort_order=-1
|
|
47
|
+
)
|