aury-boot 0.0.3__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 +30 -9
  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.3.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.3.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.3.dist-info → aury_boot-0.0.5.dist-info}/WHEEL +0 -0
  98. {aury_boot-0.0.3.dist-info → aury_boot-0.0.5.dist-info}/entry_points.txt +0 -0
@@ -9,23 +9,183 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  from pathlib import Path
12
- from typing import Literal
12
+ from typing import Any, Literal
13
13
 
14
14
  from dotenv import load_dotenv
15
15
  from pydantic import Field
16
16
  from pydantic_settings import BaseSettings, SettingsConfigDict
17
17
 
18
+ from .multi_instance import MultiInstanceConfigLoader, MultiInstanceSettings
19
+
18
20
 
19
21
  def _load_env_file(env_file: str | Path) -> bool:
20
22
  """加载 .env 文件到环境变量。"""
21
23
  return load_dotenv(env_file, override=True)
22
24
 
23
25
 
26
+ # =============================================================================
27
+ # 多实例配置基类
28
+ # =============================================================================
29
+
30
+
31
+ class DatabaseInstanceConfig(MultiInstanceSettings):
32
+ """数据库实例配置。
33
+
34
+ 环境变量格式: DATABASE_{INSTANCE}_{FIELD}
35
+ 示例:
36
+ DATABASE_DEFAULT_URL=postgresql://main...
37
+ DATABASE_DEFAULT_POOL_SIZE=10
38
+ DATABASE_ANALYTICS_URL=postgresql://analytics...
39
+ """
40
+
41
+ url: str = Field(
42
+ default="sqlite+aiosqlite:///./app.db",
43
+ description="数据库连接字符串"
44
+ )
45
+ echo: bool = Field(
46
+ default=False,
47
+ description="是否输出 SQL 语句"
48
+ )
49
+ pool_size: int = Field(
50
+ default=5,
51
+ description="数据库连接池大小"
52
+ )
53
+ max_overflow: int = Field(
54
+ default=10,
55
+ description="连接池最大溢出连接数"
56
+ )
57
+ pool_recycle: int = Field(
58
+ default=3600,
59
+ description="连接回收时间(秒)"
60
+ )
61
+ pool_timeout: int = Field(
62
+ default=30,
63
+ description="获取连接超时时间(秒)"
64
+ )
65
+ pool_pre_ping: bool = Field(
66
+ default=True,
67
+ description="是否在获取连接前进行 PING"
68
+ )
69
+
70
+
71
+ class CacheInstanceConfig(MultiInstanceSettings):
72
+ """缓存实例配置。
73
+
74
+ 环境变量格式: CACHE_{INSTANCE}_{FIELD}
75
+ 示例:
76
+ CACHE_DEFAULT_BACKEND=redis
77
+ CACHE_DEFAULT_URL=redis://localhost:6379/0
78
+ CACHE_LOCAL_BACKEND=memory
79
+ CACHE_LOCAL_MAX_SIZE=5000
80
+ """
81
+
82
+ backend: str = Field(
83
+ default="memory",
84
+ description="缓存后端 (memory/redis/memcached)"
85
+ )
86
+ url: str | None = Field(
87
+ default=None,
88
+ description="缓存服务 URL"
89
+ )
90
+ max_size: int = Field(
91
+ default=1000,
92
+ description="内存缓存最大大小"
93
+ )
94
+
95
+
96
+ class StorageInstanceConfig(MultiInstanceSettings):
97
+ """对象存储实例配置。
98
+
99
+ 环境变量格式: STORAGE_{INSTANCE}_{FIELD}
100
+ 示例:
101
+ STORAGE_DEFAULT_BACKEND=s3
102
+ STORAGE_DEFAULT_BUCKET=main-bucket
103
+ STORAGE_BACKUP_BACKEND=local
104
+ STORAGE_BACKUP_BASE_PATH=/backup
105
+ """
106
+
107
+ backend: Literal["local", "s3", "oss", "cos"] = Field(
108
+ default="local",
109
+ description="存储后端"
110
+ )
111
+ # S3 配置
112
+ access_key_id: str | None = Field(default=None)
113
+ access_key_secret: str | None = Field(default=None)
114
+ endpoint: str | None = Field(default=None)
115
+ region: str | None = Field(default=None)
116
+ bucket_name: str | None = Field(default=None)
117
+ # 本地存储
118
+ base_path: str = Field(default="./storage")
119
+
120
+
121
+ class ChannelInstanceConfig(MultiInstanceSettings):
122
+ """通道实例配置。
123
+
124
+ 环境变量格式: CHANNEL_{INSTANCE}_{FIELD}
125
+ 示例:
126
+ CHANNEL_DEFAULT_BACKEND=memory
127
+ CHANNEL_SHARED_BACKEND=redis
128
+ CHANNEL_SHARED_URL=redis://localhost:6379/3
129
+ """
130
+
131
+ backend: str = Field(
132
+ default="memory",
133
+ description="通道后端 (memory/redis)"
134
+ )
135
+ url: str | None = Field(
136
+ default=None,
137
+ description="Redis URL(当 backend=redis 时需要)"
138
+ )
139
+
140
+
141
+ class MQInstanceConfig(MultiInstanceSettings):
142
+ """消息队列实例配置。
143
+
144
+ 环境变量格式: MQ_{INSTANCE}_{FIELD}
145
+ 示例:
146
+ MQ_DEFAULT_BACKEND=redis
147
+ MQ_DEFAULT_URL=redis://localhost:6379/4
148
+ """
149
+
150
+ backend: str = Field(
151
+ default="redis",
152
+ description="消息队列后端 (redis/rabbitmq)"
153
+ )
154
+ url: str | None = Field(
155
+ default=None,
156
+ description="连接 URL"
157
+ )
158
+
159
+
160
+ class EventInstanceConfig(MultiInstanceSettings):
161
+ """事件总线实例配置。
162
+
163
+ 环境变量格式: EVENT_{INSTANCE}_{FIELD}
164
+ 示例:
165
+ EVENT_DEFAULT_BACKEND=memory
166
+ EVENT_DISTRIBUTED_BACKEND=redis
167
+ EVENT_DISTRIBUTED_URL=redis://localhost:6379/5
168
+ """
169
+
170
+ backend: str = Field(
171
+ default="memory",
172
+ description="事件后端 (memory/redis/rabbitmq)"
173
+ )
174
+ url: str | None = Field(
175
+ default=None,
176
+ description="连接 URL"
177
+ )
178
+
179
+
180
+ # =============================================================================
181
+ # 单实例配置
182
+ # =============================================================================
183
+
184
+
24
185
  class DatabaseSettings(BaseSettings):
