aury-boot 0.0.4__py3-none-any.whl → 0.0.5__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.
Files changed (98) hide show
  1. aury/boot/__init__.py +2 -2
  2. aury/boot/_version.py +2 -2
  3. aury/boot/application/__init__.py +45 -36
  4. aury/boot/application/app/__init__.py +12 -8
  5. aury/boot/application/app/base.py +12 -0
  6. aury/boot/application/app/components.py +137 -44
  7. aury/boot/application/app/middlewares.py +2 -0
  8. aury/boot/application/app/startup.py +249 -0
  9. aury/boot/application/config/__init__.py +36 -1
  10. aury/boot/application/config/multi_instance.py +200 -0
  11. aury/boot/application/config/settings.py +341 -12
  12. aury/boot/application/constants/components.py +6 -0
  13. aury/boot/application/errors/handlers.py +17 -3
  14. aury/boot/application/middleware/logging.py +8 -120
  15. aury/boot/application/rpc/__init__.py +2 -2
  16. aury/boot/commands/__init__.py +30 -10
  17. aury/boot/commands/app.py +131 -1
  18. aury/boot/commands/docs.py +104 -17
  19. aury/boot/commands/init.py +27 -8
  20. aury/boot/commands/server/app.py +2 -3
  21. aury/boot/commands/templates/project/AGENTS.md.tpl +217 -0
  22. aury/boot/commands/templates/project/README.md.tpl +2 -2
  23. aury/boot/commands/templates/project/aury_docs/00-overview.md.tpl +59 -0
  24. aury/boot/commands/templates/project/aury_docs/01-model.md.tpl +183 -0
  25. aury/boot/commands/templates/project/aury_docs/02-repository.md.tpl +206 -0
  26. aury/boot/commands/templates/project/aury_docs/03-service.md.tpl +398 -0
  27. aury/boot/commands/templates/project/aury_docs/04-schema.md.tpl +95 -0
  28. aury/boot/commands/templates/project/aury_docs/05-api.md.tpl +116 -0
  29. aury/boot/commands/templates/project/aury_docs/06-exception.md.tpl +118 -0
  30. aury/boot/commands/templates/project/aury_docs/07-cache.md.tpl +122 -0
  31. aury/boot/commands/templates/project/aury_docs/08-scheduler.md.tpl +32 -0
  32. aury/boot/commands/templates/project/aury_docs/09-tasks.md.tpl +38 -0
  33. aury/boot/commands/templates/project/aury_docs/10-storage.md.tpl +115 -0
  34. aury/boot/commands/templates/project/aury_docs/11-logging.md.tpl +92 -0
  35. aury/boot/commands/templates/project/aury_docs/12-admin.md.tpl +56 -0
  36. aury/boot/commands/templates/project/aury_docs/13-channel.md.tpl +92 -0
  37. aury/boot/commands/templates/project/aury_docs/14-mq.md.tpl +102 -0
  38. aury/boot/commands/templates/project/aury_docs/15-events.md.tpl +147 -0
  39. aury/boot/commands/templates/project/config.py.tpl +1 -1
  40. aury/boot/commands/templates/project/env.example.tpl +73 -5
  41. aury/boot/commands/templates/project/modules/tasks.py.tpl +1 -1
  42. aury/boot/contrib/admin_console/auth.py +2 -3
  43. aury/boot/contrib/admin_console/install.py +1 -1
  44. aury/boot/domain/models/mixins.py +48 -1
  45. aury/boot/domain/pagination/__init__.py +94 -0
  46. aury/boot/domain/repository/impl.py +1 -1
  47. aury/boot/domain/repository/interface.py +1 -1
  48. aury/boot/domain/transaction/__init__.py +8 -9
  49. aury/boot/infrastructure/__init__.py +86 -29
  50. aury/boot/infrastructure/cache/backends.py +102 -18
  51. aury/boot/infrastructure/cache/base.py +12 -0
  52. aury/boot/infrastructure/cache/manager.py +153 -91
  53. aury/boot/infrastructure/channel/__init__.py +24 -0
  54. aury/boot/infrastructure/channel/backends/__init__.py +9 -0
  55. aury/boot/infrastructure/channel/backends/memory.py +83 -0
  56. aury/boot/infrastructure/channel/backends/redis.py +88 -0
  57. aury/boot/infrastructure/channel/base.py +92 -0
  58. aury/boot/infrastructure/channel/manager.py +203 -0
  59. aury/boot/infrastructure/clients/__init__.py +22 -0
  60. aury/boot/infrastructure/clients/rabbitmq/__init__.py +9 -0
  61. aury/boot/infrastructure/clients/rabbitmq/config.py +46 -0
  62. aury/boot/infrastructure/clients/rabbitmq/manager.py +288 -0
  63. aury/boot/infrastructure/clients/redis/__init__.py +28 -0
  64. aury/boot/infrastructure/clients/redis/config.py +51 -0
  65. aury/boot/infrastructure/clients/redis/manager.py +264 -0
  66. aury/boot/infrastructure/database/config.py +1 -2
  67. aury/boot/infrastructure/database/manager.py +16 -38
  68. aury/boot/infrastructure/events/__init__.py +18 -21
  69. aury/boot/infrastructure/events/backends/__init__.py +11 -0
  70. aury/boot/infrastructure/events/backends/memory.py +86 -0
  71. aury/boot/infrastructure/events/backends/rabbitmq.py +193 -0
  72. aury/boot/infrastructure/events/backends/redis.py +162 -0
  73. aury/boot/infrastructure/events/base.py +127 -0
  74. aury/boot/infrastructure/events/manager.py +224 -0
  75. aury/boot/infrastructure/mq/__init__.py +24 -0
  76. aury/boot/infrastructure/mq/backends/__init__.py +9 -0
  77. aury/boot/infrastructure/mq/backends/rabbitmq.py +179 -0
  78. aury/boot/infrastructure/mq/backends/redis.py +167 -0
  79. aury/boot/infrastructure/mq/base.py +143 -0
  80. aury/boot/infrastructure/mq/manager.py +239 -0
  81. aury/boot/infrastructure/scheduler/manager.py +7 -3
  82. aury/boot/infrastructure/storage/__init__.py +9 -9
  83. aury/boot/infrastructure/storage/base.py +17 -5
  84. aury/boot/infrastructure/storage/factory.py +0 -1
  85. aury/boot/infrastructure/tasks/__init__.py +2 -2
  86. aury/boot/infrastructure/tasks/manager.py +47 -29
  87. aury/boot/testing/base.py +2 -2
  88. {aury_boot-0.0.4.dist-info → aury_boot-0.0.5.dist-info}/METADATA +19 -2
  89. aury_boot-0.0.5.dist-info/RECORD +176 -0
  90. aury/boot/commands/templates/project/DEVELOPMENT.md.tpl +0 -1397
  91. aury/boot/infrastructure/events/bus.py +0 -362
  92. aury/boot/infrastructure/events/config.py +0 -52
  93. aury/boot/infrastructure/events/consumer.py +0 -134
  94. aury/boot/infrastructure/events/models.py +0 -63
  95. aury_boot-0.0.4.dist-info/RECORD +0 -137
  96. /aury/boot/commands/templates/project/{CLI.md.tpl → aury_docs/99-cli.md.tpl} +0 -0
  97. {aury_boot-0.0.4.dist-info → aury_boot-0.0.5.dist-info}/WHEEL +0 -0
  98. {aury_boot-0.0.4.dist-info → aury_boot-0.0.5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,102 @@
1
+ # 消息队列(MQ)
2
+
3
+ 支持 `redis` 和 `rabbitmq` 后端的消息队列,用于异步任务解耦。
4
+
5
+ ## 14.1 基本用法
6
+
7
+ ```python
8
+ from aury.boot.infrastructure.mq import MQManager
9
+
10
+ # 获取实例
11
+ mq = MQManager.get_instance()
12
+
13
+ # Redis 后端
14
+ await mq.initialize(backend="redis", url="redis://localhost:6379/0")
15
+
16
+ # RabbitMQ 后端
17
+ await mq.initialize(backend="rabbitmq", url="amqp://guest:guest@localhost:5672/")
18
+ ```
19
+
20
+ ## 14.2 生产者
21
+
22
+ ```python
23
+ # 发送消息
24
+ await mq.publish(
25
+ queue="orders",
26
+ message={{"order_id": "123", "action": "created"}}
27
+ )
28
+
29
+ # 批量发送
30
+ await mq.publish_batch(
31
+ queue="orders",
32
+ messages=[
33
+ {{"order_id": "1", "action": "created"}},
34
+ {{"order_id": "2", "action": "updated"}},
35
+ ]
36
+ )
37
+ ```
38
+
39
+ ## 14.3 消费者
40
+
41
+ **文件**: `{package_name}/workers/order_worker.py`
42
+
43
+ ```python
44
+ from aury.boot.infrastructure.mq import MQManager
45
+ from aury.boot.common.logging import logger
46
+
47
+ mq = MQManager.get_instance()
48
+
49
+
50
+ async def process_order(message: dict):
51
+ \"\"\"处理订单消息。\"\"\"
52
+ logger.info(f"处理订单: {{message['order_id']}}")
53
+ # 业务逻辑...
54
+
55
+
56
+ async def start_consumer():
57
+ \"\"\"启动消费者。\"\"\"
58
+ await mq.consume("orders", process_order)
59
+
60
+
61
+ # 带确认的消费
62
+ async def process_with_ack(message: dict, ack, nack):
63
+ try:
64
+ await process_order(message)
65
+ await ack()
66
+ except Exception:
67
+ await nack(requeue=True)
68
+
69
+ await mq.consume("orders", process_with_ack, auto_ack=False)
70
+ ```
71
+
72
+ ## 14.4 多实例
73
+
74
+ ```python
75
+ # 不同用途的 MQ 实例
76
+ orders_mq = MQManager.get_instance("orders")
77
+ notifications_mq = MQManager.get_instance("notifications")
78
+
79
+ # 分别初始化
80
+ await orders_mq.initialize(backend="rabbitmq", url="amqp://localhost:5672/orders")
81
+ await notifications_mq.initialize(backend="redis", url="redis://localhost:6379/5")
82
+ ```
83
+
84
+ ## 14.5 环境变量
85
+
86
+ ```bash
87
+ # 默认实例
88
+ MQ_BACKEND=redis
89
+ MQ_URL=redis://localhost:6379/0
90
+
91
+ # 多实例(格式:MQ_{{INSTANCE}}_{{FIELD}})
92
+ MQ_DEFAULT_BACKEND=redis
93
+ MQ_DEFAULT_URL=redis://localhost:6379/4
94
+ MQ_ORDERS_BACKEND=rabbitmq
95
+ MQ_ORDERS_URL=amqp://guest:guest@localhost:5672/
96
+ MQ_ORDERS_PREFETCH_COUNT=10
97
+ ```
98
+
99
+ ## 14.6 与异步任务(Dramatiq)的区别
100
+
101
+ - **MQ**:轻量级消息传递,适合简单的生产者-消费者模式
102
+ - **Dramatiq(TaskManager)**:功能更丰富,支持重试、延迟、优先级等
@@ -0,0 +1,147 @@
1
+ # 事件总线(Events)
2
+
3
+ 支持 `memory`、`redis`、`rabbitmq` 后端的事件发布订阅系统。
4
+
5
+ ## 15.1 定义事件
6
+
7
+ **文件**: `{package_name}/events/__init__.py`
8
+
9
+ ```python
10
+ from aury.boot.infrastructure.events import Event
11
+
12
+
13
+ class OrderCreatedEvent(Event):
14
+ \"\"\"订单创建事件。\"\"\"
15
+ order_id: str
16
+ amount: float
17
+ user_id: str
18
+
19
+ @property
20
+ def event_name(self) -> str:
21
+ return "order.created"
22
+
23
+
24
+ class UserRegisteredEvent(Event):
25
+ \"\"\"用户注册事件。\"\"\"
26
+ user_id: str
27
+ email: str
28
+
29
+ @property
30
+ def event_name(self) -> str:
31
+ return "user.registered"
32
+ ```
33
+
34
+ ### 15.1.1 事件初始化
35
+
36
+ 事件类基于 Pydantic BaseModel,支持以下初始化方式:
37
+
38
+ ```python
39
+ # 方式 1:关键字参数(推荐)
40
+ event = OrderCreatedEvent(
41
+ order_id="order-123",
42
+ amount=99.99,
43
+ user_id="user-456",
44
+ )
45
+
46
+ # 方式 2:字典解包
47
+ event = OrderCreatedEvent(**{{
48
+ "order_id": "order-123",
49
+ "amount": 99.99,
50
+ "user_id": "user-456",
51
+ }})
52
+
53
+ # 方式 3:从实体创建
54
+ event = OrderCreatedEvent(
55
+ order_id=str(order.id),
56
+ amount=order.amount,
57
+ user_id=str(order.user_id),
58
+ )
59
+ ```
60
+
61
+ ## 15.2 订阅事件
62
+
63
+ ```python
64
+ from aury.boot.infrastructure.events import EventBusManager
65
+
66
+ bus = EventBusManager.get_instance()
67
+
68
+
69
+ @bus.subscribe(OrderCreatedEvent)
70
+ async def on_order_created(event: OrderCreatedEvent):
71
+ \"\"\"处理订单创建事件。\"\"\"
72
+ logger.info(f"订单创建: {{event.order_id}}, 金额: {{event.amount}}")
73
+ # 发送通知、更新统计等...
74
+
75
+
76
+ @bus.subscribe(UserRegisteredEvent)
77
+ async def send_welcome_email(event: UserRegisteredEvent):
78
+ \"\"\"发送欢迎邮件。\"\"\"
79
+ await email_service.send_welcome(event.email)
80
+ ```
81
+
82
+ ## 15.3 发布事件
83
+
84
+ ```python
85
+ from {package_name}.events import OrderCreatedEvent
86
+
87
+ @router.post("/orders")
88
+ async def create_order(request: OrderCreateRequest):
89
+ # 创建订单
90
+ order = await order_service.create(request)
91
+
92
+ # 发布事件
93
+ await bus.publish(OrderCreatedEvent(
94
+ order_id=order.id,
95
+ amount=order.amount,
96
+ user_id=order.user_id
97
+ ))
98
+
99
+ return BaseResponse(code=200, message="订单创建成功", data=order)
100
+ ```
101
+
102
+ ## 15.4 多实例(EventBusManager)
103
+
104
+ ```python
105
+ from aury.boot.infrastructure.events import EventBusManager
106
+
107
+ # 获取实例
108
+ bus = EventBusManager.get_instance()
109
+ domain_bus = EventBusManager.get_instance("domain")
110
+
111
+ # Memory 后端(单进程)
112
+ await bus.initialize(backend="memory")
113
+
114
+ # Redis Pub/Sub 后端
115
+ await bus.initialize(
116
+ backend="redis",
117
+ url="redis://localhost:6379/0",
118
+ channel_prefix="events:",
119
+ )
120
+
121
+ # RabbitMQ 后端
122
+ await bus.initialize(
123
+ backend="rabbitmq",
124
+ url="amqp://guest:guest@localhost:5672/",
125
+ exchange_name="app.events",
126
+ )
127
+ ```
128
+
129
+ ## 15.5 环境变量
130
+
131
+ ```bash
132
+ # 默认实例
133
+ EVENT_BACKEND=memory
134
+
135
+ # 多实例(格式:EVENT_{{INSTANCE}}_{{FIELD}})
136
+ EVENT_DEFAULT_BACKEND=redis
137
+ EVENT_DEFAULT_URL=redis://localhost:6379/5
138
+ EVENT_DOMAIN_BACKEND=rabbitmq
139
+ EVENT_DOMAIN_URL=amqp://guest:guest@localhost:5672/
140
+ EVENT_DOMAIN_EXCHANGE_NAME=domain.events
141
+ ```
142
+
143
+ ## 15.6 最佳实践
144
+
145
+ 1. **事件应该是不可变的** - 使用 Pydantic 的 `frozen=True`
146
+ 2. **订阅者应该快速完成** - 耗时操作使用任务队列
147
+ 3. **避免循环事件** - 不要在订阅者中发布相同类型的事件
@@ -5,7 +5,7 @@
5
5
  环境变量示例:
6
6
  DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/mydb
7
7
  CACHE_TYPE=redis
8
- CACHE_REDIS_URL=redis://localhost:6379/0
8
+ CACHE_URL=redis://localhost:6379/0
9
9
  LOG_LEVEL=INFO
10
10
  """
11
11
 
@@ -25,8 +25,10 @@ SERVICE_NAME={project_name_snake}
25
25
  # =============================================================================
26
26
  # 数据库配置 (DATABASE_)
27
27
  # =============================================================================
28
- # 数据库连接字符串(默认 SQLite)
28
+ # 支持多实例配置,格式: DATABASE_{{INSTANCE}}_{{FIELD}}
29
+ # 默认实例 (default):
29
30
  # DATABASE_URL=sqlite+aiosqlite:///./dev.db
31
+ # DATABASE_DEFAULT_URL=sqlite+aiosqlite:///./dev.db
30
32
  # PostgreSQL: postgresql+asyncpg://user:pass@localhost:5432/{project_name_snake}
31
33
  # MySQL: mysql+aiomysql://user:pass@localhost:3306/{project_name_snake}
32
34
 
@@ -43,15 +45,25 @@ SERVICE_NAME={project_name_snake}
43
45
  # 是否输出 SQL 语句(调试用)
44
46
  # DATABASE_ECHO=false
45
47
 
48
+ # 多实例示例 (readonly):
49
+ # DATABASE_READONLY_URL=postgresql+asyncpg://user:pass@replica:5432/{project_name_snake}
50
+ # DATABASE_READONLY_POOL_SIZE=10
51
+
46
52
  # =============================================================================
47
53
  # 缓存配置 (CACHE_)
48
54
  # =============================================================================
55
+ # 支持多实例配置,格式: CACHE_{{INSTANCE}}_{{FIELD}}
49
56
  # 缓存类型: memory / redis / memcached
50
57
  # CACHE_TYPE=memory
51
- # Redis/Memcached URL
52
58
  # CACHE_URL=redis://localhost:6379/0
53
59
  # 内存缓存最大大小
54
60
  # CACHE_MAX_SIZE=1000
61
+ # 默认 TTL(秒)
62
+ # CACHE_DEFAULT_TTL=300
63
+ #
64
+ # 多实例示例 (session):
65
+ # CACHE_SESSION_TYPE=redis
66
+ # CACHE_SESSION_URL=redis://localhost:6379/2
55
67
 
56
68
  # =============================================================================
57
69
  # 日志配置 (LOG_)
@@ -138,13 +150,69 @@ SERVICE_NAME={project_name_snake}
138
150
  # 任务超时时间(秒)
139
151
  # TASK_TIMEOUT=3600
140
152
 
153
+ # =============================================================================
154
+ # 流式通道配置 (CHANNEL_) - SSE/实时通信
155
+ # =============================================================================
156
+ # 支持多实例配置,格式: CHANNEL_{{INSTANCE}}_{{FIELD}}
157
+ # 后端类型: memory / redis
158
+ # CHANNEL_BACKEND=memory
159
+ # CHANNEL_DEFAULT_BACKEND=memory
160
+ #
161
+ # Redis 后端配置:
162
+ # CHANNEL_DEFAULT_BACKEND=redis
163
+ # CHANNEL_DEFAULT_URL=redis://localhost:6379/3
164
+ # CHANNEL_DEFAULT_KEY_PREFIX=channel:
165
+ # CHANNEL_DEFAULT_TTL=86400
166
+ #
167
+ # 多实例示例 (notifications):
168
+ # CHANNEL_NOTIFICATIONS_BACKEND=redis
169
+ # CHANNEL_NOTIFICATIONS_URL=redis://localhost:6379/3
170
+
171
+ # =============================================================================
172
+ # 消息队列配置 (MQ_)
173
+ # =============================================================================
174
+ # 支持多实例配置,格式: MQ_{{INSTANCE}}_{{FIELD}}
175
+ # 后端类型: redis / rabbitmq
176
+ # MQ_BACKEND=redis
177
+ # MQ_DEFAULT_BACKEND=redis
178
+ #
179
+ # Redis 后端配置:
180
+ # MQ_DEFAULT_URL=redis://localhost:6379/4
181
+ # MQ_DEFAULT_MAX_CONNECTIONS=10
182
+ #
183
+ # RabbitMQ 后端配置:
184
+ # MQ_DEFAULT_BACKEND=rabbitmq
185
+ # MQ_DEFAULT_URL=amqp://guest:guest@localhost:5672/
186
+ # MQ_DEFAULT_PREFETCH_COUNT=10
187
+ # MQ_DEFAULT_HEARTBEAT=60
188
+ #
189
+ # 多实例示例 (orders):
190
+ # MQ_ORDERS_BACKEND=rabbitmq
191
+ # MQ_ORDERS_URL=amqp://guest:guest@localhost:5672/orders
192
+
141
193
  # =============================================================================
142
194
  # 事件总线配置 (EVENT_)
143
195
  # =============================================================================
144
- # 事件总线代理 URL(如 RabbitMQ)
145
- # EVENT_BROKER_URL=amqp://guest:guest@localhost:5672/
146
- # 事件交换机名称
196
+ # 支持多实例配置,格式: EVENT_{{INSTANCE}}_{{FIELD}}
197
+ # 后端类型: memory / redis / rabbitmq
198
+ # EVENT_BACKEND=memory
199
+ # EVENT_DEFAULT_BACKEND=memory
200
+ #
201
+ # Redis Pub/Sub 后端:
202
+ # EVENT_DEFAULT_BACKEND=redis
203
+ # EVENT_DEFAULT_URL=redis://localhost:6379/5
204
+ # EVENT_DEFAULT_KEY_PREFIX=events:
205
+ #
206
+ # RabbitMQ 后端:
207
+ # EVENT_DEFAULT_BACKEND=rabbitmq
208
+ # EVENT_DEFAULT_URL=amqp://guest:guest@localhost:5672/
147
209
  # EVENT_EXCHANGE_NAME=aury.events
210
+ # EVENT_DEFAULT_EXCHANGE_TYPE=topic
211
+ #
212
+ # 多实例示例 (domain):
213
+ # EVENT_DOMAIN_BACKEND=rabbitmq
214
+ # EVENT_DOMAIN_URL=amqp://guest:guest@localhost:5672/
215
+ # EVENT_DOMAIN_EXCHANGE_NAME=domain.events
148
216
 
149
217
  # =============================================================================
150
218
  # 数据库迁移配置 (MIGRATION_)
@@ -13,7 +13,7 @@
13
13
  # from aury.boot.infrastructure.tasks import conditional_task
14
14
  #
15
15
  #
16
- # @conditional_task
16
+ # @conditional_task()
17
17
  # def example_task():
18
18
  # """示例异步任务。"""
19
19
  # logger.info("异步任务执行中...")
@@ -5,7 +5,6 @@ from typing import Any
5
5
 
6
6
  from starlette.requests import Request
7
7
 
8
-
9
8
  try: # pragma: no cover
10
9
  from sqladmin.authentication import AuthenticationBackend as _SQLAdminAuthenticationBackend
11
10
  except Exception: # pragma: no cover
@@ -14,7 +13,7 @@ except Exception: # pragma: no cover
14
13
 
15
14
  def _require_sqladmin():
16
15
  try:
17
- from sqladmin.authentication import AuthenticationBackend # noqa: F401
16
+ from sqladmin.authentication import AuthenticationBackend
18
17
  except Exception as exc: # pragma: no cover
19
18
  raise ImportError(
20
19
  "未安装 sqladmin。请先安装: uv add \"aury-boot[admin]\" 或 uv add sqladmin"
@@ -118,7 +117,7 @@ def wrap_authenticate(
118
117
  from sqladmin.authentication import AuthenticationBackend
119
118
 
120
119
  class _Wrapped(AuthenticationBackend):
121
- async def login(self, request: Request) -> bool: # noqa: D401
120
+ async def login(self, request: Request) -> bool:
122
121
  # 默认不提供登录页逻辑;用户可自己实现更复杂版本
123
122
  return False
124
123
 
@@ -14,7 +14,7 @@ from .utils import import_from_string
14
14
 
15
15
  def _require_sqladmin():
16
16
  try:
17
- from sqladmin import Admin # noqa: F401
17
+ from sqladmin import Admin
18
18
  except Exception as exc: # pragma: no cover
19
19
  raise ImportError(
20
20
  "未安装 sqladmin。请先安装: uv add \"aury-boot[admin]\" 或 uv add sqladmin"
@@ -119,7 +119,54 @@ class AuditableStateMixin:
119
119
 
120
120
 
121
121
  class VersionMixin:
122
- """乐观锁版本控制 Mixin"""
122
+ """乐观锁版本控制 Mixin
123
+
124
+ 用于防止并发更新冲突。每次更新时会检查 version 是否一致,
125
+ 如果不一致则抛出 VersionConflictError。
126
+
127
+ 工作原理:
128
+ 1. 读取记录时获取当前 version
129
+ 2. 更新时检查 version 是否与读取时一致
130
+ 3. 如果一致,version + 1 并完成更新
131
+ 4. 如果不一致,抛出 VersionConflictError
132
+
133
+ 使用场景:
134
+ - 库存管理(防止超卖)
135
+ - 订单状态更新(防止重复支付)
136
+ - 文档编辑(防止覆盖他人修改)
137
+
138
+ 示例:
139
+ class Product(IDMixin, VersionMixin, Base):
140
+ __tablename__ = "products"
141
+ name: Mapped[str]
142
+ stock: Mapped[int]
143
+
144
+ # 更新时自动检查版本
145
+ product = await repo.get(1) # version=1
146
+ product.stock -= 1
147
+ await repo.update(product) # version 自动变为 2
148
+
149
+ # 并发更新时抛出异常
150
+ # 线程 A: product.version = 1, 准备更新
151
+ # 线程 B: 已经更新,version = 2
152
+ # 线程 A: await repo.update(product) -> VersionConflictError
153
+
154
+ 异常处理:
155
+ from aury.boot.domain.exceptions import VersionConflictError
156
+
157
+ try:
158
+ await repo.update(product)
159
+ except VersionConflictError as e:
160
+ # 重新加载数据并重试
161
+ product = await repo.get(product.id)
162
+ # ... 重新应用业务逻辑 ...
163
+ await repo.update(product)
164
+
165
+ 注意:
166
+ - 乐观锁适用于读多写少的场景
167
+ - 高并发写入场景建议使用悲观锁 (QueryBuilder.for_update())
168
+ - BaseRepository.update() 已自动支持乐观锁检查
169
+ """
123
170
 
124
171
  version: Mapped[int] = mapped_column(
125
172
  Integer,
@@ -75,10 +75,104 @@ class SortParams(BaseModel):
75
75
  """排序参数。
76
76
 
77
77
  定义排序的字段和方向。
78
+
79
+ 支持两种语法:
80
+ - 简洁语法: "-created_at" (前缀 - 表示降序)
81
+ - 完整语法: "created_at:desc"
82
+
83
+ 示例:
84
+ # 从字符串解析
85
+ sort_params = SortParams.from_string("-created_at,priority")
86
+ sort_params = SortParams.from_string("created_at:desc,priority:asc")
87
+
88
+ # 带白名单验证
89
+ sort_params = SortParams.from_string(
90
+ "-created_at",
91
+ allowed_fields={"id", "created_at", "priority"}
92
+ )
93
+
94
+ # 程序化构建
95
+ sort_params = SortParams(sorts=[("-created_at",), ("priority", "asc")])
78
96
  """
79
97
 
80
98
  sorts: list[tuple[str, str]] = Field(default_factory=list, description="排序字段列表")
81
99
 
100
+ @classmethod
101
+ def from_string(
102
+ cls,
103
+ sort_str: str | None,
104
+ *,
105
+ allowed_fields: set[str] | None = None,
106
+ default_direction: str = "desc",
107
+ ) -> SortParams:
108
+ """从字符串解析排序参数。
109
+
110
+ 支持两种语法:
111
+ - 简洁语法: "-created_at" (前缀 - 表示降序)
112
+ - 完整语法: "created_at:desc"
113
+
114
+ Args:
115
+ sort_str: 排序字符串,如 "-created_at,priority" 或 "created_at:desc,priority:asc"
116
+ allowed_fields: 允许的字段白名单(可选,用于安全校验)
117
+ default_direction: 默认排序方向(当不指定方向时使用)
118
+
119
+ Returns:
120
+ SortParams: 排序参数对象
121
+
122
+ Raises:
123
+ ValueError: 字段不在白名单中 或 方向无效
124
+
125
+ 示例:
126
+ >>> SortParams.from_string("-created_at,priority")
127
+ SortParams(sorts=[('created_at', 'desc'), ('priority', 'desc')])
128
+
129
+ >>> SortParams.from_string("created_at:desc,priority:asc")
130
+ SortParams(sorts=[('created_at', 'desc'), ('priority', 'asc')])
131
+
132
+ >>> SortParams.from_string("-id", allowed_fields={"id", "name"})
133
+ SortParams(sorts=[('id', 'desc')])
134
+
135
+ >>> SortParams.from_string("-invalid", allowed_fields={"id", "name"})
136
+ ValueError: 不允许的排序字段: invalid,允许的字段: id, name
137
+ """
138
+ if not sort_str:
139
+ return cls(sorts=[])
140
+
141
+ sorts = []
142
+ for part in sort_str.split(","):
143
+ part = part.strip()
144
+ if not part:
145
+ continue
146
+
147
+ # 解析字段和方向
148
+ if part.startswith("-"):
149
+ # 简洁语法: -created_at
150
+ field = part[1:]
151
+ direction = "desc"
152
+ elif ":" in part:
153
+ # 完整语法: created_at:desc
154
+ field, direction = part.split(":", 1)
155
+ else:
156
+ # 无方向指示,使用默认
157
+ field = part
158
+ direction = default_direction
159
+
160
+ field = field.strip()
161
+ direction = direction.strip().lower()
162
+
163
+ # 字段白名单校验
164
+ if allowed_fields and field not in allowed_fields:
165
+ allowed_list = ", ".join(sorted(allowed_fields))
166
+ raise ValueError(f"不允许的排序字段: {field},允许的字段: {allowed_list}")
167
+
168
+ # 方向校验
169
+ if direction not in ("asc", "desc"):
170
+ raise ValueError(f"排序方向必须是 'asc' 或 'desc',得到: {direction}")
171
+
172
+ sorts.append((field, direction))
173
+
174
+ return cls(sorts=sorts)
175
+
82
176
  def add_sort(self, field: str, direction: str = "asc") -> None:
83
177
  """添加排序字段。
84
178
 
@@ -17,7 +17,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
17
17
  from aury.boot.common.logging import logger
18
18
  from aury.boot.domain.exceptions import VersionConflictError
19
19
  from aury.boot.domain.models import GUID, Base
20
- from aury.boot.domain.transaction import _transaction_depth
21
20
  from aury.boot.domain.pagination import (
22
21
  PaginationParams,
23
22
  PaginationResult,
@@ -25,6 +24,7 @@ from aury.boot.domain.pagination import (
25
24
  )
26
25
  from aury.boot.domain.repository.interface import IRepository
27
26
  from aury.boot.domain.repository.query_builder import QueryBuilder
27
+ from aury.boot.domain.transaction import _transaction_depth
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from typing import Self
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
  from abc import ABC, abstractmethod
9
9
  from typing import TYPE_CHECKING, Any
10
10
 
11
- from aury.boot.domain.models import Base, GUID
11
+ from aury.boot.domain.models import GUID, Base
12
12
  from aury.boot.domain.pagination import PaginationParams, PaginationResult, SortParams
13
13
 
14
14
  if TYPE_CHECKING:
@@ -9,9 +9,9 @@
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import contextvars
13
12
  from collections.abc import AsyncGenerator, Awaitable, Callable
14
13
  from contextlib import asynccontextmanager
14
+ import contextvars
15
15
  from functools import wraps
16
16
  from inspect import signature
17
17
  from typing import Any
@@ -24,9 +24,9 @@ from aury.boot.domain.exceptions import TransactionRequiredError
24
24
  # 用于跟踪嵌套事务的上下文变量
25
25
  _transaction_depth: contextvars.ContextVar[int] = contextvars.ContextVar("transaction_depth", default=0)
26
26
 
27
- # 用于存储 on_commit 回调的上下文变量
28
- _on_commit_callbacks: contextvars.ContextVar[list[Callable[[], Any] | Callable[[], Awaitable[Any]]]] = (
29
- contextvars.ContextVar("on_commit_callbacks", default=[])
27
+ # 用于存储 on_commit 回调的上下文变量(不设置 default,避免可变对象问题)
28
+ _on_commit_callbacks: contextvars.ContextVar[list[Callable[[], Any] | Callable[[], Awaitable[Any]]] | None] = (
29
+ contextvars.ContextVar("on_commit_callbacks", default=None)
30
30
  )
31
31
 
32
32
 
@@ -51,8 +51,7 @@ def on_commit(callback: Callable[[], Any] | Callable[[], Awaitable[Any]]) -> Non
51
51
  - 嵌套事务中注册的回调,只在最外层事务提交后执行
52
52
  """
53
53
  callbacks = _on_commit_callbacks.get()
54
- # 创建新列表避免修改默认值
55
- if not callbacks:
54
+ if callbacks is None:
56
55
  callbacks = []
57
56
  _on_commit_callbacks.set(callbacks)
58
57
  callbacks.append(callback)
@@ -65,7 +64,7 @@ async def _execute_on_commit_callbacks() -> None:
65
64
  return
66
65
 
67
66
  # 清空回调列表
68
- _on_commit_callbacks.set([])
67
+ _on_commit_callbacks.set(None)
69
68
 
70
69
  for callback in callbacks:
71
70
  try:
@@ -106,7 +105,7 @@ async def transactional_context(session: AsyncSession, auto_commit: bool = True)
106
105
 
107
106
  # 最外层事务初始化回调列表
108
107
  if is_outermost:
109
- _on_commit_callbacks.set([])
108
+ _on_commit_callbacks.set(None)
110
109
 
111
110
  try:
112
111
  yield session
@@ -121,7 +120,7 @@ async def transactional_context(session: AsyncSession, auto_commit: bool = True)
121
120
  if is_outermost:
122
121
  await session.rollback()
123
122
  # 回滚时清空回调列表(不执行)
124
- _on_commit_callbacks.set([])
123
+ _on_commit_callbacks.set(None)
125
124
  logger.error(f"事务回滚: {exc}")
126
125
  raise
127
126
  finally: