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.
Files changed (52) hide show
  1. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/PKG-INFO +1 -1
  2. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/__init__.py +1 -1
  3. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/audit.py +217 -44
  4. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/base_db_kit.py +15 -3
  5. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/crud.py +64 -17
  6. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/.gitignore +0 -0
  7. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/README.md +0 -0
  8. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/__init__.py +0 -0
  9. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/base_service.py +0 -0
  10. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/chat_service.py +0 -0
  11. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/embedding_service.py +0 -0
  12. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/exceptions.py +0 -0
  13. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/failover.py +0 -0
  14. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/pool.py +0 -0
  18. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/profiles.py +0 -0
  19. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/runner.py +0 -0
  21. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/common_kit.py +0 -0
  23. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/__init__.py +0 -0
  24. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/kit_config.py +0 -0
  25. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/config/log_config.py +0 -0
  26. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/__init__.py +0 -0
  27. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/article_models.py +0 -0
  28. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/web_article_kit.py +0 -0
  29. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/crawl/wx_article_kit.py +0 -0
  30. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/__init__.py +0 -0
  31. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/engine.py +0 -0
  32. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/exceptions.py +0 -0
  33. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/models/__init__.py +0 -0
  34. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/models/base.py +0 -0
  35. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/mysql_kit.py +0 -0
  36. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/pg_kit.py +0 -0
  37. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/query.py +0 -0
  38. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/session.py +0 -0
  39. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/sqlite_kit.py +0 -0
  40. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/db/transaction.py +0 -0
  41. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/__init__.py +0 -0
  42. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/anydoc_kit.py +0 -0
  43. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/docx_kit.py +0 -0
  44. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/marker_kit.py +0 -0
  45. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/md2doc_kit.py +0 -0
  46. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/mineru_kit.py +0 -0
  47. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/docs/rc_docparser_kit.py +0 -0
  48. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/file_kit.py +0 -0
  49. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/laya_kit.py +0 -0
  50. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/license_kit.py +0 -0
  51. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/kit/redis_kit.py +0 -0
  52. {sdpy_kit-0.5.2 → sdpy_kit-0.5.3}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.5.2"
26
+ __version__ = "0.5.3"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -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
- # 审计字段(与 kit/db/models/base.py::AuditMixin 命名一致)
82
- CREATED_AT = "created_at"
83
- CREATED_BY = "created_by"
84
- UPDATED_AT = "updated_at"
85
- UPDATED_BY = "updated_by"
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
- # 逻辑删除标记:0 正常 / 1 删除
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 _deleted_default(column_type: TypeEngine) -> object:
113
- """删除标记的正常值:字符串列(VARCHAR/CHAR/TEXT)填 "0",整数列填 0
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 isinstance(column_type, String):
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
- _listening = False
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(data: MutableMapping[str, object], columns: Mapping[str, TypeEngine]) -> None:
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
- _put(data, columns, CREATED_AT, now)
286
- _put(data, columns, UPDATED_AT, now)
287
- _put(data, columns, CREATED_BY, user)
288
- _put(data, columns, UPDATED_BY, user)
289
- if DELETED in columns:
290
- _put(data, columns, DELETED, _deleted_default(columns[DELETED]))
291
-
292
-
293
- def fill_update(data: MutableMapping[str, object], columns: Mapping[str, TypeEngine]) -> None:
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
- _put(data, columns, UPDATED_AT, now)
305
- _put(data, columns, UPDATED_BY, user)
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
- [(CREATED_AT, now), (UPDATED_AT, now), (CREATED_BY, user), (UPDATED_BY, user)]
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
- if insert and DELETED in columns:
348
- pairs.append((DELETED, _deleted_default(columns[DELETED])))
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 {column} = :value LIMIT 1",
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 CREATED_AT, CREATED_BY, fill_insert, fill_update
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
- return cast(SelectOfScalar[ModelType], select(self.model))
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
- """根据主键获取记录,不存在时抛出 NotFoundError"""
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
- total = session.exec(select(func.count()).select_from(self.model)).one()
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 (CREATED_AT, CREATED_BY)
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