25
- """数据库配置。
186
+ """数据库配置(单实例)。
26
187
 
27
- 环境变量前缀: DATABASE_
28
- 示例: DATABASE_URL, DATABASE_ECHO, DATABASE_POOL_SIZE
188
+ 推荐使用多实例配置: DATABASE_{INSTANCE}_{FIELD}
29
189
  """
30
190
 
31
191
  url: str = Field(
@@ -350,6 +510,49 @@ class EventSettings(BaseSettings):
350
510
  )
351
511
 
352
512
 
513
+ class MessageQueueSettings(BaseSettings):
514
+ """消息队列配置。
515
+
516
+ 环境变量前缀: MQ_
517
+ 示例: MQ_BROKER_URL, MQ_DEFAULT_QUEUE, MQ_SERIALIZER
518
+
519
+ 与 Task(任务队列)的区别:
520
+ - Task: 基于 Dramatiq,用于异步任务处理(API + Worker 模式)
521
+ - MQ: 通用消息队列,用于服务间通信、事件驱动架构
522
+
523
+ 支持的后端(通过 Kombu):
524
+ - Redis: redis://localhost:6379/0
525
+ - RabbitMQ: amqp://guest:guest@localhost:5672//
526
+ - Amazon SQS: sqs://
527
+ """
528
+
529
+ enabled: bool = Field(
530
+ default=False,
531
+ description="是否启用消息队列组件"
532
+ )
533
+ broker_url: str | None = Field(
534
+ default=None,
535
+ description="消息队列代理 URL"
536
+ )
537
+ default_queue: str = Field(
538
+ default="default",
539
+ description="默认队列名称"
540
+ )
541
+ serializer: str = Field(
542
+ default="json",
543
+ description="序列化方式(json/pickle/msgpack)"
544
+ )
545
+ prefetch_count: int = Field(
546
+ default=1,
547
+ description="预取消息数量"
548
+ )
549
+
550
+ model_config = SettingsConfigDict(
551
+ env_prefix="MQ_",
552
+ case_sensitive=False,
553
+ )
554
+
555
+
353
556
  class MigrationSettings(BaseSettings):
354
557
  """数据库迁移配置。
