sdpy-kit 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/PKG-INFO +1 -1
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/__init__.py +1 -1
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/audit.py +217 -44
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/base_db_kit.py +15 -3
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/crud.py +64 -17
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/.gitignore +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/README.md +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/base_service.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/chat_service.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/failover.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/profiles.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/types.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/common_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/engine.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/query.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/session.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/file_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/license_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/pyproject.toml +0 -0
|
@@ -65,8 +65,11 @@ UPDATE 不参与 空才填 不参与
|
|
|
65
65
|
"""
|
|
66
66
|
|
|
67
67
|
import threading
|
|
68
|
-
from collections.abc import Callable, Mapping, MutableMapping
|
|
68
|
+
from collections.abc import Callable, Generator, Mapping, MutableMapping
|
|
69
|
+
from contextlib import contextmanager
|
|
70
|
+
from contextvars import ContextVar
|
|
69
71
|
from datetime import UTC, datetime
|
|
72
|
+
from typing import Any, ClassVar
|
|
70
73
|
|
|
71
74
|
from sqlalchemy import String, event, inspect as sa_inspect
|
|
72
75
|
from sqlalchemy.exc import NoSuchTableError
|
|
@@ -78,24 +81,33 @@ from kit.db.engine import get_engine
|
|
|
78
81
|
|
|
79
82
|
logger = get_default_logger(__name__)
|
|
80
83
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
CREATED_BY =
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
# 字段「槽位 → 默认列名」:槽位是内部语义键(也是默认列名),
|
|
85
|
+
# 实际列名可用 AuditConfig.set_fields() 覆盖(如把 created_at 换成 create_time)
|
|
86
|
+
CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY, DELETED = (
|
|
87
|
+
"created_at",
|
|
88
|
+
"created_by",
|
|
89
|
+
"updated_at",
|
|
90
|
+
"updated_by",
|
|
91
|
+
"deleted",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 审计四件套槽位(created_at / created_by / updated_at / updated_by)
|
|
86
95
|
AUDIT_FIELDS: tuple[str, ...] = (CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY)
|
|
87
96
|
|
|
88
|
-
#
|
|
89
|
-
DELETED = "deleted"
|
|
90
|
-
|
|
91
|
-
# 填充器实际关注的列(审计四件套 + 删除标记)
|
|
97
|
+
# 填充器实际关注的槽位(审计四件套 + 删除标记)
|
|
92
98
|
FILL_FIELDS: tuple[str, ...] = (*AUDIT_FIELDS, DELETED)
|
|
93
99
|
|
|
100
|
+
# 槽位 → 默认列名(默认同名;set_fields 只覆盖发生变化的槽位)
|
|
101
|
+
DEFAULT_FIELDS: dict[str, str] = {slot: slot for slot in FILL_FIELDS}
|
|
102
|
+
|
|
94
103
|
# 列探测缓存:(db_type, 表名) -> {列名: SQLAlchemy 类型对象}
|
|
95
104
|
# 仅缓存**探测成功**的结果;失败不缓存,表稍后创建可自愈
|
|
96
|
-
_columns_cache: dict[tuple[str, str], dict[str, TypeEngine]] = {}
|
|
105
|
+
_columns_cache: dict[tuple[str, str], dict[str, TypeEngine[Any]]] = {}
|
|
97
106
|
_columns_lock = threading.Lock()
|
|
98
107
|
|
|
108
|
+
# 「查已删除」上下文开关:进入 AuditConfig.include_deleted() 后临时关闭过滤
|
|
109
|
+
_include_deleted: ContextVar[bool] = ContextVar("audit_include_deleted", default=False)
|
|
110
|
+
|
|
99
111
|
|
|
100
112
|
def _default_clock() -> datetime:
|
|
101
113
|
"""默认时钟:UTC aware(与 models/base.py::_utcnow 同口径)"""
|
|
@@ -109,15 +121,38 @@ def _is_empty(value: object) -> bool:
|
|
|
109
121
|
return isinstance(value, str) and not value.strip()
|
|
110
122
|
|
|
111
123
|
|
|
112
|
-
def
|
|
113
|
-
"""
|
|
124
|
+
def _is_string_type(column_type: TypeEngine[Any]) -> bool:
|
|
125
|
+
"""是否为字符串列(兼容 SQLModel 的 ``AutoString`` 这类 ``TypeDecorator``)
|
|
126
|
+
|
|
127
|
+
三级判定(任一命中即视为字符串):
|
|
128
|
+
1. ``isinstance(type, String)`` —— 原生 ``VARCHAR`` / ``CHAR`` / ``TEXT``;
|
|
129
|
+
2. ``python_type is str`` —— 多数类型装饰器;
|
|
130
|
+
3. ``impl`` 是 ``String`` 或其子类 —— SQLModel 的 ``AutoString``
|
|
131
|
+
(``TypeDecorator``,前两级都判定不到)。
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(column_type, String):
|
|
134
|
+
return True
|
|
135
|
+
try:
|
|
136
|
+
if column_type.python_type is str:
|
|
137
|
+
return True
|
|
138
|
+
except (AttributeError, NotImplementedError):
|
|
139
|
+
pass
|
|
140
|
+
impl = getattr(column_type, "impl", None)
|
|
141
|
+
if isinstance(impl, String):
|
|
142
|
+
return True
|
|
143
|
+
return isinstance(impl, type) and issubclass(impl, String)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normal_value(column_type: TypeEngine[Any]) -> object:
|
|
147
|
+
"""删除标记的「正常」值:字符串列(VARCHAR/CHAR/TEXT)为 ``"0"``,整数列为 ``0``
|
|
114
148
|
|
|
149
|
+
写入填充与查询过滤共用同一个值,避免两侧口径不一致。
|
|
115
150
|
字符串列写整数在 PostgreSQL(``character varying``)与 MySQL 严格模式下
|
|
116
151
|
会直接报类型错误,故按列类型区分。
|
|
152
|
+
|
|
153
|
+
⚠ 内部符号(不进 ``kit.db.__all__``)。
|
|
117
154
|
"""
|
|
118
|
-
if
|
|
119
|
-
return "0"
|
|
120
|
-
return 0
|
|
155
|
+
return "0" if _is_string_type(column_type) else 0
|
|
121
156
|
|
|
122
157
|
|
|
123
158
|
class AuditConfig:
|
|
@@ -127,11 +162,13 @@ class AuditConfig:
|
|
|
127
162
|
不做运行期热切换承诺。模块导入零副作用。
|
|
128
163
|
"""
|
|
129
164
|
|
|
130
|
-
_lock = threading.RLock()
|
|
131
|
-
_enabled = False
|
|
132
|
-
_user_resolver: Callable[[], object] | None = None
|
|
133
|
-
_clock: Callable[[], datetime] = _default_clock
|
|
134
|
-
|
|
165
|
+
_lock: ClassVar[Any] = threading.RLock()
|
|
166
|
+
_enabled: ClassVar[bool] = False
|
|
167
|
+
_user_resolver: ClassVar[Callable[[], object] | None] = None
|
|
168
|
+
_clock: ClassVar[Callable[[], datetime]] = _default_clock
|
|
169
|
+
_fields: ClassVar[dict[str, str]] = dict(DEFAULT_FIELDS)
|
|
170
|
+
_soft_delete_filter: ClassVar[bool] = False
|
|
171
|
+
_listening: ClassVar[bool] = False
|
|
135
172
|
|
|
136
173
|
@classmethod
|
|
137
174
|
def enable(
|
|
@@ -139,6 +176,8 @@ class AuditConfig:
|
|
|
139
176
|
*,
|
|
140
177
|
user_resolver: Callable[[], object] | None = None,
|
|
141
178
|
clock: Callable[[], datetime] | None = None,
|
|
179
|
+
fields: Mapping[str, str] | None = None,
|
|
180
|
+
soft_delete_filter: bool | None = None,
|
|
142
181
|
) -> None:
|
|
143
182
|
"""启用自动填充(幂等)
|
|
144
183
|
|
|
@@ -147,22 +186,112 @@ class AuditConfig:
|
|
|
147
186
|
返回 None / 空串时跳过 ``*_by`` 填充;抛异常时记 warning 并跳过,
|
|
148
187
|
不影响写入。
|
|
149
188
|
clock: 取时间钩子,默认 ``datetime.now(UTC)``(测试可注入冻结时钟)。
|
|
189
|
+
fields: 自定义字段名(槽位 → 实际列名),只给需要改的项,如
|
|
190
|
+
``{"created_at": "create_time", "deleted": "del_flag"}``;
|
|
191
|
+
透传给 :meth:`set_fields`。
|
|
192
|
+
soft_delete_filter: 是否让查询方法自动追加「deleted = 正常值」条件
|
|
193
|
+
(None = 保持当前设置;默认关闭)。
|
|
150
194
|
|
|
151
195
|
Note:
|
|
152
196
|
事件监听挂在父类 :class:`sqlalchemy.orm.Session` 上,因此
|
|
153
197
|
``session_scope`` 新建的 Session 与应用自建的原生 Session 都会触发。
|
|
154
198
|
"""
|
|
155
199
|
with cls._lock:
|
|
200
|
+
if fields is not None:
|
|
201
|
+
cls.set_fields(fields)
|
|
202
|
+
if soft_delete_filter is not None:
|
|
203
|
+
cls._soft_delete_filter = soft_delete_filter
|
|
204
|
+
# ClassVar 上的 Callable 赋值:pyright 会误判为方法绑定,运行时正确
|
|
156
205
|
if user_resolver is not None:
|
|
157
|
-
cls._user_resolver = user_resolver
|
|
206
|
+
cls._user_resolver = user_resolver # pyright: ignore[reportAttributeAccessIssue]
|
|
158
207
|
if clock is not None:
|
|
159
|
-
cls._clock = clock
|
|
208
|
+
cls._clock = clock # pyright: ignore[reportAttributeAccessIssue]
|
|
160
209
|
if not cls._listening:
|
|
161
210
|
event.listen(Session, "before_flush", _on_before_flush)
|
|
162
211
|
cls._listening = True
|
|
163
212
|
cls._enabled = True
|
|
164
213
|
logger.info("✅ 审计字段与删除标记自动填充已启用")
|
|
165
214
|
|
|
215
|
+
@classmethod
|
|
216
|
+
def fields(cls) -> dict[str, str]:
|
|
217
|
+
"""当前「槽位 → 实际列名」映射(副本,可安全修改)"""
|
|
218
|
+
return dict(cls._fields)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def set_fields(cls, fields: Mapping[str, str] | None = None) -> None:
|
|
222
|
+
"""配置字段名(不传 / 传 None 恢复默认)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
fields: 槽位 → 实际列名,只给需要改的槽位。可用槽位:
|
|
226
|
+
``created_at`` / ``created_by`` / ``updated_at`` / ``updated_by`` /
|
|
227
|
+
``deleted``
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: 出现未知槽位(防手误,例如把 ``created_at`` 写成 ``create_at``)
|
|
231
|
+
|
|
232
|
+
用法::
|
|
233
|
+
|
|
234
|
+
AuditConfig.set_fields(
|
|
235
|
+
{"created_at": "create_time", "deleted": "del_flag"}
|
|
236
|
+
)
|
|
237
|
+
"""
|
|
238
|
+
merged = dict(DEFAULT_FIELDS)
|
|
239
|
+
if fields:
|
|
240
|
+
unknown = set(fields) - set(DEFAULT_FIELDS)
|
|
241
|
+
if unknown:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"未知的字段槽位: {sorted(unknown)};"
|
|
244
|
+
f"可用槽位: {sorted(DEFAULT_FIELDS)}"
|
|
245
|
+
)
|
|
246
|
+
merged.update(fields)
|
|
247
|
+
with cls._lock:
|
|
248
|
+
cls._fields = merged
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def set_soft_delete_filter(cls, enabled: bool) -> None:
|
|
252
|
+
"""开关「查询自动过滤软删除记录」(默认关闭)
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
enabled: True 时,kit 的查询方法对**有 deleted 列的表/模型**追加
|
|
256
|
+
``deleted = 正常值`` 条件(整数字段为 ``0``,字符串字段为 ``"0"``)。
|
|
257
|
+
|
|
258
|
+
Note:
|
|
259
|
+
- 只影响**查询**:`CRUDBase` 的 get / get_or_raise / get_multi /
|
|
260
|
+
get_by_field / get_multi_by_field / count / exists,以及 `raw_get_by`;
|
|
261
|
+
- 写方法(create / update / update_multi / delete / upsert / raw_*)
|
|
262
|
+
不追加该条件;
|
|
263
|
+
- `query()` 手写 SQL **不处理**(与 `execute()` 同一条原则);
|
|
264
|
+
- 需要读取已删除记录时用 :meth:`include_deleted` 上下文。
|
|
265
|
+
"""
|
|
266
|
+
with cls._lock:
|
|
267
|
+
cls._soft_delete_filter = enabled
|
|
268
|
+
logger.info(f"🔎 软删除查询过滤已{'开启' if enabled else '关闭'}")
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def filter_deleted(cls) -> bool:
|
|
272
|
+
"""当前查询是否应追加「deleted = 正常值」条件
|
|
273
|
+
|
|
274
|
+
综合两件事:过滤开关已开启,且当前不在 :meth:`include_deleted` 上下文中。
|
|
275
|
+
"""
|
|
276
|
+
return cls._soft_delete_filter and not _include_deleted.get()
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
@contextmanager
|
|
280
|
+
def include_deleted(cls) -> Generator[None, None, None]:
|
|
281
|
+
"""临时查询已删除记录的上下文(仅本上下文内关闭过滤)
|
|
282
|
+
|
|
283
|
+
用法::
|
|
284
|
+
|
|
285
|
+
with AuditConfig.include_deleted():
|
|
286
|
+
rows = PgKit.query(...) # 手写 SQL 本就不受过滤影响
|
|
287
|
+
obj = PgKit.get(Model, 1) # 能查到 deleted=1 的记录
|
|
288
|
+
"""
|
|
289
|
+
token = _include_deleted.set(True)
|
|
290
|
+
try:
|
|
291
|
+
yield
|
|
292
|
+
finally:
|
|
293
|
+
_include_deleted.reset(token)
|
|
294
|
+
|
|
166
295
|
@classmethod
|
|
167
296
|
def disable(cls) -> None:
|
|
168
297
|
"""关闭自动填充(保留 user_resolver / clock 配置,再次 enable 可沿用)"""
|
|
@@ -182,7 +311,7 @@ class AuditConfig:
|
|
|
182
311
|
def now(cls) -> datetime:
|
|
183
312
|
"""当前时间(UTC aware;clock 异常时记 warning 并回退 UTC now)"""
|
|
184
313
|
try:
|
|
185
|
-
return cls._clock()
|
|
314
|
+
return cls._clock() # pyright: ignore[reportAttributeAccessIssue]
|
|
186
315
|
except Exception as e:
|
|
187
316
|
logger.warning(f"⚠️ 审计时钟调用失败,回退 UTC now: {e}")
|
|
188
317
|
return datetime.now(UTC)
|
|
@@ -190,7 +319,7 @@ class AuditConfig:
|
|
|
190
319
|
@classmethod
|
|
191
320
|
def current_user(cls) -> object | None:
|
|
192
321
|
"""当前操作人(resolver 未注册或异常时返回 None)"""
|
|
193
|
-
resolver = cls._user_resolver
|
|
322
|
+
resolver = cls._user_resolver # pyright: ignore[reportAttributeAccessIssue]
|
|
194
323
|
if resolver is None:
|
|
195
324
|
return None
|
|
196
325
|
try:
|
|
@@ -218,7 +347,7 @@ class AuditConfig:
|
|
|
218
347
|
# ==================== 列探测(原生路径) ====================
|
|
219
348
|
|
|
220
349
|
|
|
221
|
-
def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine]:
|
|
350
|
+
def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine[Any]]:
|
|
222
351
|
"""表「列名 → SQLAlchemy 类型对象」映射(仅缓存探测成功的结果)
|
|
223
352
|
|
|
224
353
|
Args:
|
|
@@ -254,12 +383,40 @@ def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine]:
|
|
|
254
383
|
return columns
|
|
255
384
|
|
|
256
385
|
|
|
386
|
+
def deleted_predicate(
|
|
387
|
+
db_type: str, table_name: str
|
|
388
|
+
) -> tuple[str, dict[str, object]] | None:
|
|
389
|
+
"""软删除过滤条件(SQL 片段 + 绑定参数);不适用时返回 None
|
|
390
|
+
|
|
391
|
+
适用条件:过滤开关已开启、当前不在 :meth:`AuditConfig.include_deleted` 上下文、
|
|
392
|
+
表中存在 deleted 列。
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
``("deleted = :_audit_deleted", {"_audit_deleted": 0})``;字符串列的值是
|
|
396
|
+
``"0"``。条件不适用时返回 None(调用方据此不加条件)。
|
|
397
|
+
|
|
398
|
+
Note:
|
|
399
|
+
严格等值,不对 ``NULL`` 做特殊处理——本工具类只提供底层约定,
|
|
400
|
+
不为业务历史数据兜底。
|
|
401
|
+
|
|
402
|
+
⚠ 内部符号:只供 kit 自建 SQL 的**查询**方法(如 ``raw_get_by``)使用,
|
|
403
|
+
写方法不得调用。
|
|
404
|
+
"""
|
|
405
|
+
if not AuditConfig.filter_deleted():
|
|
406
|
+
return None
|
|
407
|
+
name = AuditConfig.fields()[DELETED]
|
|
408
|
+
columns = table_columns(db_type, table_name)
|
|
409
|
+
if name not in columns:
|
|
410
|
+
return None
|
|
411
|
+
return f"{name} = :_audit_deleted", {"_audit_deleted": normal_value(columns[name])}
|
|
412
|
+
|
|
413
|
+
|
|
257
414
|
# ==================== dict 路径填充(Core DML / 原生构建器) ====================
|
|
258
415
|
|
|
259
416
|
|
|
260
417
|
def _put(
|
|
261
418
|
data: MutableMapping[str, object],
|
|
262
|
-
columns: Mapping[str, TypeEngine],
|
|
419
|
+
columns: Mapping[str, TypeEngine[Any]],
|
|
263
420
|
name: str,
|
|
264
421
|
value: object,
|
|
265
422
|
) -> None:
|
|
@@ -271,7 +428,9 @@ def _put(
|
|
|
271
428
|
data[name] = value
|
|
272
429
|
|
|
273
430
|
|
|
274
|
-
def fill_insert(
|
|
431
|
+
def fill_insert(
|
|
432
|
+
data: MutableMapping[str, object], columns: Mapping[str, TypeEngine[Any]]
|
|
433
|
+
) -> None:
|
|
275
434
|
"""INSERT 半区:created_* 与 updated_* 同值填补,deleted 补正常值
|
|
276
435
|
|
|
277
436
|
Args:
|
|
@@ -282,15 +441,19 @@ def fill_insert(data: MutableMapping[str, object], columns: Mapping[str, TypeEng
|
|
|
282
441
|
return
|
|
283
442
|
now = AuditConfig.now()
|
|
284
443
|
user = AuditConfig.current_user()
|
|
285
|
-
|
|
286
|
-
_put(data, columns,
|
|
287
|
-
_put(data, columns,
|
|
288
|
-
_put(data, columns,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
444
|
+
names = AuditConfig._fields
|
|
445
|
+
_put(data, columns, names[CREATED_AT], now)
|
|
446
|
+
_put(data, columns, names[UPDATED_AT], now)
|
|
447
|
+
_put(data, columns, names[CREATED_BY], user)
|
|
448
|
+
_put(data, columns, names[UPDATED_BY], user)
|
|
449
|
+
deleted_col = names[DELETED]
|
|
450
|
+
if deleted_col in columns:
|
|
451
|
+
_put(data, columns, deleted_col, normal_value(columns[deleted_col]))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def fill_update(
|
|
455
|
+
data: MutableMapping[str, object], columns: Mapping[str, TypeEngine[Any]]
|
|
456
|
+
) -> None:
|
|
294
457
|
"""UPDATE 半区:只动 updated_*(不碰 created_*,也不碰 deleted)
|
|
295
458
|
|
|
296
459
|
Args:
|
|
@@ -301,8 +464,9 @@ def fill_update(data: MutableMapping[str, object], columns: Mapping[str, TypeEng
|
|
|
301
464
|
return
|
|
302
465
|
now = AuditConfig.now()
|
|
303
466
|
user = AuditConfig.current_user()
|
|
304
|
-
|
|
305
|
-
_put(data, columns,
|
|
467
|
+
names = AuditConfig._fields
|
|
468
|
+
_put(data, columns, names[UPDATED_AT], now)
|
|
469
|
+
_put(data, columns, names[UPDATED_BY], user)
|
|
306
470
|
|
|
307
471
|
|
|
308
472
|
def fill_insert_for(
|
|
@@ -326,7 +490,7 @@ def fill_update_for(
|
|
|
326
490
|
# ==================== ORM 路径填充(before_flush) ====================
|
|
327
491
|
|
|
328
492
|
|
|
329
|
-
def _object_columns(obj: object) -> dict[str, TypeEngine]:
|
|
493
|
+
def _object_columns(obj: object) -> dict[str, TypeEngine[Any]]:
|
|
330
494
|
"""对象映射到的「表列名 → 类型对象」(无 ``__table__`` 时返回空字典)"""
|
|
331
495
|
table = getattr(obj, "__table__", None)
|
|
332
496
|
if table is None:
|
|
@@ -339,15 +503,24 @@ def _fill_obj(
|
|
|
339
503
|
) -> None:
|
|
340
504
|
"""按矩阵填充单个 ORM 对象(列不存在 / 已有非空值 → 跳过)"""
|
|
341
505
|
columns = _object_columns(obj)
|
|
506
|
+
names = AuditConfig._fields
|
|
342
507
|
pairs: list[tuple[str, object]] = (
|
|
343
|
-
[
|
|
508
|
+
[
|
|
509
|
+
(names[CREATED_AT], now),
|
|
510
|
+
(names[UPDATED_AT], now),
|
|
511
|
+
(names[CREATED_BY], user),
|
|
512
|
+
(names[UPDATED_BY], user),
|
|
513
|
+
]
|
|
344
514
|
if insert
|
|
345
|
-
else [(UPDATED_AT, now), (UPDATED_BY, user)]
|
|
515
|
+
else [(names[UPDATED_AT], now), (names[UPDATED_BY], user)]
|
|
346
516
|
)
|
|
347
|
-
|
|
348
|
-
|
|
517
|
+
deleted_col = names[DELETED]
|
|
518
|
+
if insert and deleted_col in columns:
|
|
519
|
+
pairs.append((deleted_col, normal_value(columns[deleted_col])))
|
|
349
520
|
|
|
350
521
|
state = sa_inspect(obj)
|
|
522
|
+
if state is None: # 理论上实例一定有状态,防御性判断
|
|
523
|
+
return
|
|
351
524
|
for name, value in pairs:
|
|
352
525
|
if name not in columns or _is_empty(value) or not hasattr(obj, name):
|
|
353
526
|
continue
|
|
@@ -56,7 +56,7 @@ from sqlmodel import Session, SQLModel
|
|
|
56
56
|
|
|
57
57
|
from kit.common_kit import CommonKit
|
|
58
58
|
from kit.config.log_config import get_default_logger
|
|
59
|
-
from kit.db.audit import fill_insert_for, fill_update_for
|
|
59
|
+
from kit.db.audit import deleted_predicate, fill_insert_for, fill_update_for
|
|
60
60
|
from kit.db.crud import CRUDBase
|
|
61
61
|
from kit.db.engine import (
|
|
62
62
|
close_engine,
|
|
@@ -914,10 +914,22 @@ class BaseDbKit(ABC):
|
|
|
914
914
|
|
|
915
915
|
Returns:
|
|
916
916
|
匹配的第一行(字典);无匹配返回 None
|
|
917
|
+
|
|
918
|
+
Note:
|
|
919
|
+
开启软删除查询过滤(`AuditConfig.set_soft_delete_filter(True)`)后,
|
|
920
|
+
若表存在 deleted 列,会自动追加 ``deleted = 正常值`` 条件;
|
|
921
|
+
需读取已删除行时用 ``with AuditConfig.include_deleted():`` 包裹。
|
|
917
922
|
"""
|
|
923
|
+
where = f"{column} = :value"
|
|
924
|
+
params: dict[str, object] = {"value": value}
|
|
925
|
+
predicate = deleted_predicate(cls.DB_TYPE, table_name)
|
|
926
|
+
if predicate is not None:
|
|
927
|
+
fragment, extra = predicate
|
|
928
|
+
where = f"{where} AND {fragment}"
|
|
929
|
+
params.update(extra)
|
|
930
|
+
|
|
918
931
|
rows = cls.query(
|
|
919
|
-
f"SELECT * FROM {table_name} WHERE {
|
|
920
|
-
{"value": value},
|
|
932
|
+
f"SELECT * FROM {table_name} WHERE {where} LIMIT 1", params
|
|
921
933
|
)
|
|
922
934
|
return rows[0] if rows else None
|
|
923
935
|
|
|
@@ -41,7 +41,15 @@ from sqlmodel import Session, SQLModel, select
|
|
|
41
41
|
from sqlmodel.sql._expression_select_cls import SelectOfScalar
|
|
42
42
|
|
|
43
43
|
from kit.config.log_config import get_default_logger
|
|
44
|
-
from kit.db.audit import
|
|
44
|
+
from kit.db.audit import (
|
|
45
|
+
CREATED_AT,
|
|
46
|
+
CREATED_BY,
|
|
47
|
+
DELETED,
|
|
48
|
+
AuditConfig,
|
|
49
|
+
fill_insert,
|
|
50
|
+
fill_update,
|
|
51
|
+
normal_value,
|
|
52
|
+
)
|
|
45
53
|
from kit.db.exceptions import DBError, NotFoundError
|
|
46
54
|
|
|
47
55
|
logger = get_default_logger(__name__)
|
|
@@ -69,9 +77,37 @@ class CRUDBase[
|
|
|
69
77
|
def __init__(self, model: type[ModelType]):
|
|
70
78
|
self.model = model
|
|
71
79
|
|
|
72
|
-
def _select_all(self) -> SelectOfScalar[ModelType]:
|
|
73
|
-
"""返回 sqlmodel.select 的标量查询(SQLModel 类型桥接)
|
|
74
|
-
|
|
80
|
+
def _select_all(self, *, include_deleted: bool = False) -> SelectOfScalar[ModelType]:
|
|
81
|
+
"""返回 sqlmodel.select 的标量查询(SQLModel 类型桥接)
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
include_deleted: 是否跳过软删除过滤(默认 False;仅过滤开关开启时有效)
|
|
85
|
+
"""
|
|
86
|
+
statement = cast(SelectOfScalar[ModelType], select(self.model))
|
|
87
|
+
condition = self._deleted_condition(include_deleted=include_deleted)
|
|
88
|
+
return statement.where(condition) if condition is not None else statement
|
|
89
|
+
|
|
90
|
+
def _deleted_condition(self, *, include_deleted: bool = False) -> Any:
|
|
91
|
+
"""软删除过滤条件(过滤关闭 / 模型无 deleted 列时返回 None)
|
|
92
|
+
|
|
93
|
+
严格等值:``deleted = 正常值``(整数列 ``0``、字符串列 ``"0"``)。
|
|
94
|
+
``NULL`` 不视为未删除——本工具类只做底层约定,不为业务历史数据兜底。
|
|
95
|
+
"""
|
|
96
|
+
if include_deleted or not AuditConfig.filter_deleted():
|
|
97
|
+
return None
|
|
98
|
+
column = getattr(self.model, AuditConfig.fields()[DELETED], None)
|
|
99
|
+
if column is None:
|
|
100
|
+
return None
|
|
101
|
+
return column == normal_value(column.type)
|
|
102
|
+
|
|
103
|
+
def _is_visible(self, obj: ModelType) -> bool:
|
|
104
|
+
"""对象是否未被软删除(过滤关闭 / 模型无该字段时恒为 True)"""
|
|
105
|
+
if not AuditConfig.filter_deleted():
|
|
106
|
+
return True
|
|
107
|
+
column = getattr(self.model, AuditConfig.fields()[DELETED], None)
|
|
108
|
+
if column is None:
|
|
109
|
+
return True
|
|
110
|
+
return getattr(obj, column.key, None) == normal_value(column.type)
|
|
75
111
|
|
|
76
112
|
def _id_col(self) -> Column[Any]:
|
|
77
113
|
"""约定的主键列(避免 SQLModel 泛型无 id 字段的类型缺陷)"""
|
|
@@ -81,7 +117,7 @@ class CRUDBase[
|
|
|
81
117
|
"""模型对应的 Table(避免 SQLModel 泛型无 __table__ 的类型缺陷)"""
|
|
82
118
|
return cast(Table, getattr(self.model, "__table__"))
|
|
83
119
|
|
|
84
|
-
def _column_names(self) -> dict[str, TypeEngine]:
|
|
120
|
+
def _column_names(self) -> dict[str, TypeEngine[Any]]:
|
|
85
121
|
"""模型映射表的「列名 → SQLAlchemy 类型对象」(读元数据,零 DB 查询)
|
|
86
122
|
|
|
87
123
|
供审计字段自动填充判断「表中是否存在该列」,以及 ``deleted`` 的取值类型。
|
|
@@ -91,17 +127,20 @@ class CRUDBase[
|
|
|
91
127
|
# ==================== 查询方法 ====================
|
|
92
128
|
|
|
93
129
|
def get(self, session: Session, id: Any) -> ModelType | None:
|
|
94
|
-
"""
|
|
130
|
+
"""根据主键获取单条记录(开启软删除过滤时隐藏已删除记录)"""
|
|
95
131
|
obj = session.get(self.model, id)
|
|
132
|
+
if obj is not None and not self._is_visible(obj):
|
|
133
|
+
logger.info(f"🔍 查询 {self.model.__name__} id={id} → 已软删除(过滤隐藏)")
|
|
134
|
+
return None
|
|
96
135
|
logger.info(
|
|
97
136
|
f"🔍 查询 {self.model.__name__} id={id} → {'命中' if obj else '未找到'}"
|
|
98
137
|
)
|
|
99
138
|
return obj
|
|
100
139
|
|
|
101
140
|
def get_or_raise(self, session: Session, id: Any) -> ModelType:
|
|
102
|
-
"""
|
|
141
|
+
"""根据主键获取记录,不存在(或已软删除)时抛出 NotFoundError"""
|
|
103
142
|
obj = session.get(self.model, id)
|
|
104
|
-
if obj is None:
|
|
143
|
+
if obj is None or not self._is_visible(obj):
|
|
105
144
|
logger.warning(
|
|
106
145
|
f"❌ {self.model.__name__} id={id} 不存在,抛出 NotFoundError"
|
|
107
146
|
)
|
|
@@ -271,19 +310,24 @@ class CRUDBase[
|
|
|
271
310
|
# ==================== 聚合查询 ====================
|
|
272
311
|
|
|
273
312
|
def count(self, session: Session) -> int:
|
|
274
|
-
"""
|
|
275
|
-
|
|
313
|
+
"""获取记录总数(开启软删除过滤时不计已删除记录)"""
|
|
314
|
+
statement = select(func.count()).select_from(self.model)
|
|
315
|
+
condition = self._deleted_condition()
|
|
316
|
+
if condition is not None:
|
|
317
|
+
statement = statement.where(condition)
|
|
318
|
+
total = session.exec(statement).one()
|
|
276
319
|
logger.info(f"🔍 统计 {self.model.__name__} 记录总数 → {total}")
|
|
277
320
|
return total
|
|
278
321
|
|
|
279
322
|
def exists(self, session: Session, id: Any) -> bool:
|
|
280
323
|
"""检查记录是否存在(仅查询主键列,不载入整对象)"""
|
|
281
324
|
id_col = self._id_col()
|
|
325
|
+
statement = select(id_col).where(id_col == id)
|
|
326
|
+
condition = self._deleted_condition()
|
|
327
|
+
if condition is not None:
|
|
328
|
+
statement = statement.where(condition)
|
|
282
329
|
found = (
|
|
283
|
-
session.exec(
|
|
284
|
-
cast(SelectOfScalar[object], select(id_col).where(id_col == id))
|
|
285
|
-
).first()
|
|
286
|
-
is not None
|
|
330
|
+
session.exec(cast(SelectOfScalar[object], statement)).first() is not None
|
|
287
331
|
)
|
|
288
332
|
logger.info(
|
|
289
333
|
f"🔍 检查 {self.model.__name__} id={id} 是否存在 → "
|
|
@@ -317,16 +361,18 @@ class CRUDBase[
|
|
|
317
361
|
raise DBError("upsert 必须提供 conflict_fields")
|
|
318
362
|
|
|
319
363
|
columns = self._column_names()
|
|
364
|
+
names = AuditConfig.fields()
|
|
320
365
|
|
|
321
366
|
# 更新集:排除冲突字段、主键,以及**只在 INSERT 填充**的 created_*
|
|
322
367
|
# (若把 created_* 带进 set_,每次冲突更新都会把创建时间刷成 now)
|
|
368
|
+
insert_only = (names[CREATED_AT], names[CREATED_BY])
|
|
323
369
|
pk_cols = {c.name for c in self._table().primary_key.columns}
|
|
324
370
|
update_cols = {
|
|
325
371
|
k: v
|
|
326
372
|
for k, v in data.items()
|
|
327
373
|
if k not in conflict_fields
|
|
328
374
|
and k not in pk_cols
|
|
329
|
-
and k not in
|
|
375
|
+
and k not in insert_only
|
|
330
376
|
}
|
|
331
377
|
if not update_cols:
|
|
332
378
|
# 无更新字段时退化为空更新(保持合法 SQL)
|
|
@@ -359,12 +405,13 @@ class CRUDBase[
|
|
|
359
405
|
else:
|
|
360
406
|
raise DBError(f"不支持的数据库类型: {dialect}")
|
|
361
407
|
|
|
362
|
-
# 按冲突字段回查返回实例(populate_existing
|
|
408
|
+
# 按冲突字段回查返回实例(populate_existing 强制刷新,避免身份映射返回旧值);
|
|
409
|
+
# 回查**必须跳过软删除过滤**:冲突行可能已被软删除,否则会误报回查失败
|
|
363
410
|
conditions = [
|
|
364
411
|
getattr(self.model, field) == data.get(field) for field in conflict_fields
|
|
365
412
|
]
|
|
366
413
|
re_stmt = (
|
|
367
|
-
self._select_all()
|
|
414
|
+
self._select_all(include_deleted=True)
|
|
368
415
|
.where(*conditions)
|
|
369
416
|
.execution_options(populate_existing=True)
|
|
370
417
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|