sdpy-kit 0.5.1__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.1 → sdpy_kit-0.5.3}/PKG-INFO +1 -1
  2. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/__init__.py +4 -1
  3. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/__init__.py +3 -0
  4. sdpy_kit-0.5.3/kit/db/audit.py +555 -0
  5. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/base_db_kit.py +102 -6
  6. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/crud.py +89 -19
  7. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/.gitignore +0 -0
  8. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/README.md +0 -0
  9. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/__init__.py +0 -0
  10. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/base_service.py +0 -0
  11. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/chat_service.py +0 -0
  12. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/embedding_service.py +0 -0
  13. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/exceptions.py +0 -0
  14. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/failover.py +0 -0
  15. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/image_service.py +0 -0
  16. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/model_builder.py +0 -0
  17. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/model_client.py +0 -0
  18. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/pool.py +0 -0
  19. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/profiles.py +0 -0
  20. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/rerank_service.py +0 -0
  21. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/runner.py +0 -0
  22. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/types.py +0 -0
  23. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/log_config.py +0 -0
  27. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/__init__.py +0 -0
  28. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/article_models.py +0 -0
  29. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/web_article_kit.py +0 -0
  30. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/wx_article_kit.py +0 -0
  31. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/engine.py +0 -0
  32. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/exceptions.py +0 -0
  33. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/models/__init__.py +0 -0
  34. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/models/base.py +0 -0
  35. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/mysql_kit.py +0 -0
  36. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/pg_kit.py +0 -0
  37. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/query.py +0 -0
  38. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/session.py +0 -0
  39. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/sqlite_kit.py +0 -0
  40. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/transaction.py +0 -0
  41. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/__init__.py +0 -0
  42. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/anydoc_kit.py +0 -0
  43. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/docx_kit.py +0 -0
  44. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/marker_kit.py +0 -0
  45. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/md2doc_kit.py +0 -0
  46. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/mineru_kit.py +0 -0
  47. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/rc_docparser_kit.py +0 -0
  48. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/file_kit.py +0 -0
  49. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/laya_kit.py +0 -0
  50. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/license_kit.py +0 -0
  51. {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/redis_kit.py +0 -0
  52. {sdpy_kit-0.5.1 → 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.1
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,12 +23,13 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.5.1"
26
+ __version__ = "0.5.3"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
30
30
  POSTGRESQL,
31
31
  SQLITE,
32
+ AuditConfig,
32
33
  CRUDBase,
33
34
  DEFAULT_DISTANCE_TYPE,
34
35
  DBError,
@@ -84,6 +85,8 @@ __all__ = [
84
85
  "VectorNotSupportedError",
85
86
  # CRUD
86
87
  "CRUDBase",
88
+ # 审计字段与删除标记的自动填充(默认关闭,enable 后生效)
89
+ "AuditConfig",
87
90
  # 事务
88
91
  "transactional",
89
92
  # 原生 SQL 语句构造(公共)
@@ -29,6 +29,7 @@ Author: Clark Chang
29
29
  - SQLModel 混入类(models/base.py)
30
30
  """
31
31
 
32
+ from kit.db.audit import AuditConfig
32
33
  from kit.db.base_db_kit import (
33
34
  DEFAULT_DISTANCE_TYPE,
34
35
  generate_snowflake_ids,
@@ -86,6 +87,8 @@ __all__ = [
86
87
  "VectorNotSupportedError",
87
88
  # CRUD
88
89
  "CRUDBase",
90
+ # 审计字段与删除标记的自动填充(默认关闭,enable 后生效)
91
+ "AuditConfig",
89
92
  # 事务
90
93
  "transactional",
91
94
  # 原生 SQL 语句构造(公共)
@@ -0,0 +1,555 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-10-20
21
+ """审计字段与删除标记的自动填充(仿 MyBatis-Plus MetaObjectHandler)
22
+
23
+ 填充字段(固定,不做别名映射):
24
+
25
+ - ``created_at`` / ``created_by`` / ``updated_at`` / ``updated_by``(审计四件套)
26
+ - ``deleted``(逻辑删除标记:``0`` 正常 / ``1`` 删除)
27
+
28
+ 判定规则(唯一):**表中存在该列** 且 **本次写入数据中该字段为空** → 填充。
29
+ "空" 指 ``None`` / 空串 / 纯空白串(``0`` 与 ``False`` 不算空)。
30
+ 调用方显式给了非空值一律保留,**不存在覆盖语义**。
31
+
32
+ 填充矩阵:
33
+
34
+ ======== ============ ====================== ==========
35
+ 动作 created_* updated_* deleted
36
+ ======== ============ ====================== ==========
37
+ INSERT 空才填 空才填(与 created_* 同值) 空才填 0
38
+ UPDATE 不参与 空才填 不参与
39
+ ======== ============ ====================== ==========
40
+
41
+ "当前值" 指**本次写入的数据**,不是数据库已存值:
42
+
43
+ - dict 路径(``raw_insert`` / ``raw_update`` / ``CRUDBase.update_multi`` / ``upsert``):
44
+ 看 ``data`` 中该键的值;
45
+ - ORM 路径(``before_flush``):看本次 flush 是否显式给该属性赋值(属性变更历史)。
46
+
47
+ 因此业务没传 ``updated_at`` 时,每次更新都会刷新它。
48
+
49
+ 三条落点:
50
+
51
+ 1. ORM flush(``session.add`` / 属性修改 + flush)→ ``before_flush`` 事件;
52
+ 2. Core DML(``CRUDBase.update_multi`` / ``upsert``)→ 方法内填 dict;
53
+ 3. 原生构建器(``raw_insert`` / ``raw_update``)→ 构建 SQL 前填 dict。
54
+
55
+ ``execute()`` 手写 SQL 与批量 executemany **不填充**(不做 SQL 文本改写)。
56
+
57
+ 默认不启用:未调用 :meth:`AuditConfig.enable` 时,kit 所有写入口行为与本模块
58
+ 引入前完全一致(含 ``raw_insert`` 的 SQL 文本)。
59
+
60
+ 用法::
61
+
62
+ from kit.db import AuditConfig
63
+
64
+ AuditConfig.enable(user_resolver=get_current_userid) # 应用启动时调用一次
65
+ """
66
+
67
+ import threading
68
+ from collections.abc import Callable, Generator, Mapping, MutableMapping
69
+ from contextlib import contextmanager
70
+ from contextvars import ContextVar
71
+ from datetime import UTC, datetime
72
+ from typing import Any, ClassVar
73
+
74
+ from sqlalchemy import String, event, inspect as sa_inspect
75
+ from sqlalchemy.exc import NoSuchTableError
76
+ from sqlalchemy.orm import Session
77
+ from sqlalchemy.types import TypeEngine
78
+
79
+ from kit.config.log_config import get_default_logger
80
+ from kit.db.engine import get_engine
81
+
82
+ logger = get_default_logger(__name__)
83
+
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)
95
+ AUDIT_FIELDS: tuple[str, ...] = (CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY)
96
+
97
+ # 填充器实际关注的槽位(审计四件套 + 删除标记)
98
+ FILL_FIELDS: tuple[str, ...] = (*AUDIT_FIELDS, DELETED)
99
+
100
+ # 槽位 → 默认列名(默认同名;set_fields 只覆盖发生变化的槽位)
101
+ DEFAULT_FIELDS: dict[str, str] = {slot: slot for slot in FILL_FIELDS}
102
+
103
+ # 列探测缓存:(db_type, 表名) -> {列名: SQLAlchemy 类型对象}
104
+ # 仅缓存**探测成功**的结果;失败不缓存,表稍后创建可自愈
105
+ _columns_cache: dict[tuple[str, str], dict[str, TypeEngine[Any]]] = {}
106
+ _columns_lock = threading.Lock()
107
+
108
+ # 「查已删除」上下文开关:进入 AuditConfig.include_deleted() 后临时关闭过滤
109
+ _include_deleted: ContextVar[bool] = ContextVar("audit_include_deleted", default=False)
110
+
111
+
112
+ def _default_clock() -> datetime:
113
+ """默认时钟:UTC aware(与 models/base.py::_utcnow 同口径)"""
114
+ return datetime.now(UTC)
115
+
116
+
117
+ def _is_empty(value: object) -> bool:
118
+ """是否为「空」:None / 空串 / 纯空白串(0 与 False 不算空)"""
119
+ if value is None:
120
+ return True
121
+ return isinstance(value, str) and not value.strip()
122
+
123
+
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``
148
+
149
+ 写入填充与查询过滤共用同一个值,避免两侧口径不一致。
150
+ 字符串列写整数在 PostgreSQL(``character varying``)与 MySQL 严格模式下
151
+ 会直接报类型错误,故按列类型区分。
152
+
153
+ ⚠ 内部符号(不进 ``kit.db.__all__``)。
154
+ """
155
+ return "0" if _is_string_type(column_type) else 0
156
+
157
+
158
+ class AuditConfig:
159
+ """审计字段自动填充的进程级配置(默认关闭)
160
+
161
+ 进程级类属性 + 线程锁;``enable`` / ``disable`` 属启动期一次性调用,
162
+ 不做运行期热切换承诺。模块导入零副作用。
163
+ """
164
+
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
172
+
173
+ @classmethod
174
+ def enable(
175
+ cls,
176
+ *,
177
+ user_resolver: Callable[[], object] | None = None,
178
+ clock: Callable[[], datetime] | None = None,
179
+ fields: Mapping[str, str] | None = None,
180
+ soft_delete_filter: bool | None = None,
181
+ ) -> None:
182
+ """启用自动填充(幂等)
183
+
184
+ Args:
185
+ user_resolver: 返回当前操作人的钩子(str 或 int,与列类型一致)。
186
+ 返回 None / 空串时跳过 ``*_by`` 填充;抛异常时记 warning 并跳过,
187
+ 不影响写入。
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 = 保持当前设置;默认关闭)。
194
+
195
+ Note:
196
+ 事件监听挂在父类 :class:`sqlalchemy.orm.Session` 上,因此
197
+ ``session_scope`` 新建的 Session 与应用自建的原生 Session 都会触发。
198
+ """
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 会误判为方法绑定,运行时正确
205
+ if user_resolver is not None:
206
+ cls._user_resolver = user_resolver # pyright: ignore[reportAttributeAccessIssue]
207
+ if clock is not None:
208
+ cls._clock = clock # pyright: ignore[reportAttributeAccessIssue]
209
+ if not cls._listening:
210
+ event.listen(Session, "before_flush", _on_before_flush)
211
+ cls._listening = True
212
+ cls._enabled = True
213
+ logger.info("✅ 审计字段与删除标记自动填充已启用")
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
+
295
+ @classmethod
296
+ def disable(cls) -> None:
297
+ """关闭自动填充(保留 user_resolver / clock 配置,再次 enable 可沿用)"""
298
+ with cls._lock:
299
+ cls._enabled = False
300
+ if cls._listening:
301
+ event.remove(Session, "before_flush", _on_before_flush)
302
+ cls._listening = False
303
+ logger.info("🔌 审计字段与删除标记自动填充已关闭")
304
+
305
+ @classmethod
306
+ def is_enabled(cls) -> bool:
307
+ """当前是否启用"""
308
+ return cls._enabled
309
+
310
+ @classmethod
311
+ def now(cls) -> datetime:
312
+ """当前时间(UTC aware;clock 异常时记 warning 并回退 UTC now)"""
313
+ try:
314
+ return cls._clock() # pyright: ignore[reportAttributeAccessIssue]
315
+ except Exception as e:
316
+ logger.warning(f"⚠️ 审计时钟调用失败,回退 UTC now: {e}")
317
+ return datetime.now(UTC)
318
+
319
+ @classmethod
320
+ def current_user(cls) -> object | None:
321
+ """当前操作人(resolver 未注册或异常时返回 None)"""
322
+ resolver = cls._user_resolver # pyright: ignore[reportAttributeAccessIssue]
323
+ if resolver is None:
324
+ return None
325
+ try:
326
+ return resolver()
327
+ except Exception as e:
328
+ logger.warning(f"⚠️ user_resolver 调用失败,跳过 *_by 填充: {e}")
329
+ return None
330
+
331
+ @classmethod
332
+ def clear_columns_cache(cls, *tables: str) -> None:
333
+ """清空列探测缓存(表结构变更后调用;不传参清空全部)
334
+
335
+ Args:
336
+ tables: 表名(可多个);不传则清空全部缓存
337
+ """
338
+ with _columns_lock:
339
+ if not tables:
340
+ _columns_cache.clear()
341
+ return
342
+ targets = set(tables)
343
+ for key in [k for k in _columns_cache if k[1] in targets]:
344
+ del _columns_cache[key]
345
+
346
+
347
+ # ==================== 列探测(原生路径) ====================
348
+
349
+
350
+ def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine[Any]]:
351
+ """表「列名 → SQLAlchemy 类型对象」映射(仅缓存探测成功的结果)
352
+
353
+ Args:
354
+ db_type: 数据库类型(postgresql / mysql / sqlite)
355
+ table_name: 表名(非限定名,按当前 search_path / database 解析)
356
+
357
+ Returns:
358
+ 列名到类型对象的映射;表不存在或探测失败时返回**空字典**(= 不填充),
359
+ 且不写入缓存(下次调用重新探测,表稍后创建可自愈)
360
+
361
+ Note:
362
+ 保留类型对象而非类型名:``deleted`` 的取值需要 ``isinstance`` 判定
363
+ (``String`` 家族 → ``"0"``,其余 → ``0``)。
364
+ """
365
+ key = (db_type, table_name)
366
+ with _columns_lock:
367
+ cached = _columns_cache.get(key)
368
+ if cached is not None:
369
+ return cached
370
+
371
+ try:
372
+ inspector = sa_inspect(get_engine(db_type))
373
+ columns = {col["name"]: col["type"] for col in inspector.get_columns(table_name)}
374
+ except NoSuchTableError:
375
+ logger.debug(f"表 {table_name} 不存在,跳过审计字段填充")
376
+ return {}
377
+ except Exception as e:
378
+ logger.warning(f"⚠️ 列探测失败({db_type}:{table_name}),跳过填充: {e}")
379
+ return {}
380
+
381
+ with _columns_lock:
382
+ _columns_cache[key] = columns
383
+ return columns
384
+
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
+
414
+ # ==================== dict 路径填充(Core DML / 原生构建器) ====================
415
+
416
+
417
+ def _put(
418
+ data: MutableMapping[str, object],
419
+ columns: Mapping[str, TypeEngine[Any]],
420
+ name: str,
421
+ value: object,
422
+ ) -> None:
423
+ """按唯一规则写一个字段:有列 + 填充值非空 + 当前值为空"""
424
+ if name not in columns or _is_empty(value):
425
+ return
426
+ if not _is_empty(data.get(name)):
427
+ return
428
+ data[name] = value
429
+
430
+
431
+ def fill_insert(
432
+ data: MutableMapping[str, object], columns: Mapping[str, TypeEngine[Any]]
433
+ ) -> None:
434
+ """INSERT 半区:created_* 与 updated_* 同值填补,deleted 补正常值
435
+
436
+ Args:
437
+ data: 待写入的列名 → 值(**原地修改**,调用方应传入副本)
438
+ columns: 表列名 → 类型对象(为空表示表无审计列,不填)
439
+ """
440
+ if not AuditConfig.is_enabled():
441
+ return
442
+ now = AuditConfig.now()
443
+ user = AuditConfig.current_user()
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:
457
+ """UPDATE 半区:只动 updated_*(不碰 created_*,也不碰 deleted)
458
+
459
+ Args:
460
+ data: 待更新的列名 → 值(**原地修改**,调用方应传入副本)
461
+ columns: 表列名 → 类型对象
462
+ """
463
+ if not AuditConfig.is_enabled():
464
+ return
465
+ now = AuditConfig.now()
466
+ user = AuditConfig.current_user()
467
+ names = AuditConfig._fields
468
+ _put(data, columns, names[UPDATED_AT], now)
469
+ _put(data, columns, names[UPDATED_BY], user)
470
+
471
+
472
+ def fill_insert_for(
473
+ db_type: str, table_name: str, data: MutableMapping[str, object]
474
+ ) -> None:
475
+ """原生路径的 INSERT 入口:**先判启用再探测**(未启用时零额外查询)"""
476
+ if not AuditConfig.is_enabled():
477
+ return
478
+ fill_insert(data, table_columns(db_type, table_name))
479
+
480
+
481
+ def fill_update_for(
482
+ db_type: str, table_name: str, data: MutableMapping[str, object]
483
+ ) -> None:
484
+ """原生路径的 UPDATE 入口:**先判启用再探测**(未启用时零额外查询)"""
485
+ if not AuditConfig.is_enabled():
486
+ return
487
+ fill_update(data, table_columns(db_type, table_name))
488
+
489
+
490
+ # ==================== ORM 路径填充(before_flush) ====================
491
+
492
+
493
+ def _object_columns(obj: object) -> dict[str, TypeEngine[Any]]:
494
+ """对象映射到的「表列名 → 类型对象」(无 ``__table__`` 时返回空字典)"""
495
+ table = getattr(obj, "__table__", None)
496
+ if table is None:
497
+ return {}
498
+ return {column.name: column.type for column in table.columns}
499
+
500
+
501
+ def _fill_obj(
502
+ obj: object, *, now: datetime, user: object | None, insert: bool
503
+ ) -> None:
504
+ """按矩阵填充单个 ORM 对象(列不存在 / 已有非空值 → 跳过)"""
505
+ columns = _object_columns(obj)
506
+ names = AuditConfig._fields
507
+ pairs: list[tuple[str, object]] = (
508
+ [
509
+ (names[CREATED_AT], now),
510
+ (names[UPDATED_AT], now),
511
+ (names[CREATED_BY], user),
512
+ (names[UPDATED_BY], user),
513
+ ]
514
+ if insert
515
+ else [(names[UPDATED_AT], now), (names[UPDATED_BY], user)]
516
+ )
517
+ deleted_col = names[DELETED]
518
+ if insert and deleted_col in columns:
519
+ pairs.append((deleted_col, normal_value(columns[deleted_col])))
520
+
521
+ state = sa_inspect(obj)
522
+ if state is None: # 理论上实例一定有状态,防御性判断
523
+ return
524
+ for name, value in pairs:
525
+ if name not in columns or _is_empty(value) or not hasattr(obj, name):
526
+ continue
527
+ if name not in state.attrs:
528
+ continue
529
+ current = getattr(obj, name, None)
530
+ if insert:
531
+ # INSERT:属性当前值非空 → 调用方已指定
532
+ if not _is_empty(current):
533
+ continue
534
+ elif state.attrs[name].history.has_changes() and not _is_empty(current):
535
+ # UPDATE:本次显式赋了非空值 → 保留;未赋值 / 赋空 → 填充
536
+ continue
537
+ setattr(obj, name, value)
538
+
539
+
540
+ def _on_before_flush(
541
+ session: Session, flush_context: object, instances: object
542
+ ) -> None:
543
+ """``before_flush`` 钩子:INSERT 走 new 集合,UPDATE 走 dirty 集合
544
+
545
+ 时间与用户在一次 flush 内只取一次,同批对象取值一致。
546
+ """
547
+ if not AuditConfig.is_enabled():
548
+ return
549
+ now = AuditConfig.now()
550
+ user = AuditConfig.current_user()
551
+ for obj in session.new:
552
+ _fill_obj(obj, now=now, user=user, insert=True)
553
+ for obj in session.dirty:
554
+ if session.is_modified(obj, include_collections=False):
555
+ _fill_obj(obj, now=now, user=user, insert=False)
@@ -56,6 +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 deleted_predicate, fill_insert_for, fill_update_for
59
60
  from kit.db.crud import CRUDBase
60
61
  from kit.db.engine import (
61
62
  close_engine,
@@ -144,6 +145,18 @@ _RESERVED_BIND_NAMES = frozenset({"id", "vec"})
144
145
  _BIND_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
145
146
 
146
147
 
148
+ def _check_identifier(name: str) -> None:
149
+ """校验表名 / 列名为合法标识符(拼接进 SQL 前的白名单)
150
+
151
+ Raises:
152
+ ValueError: 含字母、数字、下划线以外的字符,或以数字开头
153
+ """
154
+ if not _BIND_NAME_RE.match(name):
155
+ raise ValueError(
156
+ f"{name!r} 不是合法标识符(表名/列名仅允许字母、数字、下划线)"
157
+ )
158
+
159
+
147
160
  def _check_extra_key(key: str, id_col: str, column: str) -> None:
148
161
  """校验 ``extra_data`` 的键可作为绑定参数名(拼装前抛出可操作的 ``ValueError``)
149
162
 
@@ -806,16 +819,87 @@ class BaseDbKit(ABC):
806
819
  """向指定表插入一行数据(键为列名)
807
820
 
808
821
  Args:
809
- table_name: 表名
822
+ table_name: 表名(仅接受合法标识符)
810
823
  data: 列名 → 值 字典
811
824
 
812
825
  Returns:
813
826
  受影响的行数
827
+
828
+ Note:
829
+ 调用 ``AuditConfig.enable()`` 后,若表存在 ``created_at`` / ``created_by``
830
+ / ``updated_at`` / ``updated_by`` / ``deleted`` 列,且 ``data`` 中该键缺失
831
+ 或为空,则自动补齐;显式非空值一律保留。未启用时行为与引入前完全一致。
832
+
833
+ Raises:
834
+ ValueError: 表名不是合法标识符
814
835
  """
815
- columns = ", ".join(data.keys())
816
- placeholders = ", ".join(f":{name}" for name in data)
836
+ _check_identifier(table_name)
837
+ payload = dict(data) # 副本:不污染调用方 dict
838
+ fill_insert_for(cls.DB_TYPE, table_name, payload)
839
+ columns = ", ".join(payload.keys())
840
+ placeholders = ", ".join(f":{name}" for name in payload)
817
841
  sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
818
- return cls.execute(sql, dict(data))
842
+ return cls.execute(sql, payload)
843
+
844
+ @classmethod
845
+ def raw_update(
846
+ cls,
847
+ table_name: str,
848
+ data: dict[str, object],
849
+ where: str,
850
+ params: dict[str, object] | None = None,
851
+ ) -> int:
852
+ """按 where 条件更新数据(自动补 updated_*,需启用自动填充)
853
+
854
+ Args:
855
+ table_name: 表名(仅接受合法标识符)
856
+ data: 列名 → 新值(业务字段;``updated_*`` 由填充器补)
857
+ where: WHERE 子句(**不含** ``WHERE`` 关键字,如 ``"id = :id"``)
858
+ params: where 子句的绑定参数(键名不得与 ``data`` 的键重复)
859
+
860
+ Returns:
861
+ 受影响的行数
862
+
863
+ Raises:
864
+ ValueError: ``data`` 为空 / ``where`` 为空 / 标识符非法 /
865
+ ``data`` 与 ``params`` 的绑定名冲突
866
+
867
+ Note:
868
+ - 无 WHERE 的全表更新属危险操作,请显式用 ``execute()`` 书写。
869
+ - ``where`` 中出现的 ``:name`` 必须由 ``params`` 提供,否则由
870
+ SQLAlchemy 抛 ``A value is required for bind parameter``。
871
+ - 未启用自动填充时,``data`` 原样写入(不加 ``updated_*``)。
872
+
873
+ 用法::
874
+
875
+ PgKit.raw_update(
876
+ "writing_project",
877
+ {"title": "新标题"},
878
+ "id = :id AND created_by = :owner",
879
+ {"id": 123, "owner": "admin"},
880
+ )
881
+ """
882
+ if not data:
883
+ raise ValueError("raw_update 的 data 不能为空")
884
+ if not where or not where.strip():
885
+ raise ValueError("raw_update 必须提供非空 where 子句")
886
+ _check_identifier(table_name)
887
+ for name in data:
888
+ _check_identifier(name)
889
+
890
+ payload = dict(data) # 副本:不污染调用方 dict
891
+ fill_update_for(cls.DB_TYPE, table_name, payload)
892
+ assignments = ", ".join(f"{name} = :{name}" for name in payload)
893
+
894
+ bound = dict(params or {})
895
+ conflict = set(payload) & set(bound)
896
+ if conflict:
897
+ raise ValueError(
898
+ f"data 与 params 的绑定名冲突: {sorted(conflict)};请改用其它参数名"
899
+ )
900
+ bound.update(payload)
901
+ sql = f"UPDATE {table_name} SET {assignments} WHERE {where}"
902
+ return cls.execute(sql, bound)
819
903
 
820
904
  @classmethod
821
905
  def raw_get_by(
@@ -830,10 +914,22 @@ class BaseDbKit(ABC):
830
914
 
831
915
  Returns:
832
916
  匹配的第一行(字典);无匹配返回 None
917
+
918
+ Note:
919
+ 开启软删除查询过滤(`AuditConfig.set_soft_delete_filter(True)`)后,
920
+ 若表存在 deleted 列,会自动追加 ``deleted = 正常值`` 条件;
921
+ 需读取已删除行时用 ``with AuditConfig.include_deleted():`` 包裹。
833
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
+
834
931
  rows = cls.query(
835
- f"SELECT * FROM {table_name} WHERE {column} = :value LIMIT 1",
836
- {"value": value},
932
+ f"SELECT * FROM {table_name} WHERE {where} LIMIT 1", params
837
933
  )
838
934
  return rows[0] if rows else None
839
935
 
@@ -36,10 +36,20 @@ from sqlalchemy import Column, Table, delete, func, update
36
36
  from sqlalchemy.dialects import mysql as mysql_dialect
37
37
  from sqlalchemy.dialects import postgresql as pg_dialect
38
38
  from sqlalchemy.dialects import sqlite as sqlite_dialect
39
+ from sqlalchemy.types import TypeEngine
39
40
  from sqlmodel import Session, SQLModel, select
40
41
  from sqlmodel.sql._expression_select_cls import SelectOfScalar
41
42
 
42
43
  from kit.config.log_config import get_default_logger
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
+ )
43
53
  from kit.db.exceptions import DBError, NotFoundError
44
54
 
45
55
  logger = get_default_logger(__name__)
@@ -67,9 +77,37 @@ class CRUDBase[
67
77
  def __init__(self, model: type[ModelType]):
68
78
  self.model = model
69
79
 
70
- def _select_all(self) -> SelectOfScalar[ModelType]:
71
- """返回 sqlmodel.select 的标量查询(SQLModel 类型桥接)"""
72
- 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)
73
111
 
74
112
  def _id_col(self) -> Column[Any]:
75
113
  """约定的主键列(避免 SQLModel 泛型无 id 字段的类型缺陷)"""
@@ -79,20 +117,30 @@ class CRUDBase[
79
117
  """模型对应的 Table(避免 SQLModel 泛型无 __table__ 的类型缺陷)"""
80
118
  return cast(Table, getattr(self.model, "__table__"))
81
119
 
120
+ def _column_names(self) -> dict[str, TypeEngine[Any]]:
121
+ """模型映射表的「列名 → SQLAlchemy 类型对象」(读元数据,零 DB 查询)
122
+
123
+ 供审计字段自动填充判断「表中是否存在该列」,以及 ``deleted`` 的取值类型。
124
+ """
125
+ return {column.name: column.type for column in self._table().columns}
126
+
82
127
  # ==================== 查询方法 ====================
83
128
 
84
129
  def get(self, session: Session, id: Any) -> ModelType | None:
85
- """根据主键获取单条记录"""
130
+ """根据主键获取单条记录(开启软删除过滤时隐藏已删除记录)"""
86
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
87
135
  logger.info(
88
136
  f"🔍 查询 {self.model.__name__} id={id} → {'命中' if obj else '未找到'}"
89
137
  )
90
138
  return obj
91
139
 
92
140
  def get_or_raise(self, session: Session, id: Any) -> ModelType:
93
- """根据主键获取记录,不存在时抛出 NotFoundError"""
141
+ """根据主键获取记录,不存在(或已软删除)时抛出 NotFoundError"""
94
142
  obj = session.get(self.model, id)
95
- if obj is None:
143
+ if obj is None or not self._is_visible(obj):
96
144
  logger.warning(
97
145
  f"❌ {self.model.__name__} id={id} 不存在,抛出 NotFoundError"
98
146
  )
@@ -219,12 +267,17 @@ class CRUDBase[
219
267
  ids: list[Any],
220
268
  obj_in: UpdateSchemaType | dict[str, Any],
221
269
  ) -> int:
222
- """批量更新记录(单条 UPDATE SQL,不 commit)"""
223
- update_data = (
270
+ """批量更新记录(单条 UPDATE SQL,不 commit)
271
+
272
+ 启用审计填充时自动补 ``updated_at`` / ``updated_by``
273
+ (不碰 ``created_*`` 与 ``deleted``)。
274
+ """
275
+ update_data = dict(
224
276
  obj_in
225
277
  if isinstance(obj_in, dict)
226
278
  else obj_in.model_dump(exclude_unset=True)
227
279
  )
280
+ fill_update(update_data, self._column_names())
228
281
  id_col = self._id_col()
229
282
  statement = update(self.model).where(id_col.in_(ids)).values(**update_data)
230
283
  result = session.execute(statement)
@@ -257,19 +310,24 @@ class CRUDBase[
257
310
  # ==================== 聚合查询 ====================
258
311
 
259
312
  def count(self, session: Session) -> int:
260
- """获取记录总数"""
261
- 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()
262
319
  logger.info(f"🔍 统计 {self.model.__name__} 记录总数 → {total}")
263
320
  return total
264
321
 
265
322
  def exists(self, session: Session, id: Any) -> bool:
266
323
  """检查记录是否存在(仅查询主键列,不载入整对象)"""
267
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)
268
329
  found = (
269
- session.exec(
270
- cast(SelectOfScalar[object], select(id_col).where(id_col == id))
271
- ).first()
272
- is not None
330
+ session.exec(cast(SelectOfScalar[object], statement)).first() is not None
273
331
  )
274
332
  logger.info(
275
333
  f"🔍 检查 {self.model.__name__} id={id} 是否存在 → "
@@ -294,7 +352,7 @@ class CRUDBase[
294
352
  obj_in: 创建/更新数据
295
353
  conflict_fields: 用于判断冲突的字段列表(需为唯一约束)
296
354
  """
297
- data = (
355
+ data = dict(
298
356
  obj_in
299
357
  if isinstance(obj_in, dict)
300
358
  else obj_in.model_dump(exclude_unset=True)
@@ -302,17 +360,28 @@ class CRUDBase[
302
360
  if not conflict_fields:
303
361
  raise DBError("upsert 必须提供 conflict_fields")
304
362
 
305
- # 更新集:排除冲突字段与主键(避免覆盖主键/冲突键)
363
+ columns = self._column_names()
364
+ names = AuditConfig.fields()
365
+
366
+ # 更新集:排除冲突字段、主键,以及**只在 INSERT 填充**的 created_*
367
+ # (若把 created_* 带进 set_,每次冲突更新都会把创建时间刷成 now)
368
+ insert_only = (names[CREATED_AT], names[CREATED_BY])
306
369
  pk_cols = {c.name for c in self._table().primary_key.columns}
307
370
  update_cols = {
308
371
  k: v
309
372
  for k, v in data.items()
310
- if k not in conflict_fields and k not in pk_cols
373
+ if k not in conflict_fields
374
+ and k not in pk_cols
375
+ and k not in insert_only
311
376
  }
312
377
  if not update_cols:
313
378
  # 无更新字段时退化为空更新(保持合法 SQL)
314
379
  update_cols = {conflict_fields[0]: data[conflict_fields[0]]}
315
380
 
381
+ # INSERT 半区补 created_*/updated_*/deleted;UPDATE 半区补 updated_*
382
+ fill_insert(data, columns)
383
+ fill_update(update_cols, columns)
384
+
316
385
  # 各数据库方言的 INSERT ... ON CONFLICT / ON DUPLICATE KEY(原子单语句)
317
386
  dialect = session.get_bind().dialect.name
318
387
  if dialect == "postgresql":
@@ -336,12 +405,13 @@ class CRUDBase[
336
405
  else:
337
406
  raise DBError(f"不支持的数据库类型: {dialect}")
338
407
 
339
- # 按冲突字段回查返回实例(populate_existing 强制刷新,避免身份映射返回旧值)
408
+ # 按冲突字段回查返回实例(populate_existing 强制刷新,避免身份映射返回旧值);
409
+ # 回查**必须跳过软删除过滤**:冲突行可能已被软删除,否则会误报回查失败
340
410
  conditions = [
341
411
  getattr(self.model, field) == data.get(field) for field in conflict_fields
342
412
  ]
343
413
  re_stmt = (
344
- self._select_all()
414
+ self._select_all(include_deleted=True)
345
415
  .where(*conditions)
346
416
  .execution_options(populate_existing=True)
347
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