355
558
 
@@ -566,9 +769,29 @@ class BaseConfig(BaseSettings):
566
769
  所有应用配置的基类,提供通用配置项。
567
770
  初始化时自动从 .env 文件加载环境变量,然后由 pydantic-settings 读取环境变量。
568
771
 
772
+ 多实例配置:
773
+ 框架支持多种组件的多实例配置,使用统一的环境变量格式:
774
+ {PREFIX}_{INSTANCE}_{FIELD}=value
775
+
776
+ 示例:
777
+ DATABASE_DEFAULT_URL=postgresql://main...
778
+ DATABASE_ANALYTICS_URL=postgresql://analytics...
779
+ CACHE_DEFAULT_BACKEND=redis
780
+ CACHE_DEFAULT_URL=redis://localhost:6379/1
781
+ MQ_DEFAULT_URL=redis://localhost:6379/2
782
+ EVENT_DEFAULT_BACKEND=memory
783
+
569
784
  注意:Application 层配置完全独立,不依赖 Infrastructure 层。
570
785
  """
571
786
 
787
+ # 多实例配置缓存
788
+ _databases: dict[str, DatabaseInstanceConfig] | None = None
789
+ _caches: dict[str, CacheInstanceConfig] | None = None
790
+ _storages: dict[str, StorageInstanceConfig] | None = None
791
+ _channels: dict[str, ChannelInstanceConfig] | None = None
792
+ _mqs: dict[str, MQInstanceConfig] | None = None
793
+ _events: dict[str, EventInstanceConfig] | None = None
794
+
572
795
  def __init__(self, _env_file: str | Path = ".env", **kwargs) -> None:
573
796
  """初始化配置。
574
797
 
@@ -597,13 +820,8 @@ class BaseConfig(BaseSettings):
597
820
  admin: AdminConsoleSettings = Field(default_factory=AdminConsoleSettings)
598
821
 
599
822
  # ========== 数据与缓存 ==========
600
- # 数据库配置
601
823
  database: DatabaseSettings = Field(default_factory=DatabaseSettings)
602
-
603
- # 缓存配置
604
824
  cache: CacheSettings = Field(default_factory=CacheSettings)
605
-
606
- # 对象存储配置(接入用;storage SDK 本身不读取 env)
607
825
  storage: StorageSettings = Field(default_factory=StorageSettings)
608
826
 
609
827
  # 迁移配置
@@ -617,10 +835,7 @@ class BaseConfig(BaseSettings):
617
835
  scheduler: SchedulerSettings = Field(default_factory=SchedulerSettings)
618
836
 
619
837
  # ========== 异步与事件 ==========
620
- # 任务队列配置
621
838
  task: TaskSettings = Field(default_factory=TaskSettings)
622
-
623
- # 事件总线配置
624
839
  event: EventSettings = Field(default_factory=EventSettings)
625
840
 
626
841
  # ========== 微服务通信 ==========
@@ -635,6 +850,111 @@ class BaseConfig(BaseSettings):
635
850
  extra="ignore",
636
851
  )
637
852
 
853
+ # ========== 多实例配置访问方法 ==========
854
+
855
+ def get_databases(self) -> dict[str, DatabaseInstanceConfig]:
856
+ """获取所有数据库实例配置。
857
+
858
+ 从环境变量解析 DATABASE_{INSTANCE}_{FIELD} 格式的配置。
859
+ 如果没有配置多实例,返回从单实例配置转换的 default 实例。
860
+ """
861
+ if self._databases is None:
862
+ loader = MultiInstanceConfigLoader("DATABASE", DatabaseInstanceConfig)
863
+ self._databases = loader.load()
864
+ if not self._databases:
865
+ self._databases = {
866
+ "default": DatabaseInstanceConfig(
867
+ url=self.database.url,
868
+ echo=self.database.echo,
869
+ pool_size=self.database.pool_size,
870
+ max_overflow=self.database.max_overflow,
871
+ pool_recycle=self.database.pool_recycle,
872
+ pool_timeout=self.database.pool_timeout,
873
+ pool_pre_ping=self.database.pool_pre_ping,
874
+ )
875
+ }
876
+ return self._databases
877
+
878
+ def get_caches(self) -> dict[str, CacheInstanceConfig]:
879
+ """获取所有缓存实例配置。
880
+
881
+ 从环境变量解析 CACHE_{INSTANCE}_{FIELD} 格式的配置。
882
+ 如果没有配置多实例,返回从单实例配置转换的 default 实例。
883
+ """
884
+ if self._caches is None:
885
+ loader = MultiInstanceConfigLoader("CACHE", CacheInstanceConfig)
886
+ self._caches = loader.load()
887
+ if not self._caches:
888
+ self._caches = {
889
+ "default": CacheInstanceConfig(
890
+ backend=self.cache.cache_type,
891
+ url=self.cache.url,
892
+ max_size=self.cache.max_size,
893
+ )
894
+ }
895
+ return self._caches
896
+
897
+ def get_storages(self) -> dict[str, StorageInstanceConfig]:
898
+ """获取所有存储实例配置。
899
+
900
+ 从环境变量解析 STORAGE_{INSTANCE}_{FIELD} 格式的配置。
901
+ 如果没有配置多实例,返回从单实例配置转换的 default 实例。
902
+ """
903
+ if self._storages is None:
904
+ loader = MultiInstanceConfigLoader("STORAGE", StorageInstanceConfig)
905
+ self._storages = loader.load()
906
+ if not self._storages:
907
+ self._storages = {
908
+ "default": StorageInstanceConfig(
909
+ backend=self.storage.type,
910
+ access_key_id=self.storage.access_key_id,
911
+ access_key_secret=self.storage.access_key_secret,
912
+ endpoint=self.storage.endpoint,
913
+ region=self.storage.region,
914
+ bucket_name=self.storage.bucket_name,
915
+ base_path=self.storage.base_path,
916
+ )
917
+ }
918
+ return self._storages
919
+
920
+ def get_channels(self) -> dict[str, ChannelInstanceConfig]:
921
+ """获取所有通道实例配置。
922
+
923
+ 从环境变量解析 CHANNEL_{INSTANCE}_{FIELD} 格式的配置。
924
+ """
925
+ if self._channels is None:
926
+ loader = MultiInstanceConfigLoader("CHANNEL", ChannelInstanceConfig)
927
+ self._channels = loader.load()
928
+ return self._channels
929
+
930
+ def get_mqs(self) -> dict[str, MQInstanceConfig]:
931
+ """获取所有消息队列实例配置。
932
+
933
+ 从环境变量解析 MQ_{INSTANCE}_{FIELD} 格式的配置。
934
+ """
935
+ if self._mqs is None:
936
+ loader = MultiInstanceConfigLoader("MQ", MQInstanceConfig)
937
+ self._mqs = loader.load()
938
+ return self._mqs
939
+
940
+ def get_events(self) -> dict[str, EventInstanceConfig]:
941
+ """获取所有事件总线实例配置。
942
+
943
+ 从环境变量解析 EVENT_{INSTANCE}_{FIELD} 格式的配置。
944
+ 如果没有配置多实例,返回从单实例配置转换的 default 实例。
945
+ """
946
+ if self._events is None:
947
+ loader = MultiInstanceConfigLoader("EVENT", EventInstanceConfig)
948
+ self._events = loader.load()
949
+ if not self._events and self.event.broker_url:
950
+ self._events = {
951
+ "default": EventInstanceConfig(
952
+ backend="redis" if "redis" in (self.event.broker_url or "") else "rabbitmq",
953
+ url=self.event.broker_url,
954
+ )
955
+ }
956
+ return self._events
957
+
638
958
  @property
639
959
  def is_production(self) -> bool:
640
960
  """是否为生产环境。"""
@@ -642,21 +962,30 @@ class BaseConfig(BaseSettings):
642
962
 
643
963
 
644
964
  __all__ = [
965
+ # 配置类
645
966
  "AdminAuthSettings",
646
967
  "AdminConsoleSettings",
647
968
  "BaseConfig",
648
969
  "CORSSettings",
970
+ # 多实例配置类
971
+ "CacheInstanceConfig",
649
972
  "CacheSettings",
973
+ "ChannelInstanceConfig",
974
+ "DatabaseInstanceConfig",
650
975
  "DatabaseSettings",
976
+ "EventInstanceConfig",
651
977
  "EventSettings",
652
978
  "HealthCheckSettings",
653
979
  "LogSettings",
980
+ "MQInstanceConfig",
981
+ "MessageQueueSettings",
654
982
  "MigrationSettings",
655
983
  "RPCClientSettings",
656
984
  "RPCServiceSettings",
657
985
  "SchedulerSettings",
658
986
  "ServerSettings",
659
987
  "ServiceSettings",
988
+ "StorageInstanceConfig",
660
989
  "StorageSettings",
661
990
  "TaskSettings",
662
991
  ]
@@ -35,6 +35,12 @@ class ComponentName(str, Enum):
35
35
  # 存储组件
36
36
  STORAGE = "storage"
37
37
 
38
+ # 消息队列组件
39
+ MESSAGE_QUEUE = "message_queue"
40
+
41
+ # 事件总线组件
42
+ EVENT_BUS = "event_bus"
43
+
38
44
  # 迁移组件
39
45
  MIGRATIONS = "migrations"
40
46
 
@@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from fastapi import HTTPException, Request, status
12
+ from fastapi.exceptions import RequestValidationError
12
13
  from fastapi.responses import JSONResponse
13
14
  from pydantic import ValidationError
14
15
  from sqlalchemy.exc import IntegrityError, SQLAlchemyError
@@ -121,9 +122,19 @@ class BaseErrorHandler(ErrorHandler):
121
122
 
122
123
  errors = [detail.model_dump() for detail in exception.details] if exception.details else None
123
124
 
125
+ # 兼容 ErrorCode 枚举和字符串
126
+ code_value = exception.code.value if hasattr(exception.code, "value") else exception.code
127
+
128
+ # 尝试转换为 int,如果失败则使用 status_code
129
+ try:
130
+ code_int = int(code_value)
131
+ except (ValueError, TypeError):
132
+ # 非数字字符串(如 "TODO_ATTACHMENT_ERROR"),使用 HTTP 状态码作为 code
133
+ code_int = exception.status_code
134
+
124
135
  response = ResponseBuilder.fail(
125
136
  message=exception.message,
126
- code=int(exception.code.value),
137
+ code=code_int,
127
138
  errors=errors,
128
139
  )
129
140
 
@@ -160,11 +171,14 @@ class HTTPExceptionHandler(ErrorHandler):
160
171
 
161
172
 
162
173
  class ValidationErrorHandler(ErrorHandler):
163
- """Pydantic验证异常处理器。"""
174
+ """验证异常处理器。
175
+
176
+ 处理 Pydantic ValidationError 和 FastAPI RequestValidationError。
177
+ """
164
178
 
165
179
  def can_handle(self, exception: Exception) -> bool:
166
180
  """判断是否为验证异常。"""
167
- return isinstance(exception, ValidationError)
181
+ return isinstance(exception, ValidationError | RequestValidationError)
168
182
 
169
183
  async def handle(self, exception: Exception, request: Request) -> JSONResponse:
170
184
  """处理验证异常。"""
@@ -16,7 +16,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
16
16
  from starlette.requests import Request
17
17
  from starlette.responses import Response
18
18
 
19
- from aury.boot.common.logging import get_trace_id, logger, set_trace_id
19
+ from aury.boot.application.errors import global_exception_handler
20
+ from aury.boot.common.logging import logger, set_trace_id
20
21
 
21
22
 
22
23
  def log_request[T](func: Callable[..., T]) -> Callable[..., T]:
@@ -107,10 +108,7 @@ def _should_log_body(content_type: str | None) -> bool:
107
108
  if not content_type:
108
109
  return True
109
110
  content_type = content_type.lower()
110
- for skip_type in SKIP_BODY_CONTENT_TYPES:
111
- if skip_type in content_type:
112
- return False
113
- return True
111
+ return all(skip_type not in content_type for skip_type in SKIP_BODY_CONTENT_TYPES)
114
112
 
115
113
 
116
114
  class RequestLoggingMiddleware(BaseHTTPMiddleware):
@@ -211,7 +209,11 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
211
209
  f"请求处理失败: {request.method} {request.url.path} | "
212
210
  f"耗时: {duration:.3f}s | Trace-ID: {trace_id}"
213
211
  )
214
- raise
212
+ # 使用全局异常处理器生成响应,而不是直接抛出异常
213
+ # BaseHTTPMiddleware 中直接 raise 会绕过 FastAPI 的异常处理器
214
+ response = await global_exception_handler(request, exc)
215
+ response.headers["x-trace-id"] = trace_id
216
+ return response
215
217
 
216
218
 
217
219
  class WebSocketLoggingMiddleware:
@@ -328,120 +330,6 @@ class WebSocketLoggingMiddleware:
328
330
  raise
329
331
 
330
332
 
331
- class WebSocketLoggingMiddleware:
332
- """WebSocket 日志中间件。
333
-
334
- 记录 WebSocket 连接生命周期和消息收发(可选)。
335
-
336
- 使用示例:
337
- from aury.boot.application.middleware.logging import WebSocketLoggingMiddleware
338
-
339
- app.add_middleware(WebSocketLoggingMiddleware, log_messages=True)
340
- """
341
-
342
- def __init__(
343
- self,
344
- app,
345
- *,
346
- log_messages: bool = False,
347
- max_message_length: int = 500,
348
- ) -> None:
349
- """初始化 WebSocket 日志中间件。
350
-
351
- Args:
352
- app: ASGI 应用
353
- log_messages: 是否记录消息内容(默认 False,注意性能和敏感数据)
354
- max_message_length: 消息内容最大记录长度
355
- """
356
- self.app = app
357
- self.log_messages = log_messages
358
- self.max_message_length = max_message_length
359
-
360
- async def __call__(self, scope, receive, send) -> None:
361
- if scope["type"] != "websocket":
362
- await self.app(scope, receive, send)
363
- return
364
-
365
- # 获取或生成 trace_id
366
- headers = dict(scope.get("headers", []))
367
- trace_id = (
368
- headers.get(b"x-trace-id", b"").decode() or
369
- headers.get(b"x-request-id", b"").decode() or
370
- str(uuid.uuid4())
371
- )
372
- set_trace_id(trace_id)
373
-
374
- path = scope.get("path", "/")
375
- client = scope.get("client")
376
- client_host = f"{client[0]}:{client[1]}" if client else "unknown"
377
-
378
- start_time = time.time()
379
- message_count = {"sent": 0, "received": 0}
380
-
381
- async def logging_receive():
382
- message = await receive()
383
- msg_type = message.get("type", "")
384
-
385
- if msg_type == "websocket.connect":
386
- logger.info(
387
- f"WS → 连接: {path} | "
388
- f"客户端: {client_host} | Trace-ID: {trace_id}"
389
- )
390
- elif msg_type == "websocket.disconnect":
391
- duration = time.time() - start_time
392
- logger.info(
393
- f"WS ← 断开: {path} | "
394
- f"时长: {duration:.1f}s | "
395
- f"收/发: {message_count['received']}/{message_count['sent']} | "
396
- f"Trace-ID: {trace_id}"
397
- )
398
- elif msg_type == "websocket.receive":
399
- message_count["received"] += 1
400
- if self.log_messages:
401
- text = message.get("text") or message.get("bytes", b"").decode("utf-8", errors="replace")
402
- if len(text) > self.max_message_length:
403
- text = text[:self.max_message_length] + "..."
404
- logger.debug(f"WS → 收: {path} | {text}")
405
-
406
- return message
407
-
408
- async def logging_send(message):
409
- msg_type = message.get("type", "")
410
-
411
- if msg_type == "websocket.send":
412
- message_count["sent"] += 1
413
- if self.log_messages:
414
- text = message.get("text") or message.get("bytes", b"").decode("utf-8", errors="replace")
415
- if len(text) > self.max_message_length:
416
- text = text[:self.max_message_length] + "..."
417
- logger.debug(f"WS ← 发: {path} | {text}")
418
- elif msg_type == "websocket.close":
419
- code = message.get("code", 1000)
420
- reason = message.get("reason", "")
421
- duration = time.time() - start_time
422
- log_level = "warning" if code != 1000 else "info"
423
- logger.log(
424
- log_level.upper(),
425
- f"WS × 关闭: {path} | "
426
- f"Code: {code}{' | 原因: ' + reason if reason else ''} | "
427
- f"时长: {duration:.1f}s | Trace-ID: {trace_id}"
428
- )
429
-
430
- await send(message)
431
-
432
- try:
433
- await self.app(scope, logging_receive, logging_send)
434
- except Exception as exc:
435
- duration = time.time() - start_time
436
- logger.exception(
437
- f"WS ✖ 异常: {path} | "
438
- f"时长: {duration:.1f}s | "
439
- f"收/发: {message_count['received']}/{message_count['sent']} | "
440
- f"Trace-ID: {trace_id}"
441
- )
442
- raise
443
-
444
-
445
333
  __all__ = [
446
334
  "RequestLoggingMiddleware",
447
335
  "WebSocketLoggingMiddleware",
@@ -51,13 +51,13 @@ __all__ = [
51
51
  "BaseRPCClient",
52
52
  "CompositeServiceDiscovery",
53
53
  "ConfigServiceDiscovery",
54
- "create_rpc_client",
55
54
  "DNSServiceDiscovery",
56
- "get_service_discovery",
57
55
  "RPCClient",
58
56
  "RPCError",
59
57
  "RPCResponse",
60
58
  "ServiceDiscovery",
59
+ "create_rpc_client",
60
+ "get_service_discovery",
61
61
  "set_service_discovery",
62
62
  ]
63
63