aury-boot 0.0.2__py3-none-any.whl → 0.0.3__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 (138) hide show
  1. aury/boot/__init__.py +66 -0
  2. aury/boot/_version.py +2 -2
  3. aury/boot/application/__init__.py +120 -0
  4. aury/boot/application/app/__init__.py +39 -0
  5. aury/boot/application/app/base.py +511 -0
  6. aury/boot/application/app/components.py +434 -0
  7. aury/boot/application/app/middlewares.py +101 -0
  8. aury/boot/application/config/__init__.py +44 -0
  9. aury/boot/application/config/settings.py +663 -0
  10. aury/boot/application/constants/__init__.py +19 -0
  11. aury/boot/application/constants/components.py +50 -0
  12. aury/boot/application/constants/scheduler.py +28 -0
  13. aury/boot/application/constants/service.py +29 -0
  14. aury/boot/application/errors/__init__.py +55 -0
  15. aury/boot/application/errors/chain.py +80 -0
  16. aury/boot/application/errors/codes.py +67 -0
  17. aury/boot/application/errors/exceptions.py +238 -0
  18. aury/boot/application/errors/handlers.py +320 -0
  19. aury/boot/application/errors/response.py +120 -0
  20. aury/boot/application/interfaces/__init__.py +76 -0
  21. aury/boot/application/interfaces/egress.py +224 -0
  22. aury/boot/application/interfaces/ingress.py +98 -0
  23. aury/boot/application/middleware/__init__.py +22 -0
  24. aury/boot/application/middleware/logging.py +451 -0
  25. aury/boot/application/migrations/__init__.py +13 -0
  26. aury/boot/application/migrations/manager.py +685 -0
  27. aury/boot/application/migrations/setup.py +237 -0
  28. aury/boot/application/rpc/__init__.py +63 -0
  29. aury/boot/application/rpc/base.py +108 -0
  30. aury/boot/application/rpc/client.py +294 -0
  31. aury/boot/application/rpc/discovery.py +218 -0
  32. aury/boot/application/scheduler/__init__.py +13 -0
  33. aury/boot/application/scheduler/runner.py +123 -0
  34. aury/boot/application/server/__init__.py +296 -0
  35. aury/boot/commands/__init__.py +30 -0
  36. aury/boot/commands/add.py +76 -0
  37. aury/boot/commands/app.py +105 -0
  38. aury/boot/commands/config.py +177 -0
  39. aury/boot/commands/docker.py +367 -0
  40. aury/boot/commands/docs.py +284 -0
  41. aury/boot/commands/generate.py +1277 -0
  42. aury/boot/commands/init.py +890 -0
  43. aury/boot/commands/migrate/__init__.py +37 -0
  44. aury/boot/commands/migrate/app.py +54 -0
  45. aury/boot/commands/migrate/commands.py +303 -0
  46. aury/boot/commands/scheduler.py +124 -0
  47. aury/boot/commands/server/__init__.py +21 -0
  48. aury/boot/commands/server/app.py +541 -0
  49. aury/boot/commands/templates/generate/api.py.tpl +105 -0
  50. aury/boot/commands/templates/generate/model.py.tpl +17 -0
  51. aury/boot/commands/templates/generate/repository.py.tpl +19 -0
  52. aury/boot/commands/templates/generate/schema.py.tpl +29 -0
  53. aury/boot/commands/templates/generate/service.py.tpl +48 -0
  54. aury/boot/commands/templates/project/CLI.md.tpl +92 -0
  55. aury/boot/commands/templates/project/DEVELOPMENT.md.tpl +1397 -0
  56. aury/boot/commands/templates/project/README.md.tpl +111 -0
  57. aury/boot/commands/templates/project/admin_console_init.py.tpl +50 -0
  58. aury/boot/commands/templates/project/config.py.tpl +30 -0
  59. aury/boot/commands/templates/project/conftest.py.tpl +26 -0
  60. aury/boot/commands/templates/project/env.example.tpl +213 -0
  61. aury/boot/commands/templates/project/gitignore.tpl +128 -0
  62. aury/boot/commands/templates/project/main.py.tpl +41 -0
  63. aury/boot/commands/templates/project/modules/api.py.tpl +19 -0
  64. aury/boot/commands/templates/project/modules/exceptions.py.tpl +84 -0
  65. aury/boot/commands/templates/project/modules/schedules.py.tpl +18 -0
  66. aury/boot/commands/templates/project/modules/tasks.py.tpl +20 -0
  67. aury/boot/commands/worker.py +143 -0
  68. aury/boot/common/__init__.py +35 -0
  69. aury/boot/common/exceptions/__init__.py +114 -0
  70. aury/boot/common/i18n/__init__.py +16 -0
  71. aury/boot/common/i18n/translator.py +272 -0
  72. aury/boot/common/logging/__init__.py +716 -0
  73. aury/boot/contrib/__init__.py +10 -0
  74. aury/boot/contrib/admin_console/__init__.py +18 -0
  75. aury/boot/contrib/admin_console/auth.py +137 -0
  76. aury/boot/contrib/admin_console/discovery.py +69 -0
  77. aury/boot/contrib/admin_console/install.py +172 -0
  78. aury/boot/contrib/admin_console/utils.py +44 -0
  79. aury/boot/domain/__init__.py +79 -0
  80. aury/boot/domain/exceptions/__init__.py +132 -0
  81. aury/boot/domain/models/__init__.py +51 -0
  82. aury/boot/domain/models/base.py +69 -0
  83. aury/boot/domain/models/mixins.py +135 -0
  84. aury/boot/domain/models/models.py +96 -0
  85. aury/boot/domain/pagination/__init__.py +279 -0
  86. aury/boot/domain/repository/__init__.py +23 -0
  87. aury/boot/domain/repository/impl.py +423 -0
  88. aury/boot/domain/repository/interceptors.py +47 -0
  89. aury/boot/domain/repository/interface.py +106 -0
  90. aury/boot/domain/repository/query_builder.py +348 -0
  91. aury/boot/domain/service/__init__.py +11 -0
  92. aury/boot/domain/service/base.py +73 -0
  93. aury/boot/domain/transaction/__init__.py +404 -0
  94. aury/boot/infrastructure/__init__.py +104 -0
  95. aury/boot/infrastructure/cache/__init__.py +31 -0
  96. aury/boot/infrastructure/cache/backends.py +348 -0
  97. aury/boot/infrastructure/cache/base.py +68 -0
  98. aury/boot/infrastructure/cache/exceptions.py +37 -0
  99. aury/boot/infrastructure/cache/factory.py +94 -0
  100. aury/boot/infrastructure/cache/manager.py +274 -0
  101. aury/boot/infrastructure/database/__init__.py +39 -0
  102. aury/boot/infrastructure/database/config.py +71 -0
  103. aury/boot/infrastructure/database/exceptions.py +44 -0
  104. aury/boot/infrastructure/database/manager.py +317 -0
  105. aury/boot/infrastructure/database/query_tools/__init__.py +164 -0
  106. aury/boot/infrastructure/database/strategies/__init__.py +198 -0
  107. aury/boot/infrastructure/di/__init__.py +15 -0
  108. aury/boot/infrastructure/di/container.py +393 -0
  109. aury/boot/infrastructure/events/__init__.py +33 -0
  110. aury/boot/infrastructure/events/bus.py +362 -0
  111. aury/boot/infrastructure/events/config.py +52 -0
  112. aury/boot/infrastructure/events/consumer.py +134 -0
  113. aury/boot/infrastructure/events/middleware.py +51 -0
  114. aury/boot/infrastructure/events/models.py +63 -0
  115. aury/boot/infrastructure/monitoring/__init__.py +529 -0
  116. aury/boot/infrastructure/scheduler/__init__.py +19 -0
  117. aury/boot/infrastructure/scheduler/exceptions.py +37 -0
  118. aury/boot/infrastructure/scheduler/manager.py +478 -0
  119. aury/boot/infrastructure/storage/__init__.py +38 -0
  120. aury/boot/infrastructure/storage/base.py +164 -0
  121. aury/boot/infrastructure/storage/exceptions.py +37 -0
  122. aury/boot/infrastructure/storage/factory.py +88 -0
  123. aury/boot/infrastructure/tasks/__init__.py +24 -0
  124. aury/boot/infrastructure/tasks/config.py +45 -0
  125. aury/boot/infrastructure/tasks/constants.py +37 -0
  126. aury/boot/infrastructure/tasks/exceptions.py +37 -0
  127. aury/boot/infrastructure/tasks/manager.py +490 -0
  128. aury/boot/testing/__init__.py +24 -0
  129. aury/boot/testing/base.py +122 -0
  130. aury/boot/testing/client.py +163 -0
  131. aury/boot/testing/factory.py +154 -0
  132. aury/boot/toolkit/__init__.py +21 -0
  133. aury/boot/toolkit/http/__init__.py +367 -0
  134. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/METADATA +3 -2
  135. aury_boot-0.0.3.dist-info/RECORD +137 -0
  136. aury_boot-0.0.2.dist-info/RECORD +0 -5
  137. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/WHEEL +0 -0
  138. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1277 @@
1
+ """代码生成器命令。
2
+
3
+ 生成符合 Aury 规范的代码文件:
4
+ - model: SQLAlchemy 模型
5
+ - repo: Repository 数据访问层
6
+ - service: Service 业务逻辑层
7
+ - api: FastAPI 路由
8
+ - schema: Pydantic 模型
9
+ - crud: 一键生成以上所有
10
+
11
+ 支持两种字段定义模式:
12
+ 1. 命令行参数(AI 友好):
13
+ aury generate model user email:str:unique age:int? status:str=active
14
+
15
+ 2. 交互式(人类友好):
16
+ aury generate model user -i
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ import re
24
+ from typing import Annotated
25
+
26
+ from rich.console import Console
27
+ from rich.panel import Panel
28
+ from rich.prompt import Confirm, Prompt
29
+ from rich.table import Table
30
+ import typer
31
+
32
+ from .config import get_project_config
33
+
34
+ console = Console()
35
+
36
+ # 模板目录
37
+ GENERATE_TEMPLATES_DIR = Path(__file__).parent / "templates" / "generate"
38
+
39
+ # 创建代码生成器子应用
40
+ app = typer.Typer(
41
+ name="generate",
42
+ help="代码生成器 - 生成 model/repo/service/api/schema",
43
+ no_args_is_help=True,
44
+ )
45
+
46
+
47
+ # ============================================================
48
+ # 字段解析
49
+ # ============================================================
50
+
51
+ # 类型映射:简写 -> (SQLAlchemy 类型, Pydantic 类型, 需要的导入)
52
+ TYPE_MAPPING: dict[str, tuple[str, str, list[str]]] = {
53
+ # 字符串
54
+ "str": ("String(255)", "str", ["String"]),
55
+ "string": ("String(255)", "str", ["String"]),
56
+ "text": ("Text", "str", ["Text"]),
57
+ # 数字
58
+ "int": ("Integer", "int", ["Integer"]),
59
+ "integer": ("Integer", "int", ["Integer"]),
60
+ "bigint": ("BigInteger", "int", ["BigInteger"]),
61
+ "float": ("Float", "float", ["Float"]),
62
+ "decimal": ("Numeric(10, 2)", "Decimal", ["Numeric"]),
63
+ # 布尔
64
+ "bool": ("Boolean", "bool", ["Boolean"]),
65
+ "boolean": ("Boolean", "bool", ["Boolean"]),
66
+ # 日期时间
67
+ "datetime": ("DateTime", "datetime", ["DateTime"]),
68
+ "date": ("Date", "date", ["Date"]),
69
+ "time": ("Time", "time", ["Time"]),
70
+ # JSON
71
+ "json": ("JSON", "dict", ["JSON"]),
72
+ "dict": ("JSON", "dict", ["JSON"]),
73
+ # UUID
74
+ "uuid": ("GUID", "str", []), # 使用框架内置 GUID
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class FieldDefinition:
80
+ """字段定义。"""
81
+
82
+ name: str
83
+ type_name: str = "str"
84
+ nullable: bool = False
85
+ unique: bool = False
86
+ index: bool = False
87
+ default: str | None = None
88
+ max_length: int | None = None # 用于 str 类型
89
+ comment: str | None = None
90
+
91
+ @classmethod
92
+ def parse(cls, spec: str) -> "FieldDefinition":
93
+ """解析字段定义字符串。
94
+
95
+ 格式: name:type:modifiers
96
+ 修饰符:
97
+ - ? 或 nullable: 可空
98
+ - unique: 唯一
99
+ - index: 索引
100
+ - =value: 默认值
101
+ - (length): 长度限制
102
+
103
+ 示例:
104
+ - email:str:unique
105
+ - age:int?
106
+ - status:str=active
107
+ - name:str(100)
108
+ - bio:text?
109
+ - price:decimal:index
110
+ """
111
+ parts = spec.split(":")
112
+ name = parts[0]
113
+
114
+ # 默认类型为 str
115
+ type_name = "str"
116
+ nullable = False
117
+ unique = False
118
+ index = False
119
+ default = None
120
+ max_length = None
121
+
122
+ for part in parts[1:]:
123
+ # 检查是否是类型定义
124
+ type_match = re.match(r"^([a-z]+)(\((\d+)\))?$", part.lower())
125
+ if type_match and type_match.group(1) in TYPE_MAPPING:
126
+ type_name = type_match.group(1)
127
+ if type_match.group(3):
128
+ max_length = int(type_match.group(3))
129
+ continue
130
+
131
+ # 检查修饰符
132
+ part_lower = part.lower()
133
+ if part_lower in ("?", "nullable"):
134
+ nullable = True
135
+ elif part_lower == "unique":
136
+ unique = True
137
+ elif part_lower == "index":
138
+ index = True
139
+ elif part.startswith("="):
140
+ default = part[1:]
141
+ elif part.endswith("?"):
142
+ # 处理 age:int? 这种格式
143
+ type_check = part[:-1].lower()
144
+ if type_check in TYPE_MAPPING:
145
+ type_name = type_check
146
+ nullable = True
147
+
148
+ # 处理名字后面直接跟 ? 的情况,如 "age?"
149
+ if name.endswith("?"):
150
+ name = name[:-1]
151
+ nullable = True
152
+
153
+ return cls(
154
+ name=name,
155
+ type_name=type_name,
156
+ nullable=nullable,
157
+ unique=unique,
158
+ index=index,
159
+ default=default,
160
+ max_length=max_length,
161
+ )
162
+
163
+
164
+ # 可用的模型基类
165
+ # id_type: "int" | "uuid" 用于生成正确的 Schema/Service/API 类型
166
+ MODEL_BASE_CLASSES = {
167
+ "IDOnlyModel": {
168
+ "desc": "纯 int 主键(无时间戳,适合关系表)",
169
+ "features": ["id: int"],
170
+ "id_type": "int",
171
+ "has_timestamps": False,
172
+ },
173
+ "UUIDOnlyModel": {
174
+ "desc": "纯 UUID 主键(无时间戳,适合关系表)",
175
+ "features": ["id: UUID"],
176
+ "id_type": "uuid",
177
+ "has_timestamps": False,
178
+ },
179
+ "Model": {
180
+ "desc": "标准模型(int主键 + 时间戳)",
181
+ "features": ["id: int", "created_at", "updated_at"],
182
+ "id_type": "int",
183
+ "has_timestamps": True,
184
+ },
185
+ "AuditableStateModel": {
186
+ "desc": "标准模型 + 软删除",
187
+ "features": ["id: int", "created_at", "updated_at", "deleted_at"],
188
+ "id_type": "int",
189
+ "has_timestamps": True,
190
+ },
191
+ "UUIDModel": {
192
+ "desc": "UUID 主键模型",
193
+ "features": ["id: UUID", "created_at", "updated_at"],
194
+ "id_type": "uuid",
195
+ "has_timestamps": True,
196
+ },
197
+ "UUIDAuditableStateModel": {
198
+ "desc": "UUID 主键 + 软删除(推荐)",
199
+ "features": ["id: UUID", "created_at", "updated_at", "deleted_at"],
200
+ "id_type": "uuid",
201
+ "has_timestamps": True,
202
+ },
203
+ "VersionedModel": {
204
+ "desc": "乐观锁模型(int主键 + version)",
205
+ "features": ["id: int", "version"],
206
+ "id_type": "int",
207
+ "has_timestamps": False,
208
+ },
209
+ "VersionedTimestampedModel": {
210
+ "desc": "乐观锁 + 时间戳",
211
+ "features": ["id: int", "created_at", "updated_at", "version"],
212
+ "id_type": "int",
213
+ "has_timestamps": True,
214
+ },
215
+ "VersionedUUIDModel": {
216
+ "desc": "UUID + 乐观锁 + 时间戳",
217
+ "features": ["id: UUID", "created_at", "updated_at", "version"],
218
+ "id_type": "uuid",
219
+ "has_timestamps": True,
220
+ },
221
+ "FullFeaturedModel": {
222
+ "desc": "完整功能 int版",
223
+ "features": ["id: int", "created_at", "updated_at", "deleted_at", "version"],
224
+ "id_type": "int",
225
+ "has_timestamps": True,
226
+ },
227
+ "FullFeaturedUUIDModel": {
228
+ "desc": "完整功能 UUID版",
229
+ "features": ["id: UUID", "created_at", "updated_at", "deleted_at", "version"],
230
+ "id_type": "uuid",
231
+ "has_timestamps": True,
232
+ },
233
+ }
234
+
235
+ # UUID 类型的基类名称
236
+ UUID_BASE_CLASSES = {k for k, v in MODEL_BASE_CLASSES.items() if v.get("id_type") == "uuid"}
237
+
238
+
239
+ @dataclass
240
+ class ModelDefinition:
241
+ """模型定义。"""
242
+
243
+ name: str
244
+ fields: list[FieldDefinition] = field(default_factory=list)
245
+ soft_delete: bool = True
246
+ timestamps: bool = True
247
+ base_class: str | None = None # 用户指定的基类
248
+
249
+ @property
250
+ def id_type(self) -> str:
251
+ """获取 id 类型:'int' 或 'uuid'。"""
252
+ if self.base_class:
253
+ return MODEL_BASE_CLASSES.get(self.base_class, {}).get("id_type", "uuid")
254
+ # 默认通过 soft_delete/timestamps 推断时使用 UUID
255
+ return "uuid"
256
+
257
+ @property
258
+ def id_py_type(self) -> str:
259
+ """获取 Python/Pydantic 的 id 类型。"""
260
+ return "int" if self.id_type == "int" else "UUID"
261
+
262
+ @property
263
+ def has_timestamps(self) -> bool:
264
+ """是否有时间戳字段。"""
265
+ if self.base_class:
266
+ return MODEL_BASE_CLASSES.get(self.base_class, {}).get("has_timestamps", True)
267
+ return self.timestamps
268
+
269
+ @property
270
+ def class_name(self) -> str:
271
+ """PascalCase 类名。"""
272
+ return _to_pascal_case(self.name)
273
+
274
+ @property
275
+ def file_name(self) -> str:
276
+ """snake_case 文件名。"""
277
+ return _to_snake_case(self.name)
278
+
279
+ @property
280
+ def table_name(self) -> str:
281
+ """snake_case 复数表名。"""
282
+ return _to_plural(self.file_name)
283
+
284
+ @property
285
+ def var_name(self) -> str:
286
+ """变量名。"""
287
+ return self.file_name
288
+
289
+ @property
290
+ def var_name_plural(self) -> str:
291
+ """复数变量名。"""
292
+ return _to_plural(self.file_name)
293
+
294
+
295
+ # ============================================================
296
+ # 工具函数
297
+ # ============================================================
298
+
299
+
300
+ def _to_snake_case(name: str) -> str:
301
+ """转换为 snake_case。"""
302
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
303
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
304
+
305
+
306
+ def _to_pascal_case(name: str) -> str:
307
+ """转换为 PascalCase。"""
308
+ snake = _to_snake_case(name)
309
+ return "".join(word.capitalize() for word in snake.split("_"))
310
+
311
+
312
+ def _to_plural(name: str) -> str:
313
+ """简单的复数转换。"""
314
+ if name.endswith("y"):
315
+ return name[:-1] + "ies"
316
+ if name.endswith(("s", "x", "ch", "sh")):
317
+ return name + "es"
318
+ return name + "s"
319
+
320
+
321
+ def _create_file(path: Path, content: str, force: bool = False) -> bool:
322
+ """创建文件。"""
323
+ if path.exists() and not force:
324
+ return False
325
+ path.parent.mkdir(parents=True, exist_ok=True)
326
+ path.write_text(content, encoding="utf-8")
327
+ return True
328
+
329
+
330
+ def _update_init_file(init_path: Path, import_line: str, export_name: str) -> None:
331
+ """更新 __init__.py 文件。"""
332
+ if not init_path.exists():
333
+ init_path.write_text(
334
+ f'{import_line}\n\n__all__ = ["{export_name}"]\n', encoding="utf-8"
335
+ )
336
+ return
337
+
338
+ content = init_path.read_text(encoding="utf-8")
339
+
340
+ # 检查是否已导入
341
+ if import_line in content:
342
+ return
343
+
344
+ # 添加导入
345
+ if "__all__" in content:
346
+ content = content.replace("__all__", f"{import_line}\n\n__all__")
347
+ if f'"{export_name}"' not in content:
348
+ content = content.replace("__all__ = [", f'__all__ = [\n "{export_name}",')
349
+ else:
350
+ content += f'\n{import_line}\n\n__all__ = ["{export_name}"]\n'
351
+
352
+ init_path.write_text(content, encoding="utf-8")
353
+
354
+
355
+ # ============================================================
356
+ # 交互式字段收集
357
+ # ============================================================
358
+
359
+
360
+ def _collect_base_class_interactive() -> str:
361
+ """交互式选择模型基类。"""
362
+ console.print("\n[bold cyan]📚 选择模型基类[/bold cyan]\n")
363
+
364
+ # 显示可用基类
365
+ table = Table(title="可用模型基类", show_header=True, header_style="bold magenta")
366
+ table.add_column("序号", style="dim", width=4)
367
+ table.add_column("基类名", style="cyan")
368
+ table.add_column("说明")
369
+ table.add_column("自动继承的字段")
370
+
371
+ base_names = list(MODEL_BASE_CLASSES.keys())
372
+ for i, name in enumerate(base_names, 1):
373
+ info = MODEL_BASE_CLASSES[name]
374
+ # 推荐的加标记
375
+ desc = info["desc"]
376
+ if name == "UUIDAuditableStateModel":
377
+ desc = f"[bold green]★ {desc}[/bold green]"
378
+ table.add_row(str(i), name, desc, ", ".join(info["features"]))
379
+
380
+ console.print(table)
381
+ console.print()
382
+
383
+ # 默认选择 UUIDAuditableStateModel(第 4 个)
384
+ choice = Prompt.ask(
385
+ "请选择基类序号",
386
+ default="4",
387
+ choices=[str(i) for i in range(1, len(base_names) + 1)],
388
+ )
389
+
390
+ selected = base_names[int(choice) - 1]
391
+ console.print(f" [green]✓ 已选择: {selected}[/green]\n")
392
+ return selected
393
+
394
+
395
+ def _collect_fields_interactive() -> list[FieldDefinition]:
396
+ """交互式收集字段定义。"""
397
+ fields: list[FieldDefinition] = []
398
+
399
+ console.print("\n[bold cyan]📝 添加字段[/bold cyan] (输入空名称结束)\n")
400
+
401
+ # 显示类型帮助
402
+ table = Table(title="支持的类型", show_header=True, header_style="bold magenta")
403
+ table.add_column("类型", style="cyan")
404
+ table.add_column("说明")
405
+ table.add_row("str, string", "字符串 (默认)")
406
+ table.add_row("text", "长文本")
407
+ table.add_row("int, integer", "整数")
408
+ table.add_row("bigint", "大整数")
409
+ table.add_row("float", "浮点数")
410
+ table.add_row("decimal", "精确小数")
411
+ table.add_row("bool, boolean", "布尔值")
412
+ table.add_row("datetime", "日期时间")
413
+ table.add_row("date", "日期")
414
+ table.add_row("json, dict", "JSON 对象")
415
+ console.print(table)
416
+ console.print()
417
+
418
+ while True:
419
+ name = Prompt.ask("[bold]字段名[/bold]", default="")
420
+ if not name:
421
+ break
422
+
423
+ type_name = Prompt.ask(
424
+ " 类型",
425
+ default="str",
426
+ choices=list(TYPE_MAPPING.keys()),
427
+ )
428
+
429
+ nullable = Confirm.ask(" 可空?", default=False)
430
+ unique = Confirm.ask(" 唯一?", default=False)
431
+ index = Confirm.ask(" 索引?", default=False)
432
+ default = Prompt.ask(" 默认值 (留空无默认)", default="")
433
+
434
+ max_length = None
435
+ if type_name in ("str", "string"):
436
+ length_str = Prompt.ask(" 最大长度", default="255")
437
+ max_length = int(length_str) if length_str.isdigit() else 255
438
+
439
+ fields.append(
440
+ FieldDefinition(
441
+ name=name,
442
+ type_name=type_name,
443
+ nullable=nullable,
444
+ unique=unique,
445
+ index=index,
446
+ default=default if default else None,
447
+ max_length=max_length,
448
+ )
449
+ )
450
+
451
+ console.print(f" [green]✓ 已添加: {name}:{type_name}[/green]\n")
452
+
453
+ return fields
454
+
455
+
456
+ # ============================================================
457
+ # 模板读取
458
+ # ============================================================
459
+
460
+
461
+ def _read_generate_template(name: str) -> str:
462
+ """读取代码生成模板文件。"""
463
+ template_path = GENERATE_TEMPLATES_DIR / name
464
+ if not template_path.exists():
465
+ raise FileNotFoundError(f"模板文件不存在: {name} (查找路径: {GENERATE_TEMPLATES_DIR})")
466
+ return template_path.read_text(encoding="utf-8")
467
+
468
+
469
+ def _get_base_class_from_model_file(code_root: Path, model_name: str) -> str | None:
470
+ """从已生成的模型文件中读取基类名称。
471
+
472
+ Args:
473
+ code_root: 代码根目录
474
+ model_name: 模型名称(snake_case)
475
+
476
+ Returns:
477
+ 基类名称,如果无法读取则返回 None
478
+ """
479
+ model_file = code_root / "models" / f"{model_name}.py"
480
+ if not model_file.exists():
481
+ return None
482
+
483
+ try:
484
+ content = model_file.read_text(encoding="utf-8")
485
+ # 查找继承的基类,例如: class User(UUIDAuditableStateModel):
486
+ pattern = r"class\s+\w+\s*\((\w+)\)"
487
+ match = re.search(pattern, content)
488
+ if match:
489
+ base_class = match.group(1)
490
+ # 验证是否是有效的基类
491
+ if base_class in MODEL_BASE_CLASSES:
492
+ return base_class
493
+ except Exception:
494
+ pass
495
+
496
+ return None
497
+
498
+
499
+ # ============================================================
500
+ # 模板生成
501
+ # ============================================================
502
+
503
+
504
+ def _generate_model_content(model: ModelDefinition) -> str:
505
+ """生成 Model 内容。"""
506
+ # 收集需要的导入
507
+ imports: set[str] = {"String"} # 默认总是有 String
508
+ for f in model.fields:
509
+ type_info = TYPE_MAPPING.get(f.type_name, ("String(255)", "str", ["String"]))
510
+ imports.update(type_info[2])
511
+
512
+ imports_str = ", ".join(sorted(imports))
513
+
514
+ # 生成字段定义
515
+ field_lines = []
516
+ for f in model.fields:
517
+ type_info = TYPE_MAPPING.get(f.type_name, ("String(255)", "str", ["String"]))
518
+ sa_type = type_info[0]
519
+ py_type = type_info[1]
520
+
521
+ # 处理字符串长度
522
+ if f.type_name in ("str", "string") and f.max_length:
523
+ sa_type = f"String({f.max_length})"
524
+
525
+ # 构建 Mapped 类型
526
+ if f.nullable:
527
+ mapped_type = f"Mapped[{py_type} | None]"
528
+ else:
529
+ mapped_type = f"Mapped[{py_type}]"
530
+
531
+ # 构建 mapped_column 参数
532
+ col_args = [sa_type]
533
+ if f.unique:
534
+ col_args.append("unique=True")
535
+ if f.index:
536
+ col_args.append("index=True")
537
+ if f.nullable:
538
+ col_args.append("nullable=True")
539
+ if f.default is not None:
540
+ if f.type_name in ("str", "string", "text"):
541
+ col_args.append(f'default="{f.default}"')
542
+ elif f.type_name in ("bool", "boolean"):
543
+ col_args.append(f"default={f.default.capitalize()}")
544
+ else:
545
+ col_args.append(f"default={f.default}")
546
+
547
+ col_args_str = ", ".join(col_args)
548
+ field_lines.append(f" {f.name}: {mapped_type} = mapped_column({col_args_str})")
549
+
550
+ fields_str = "\n".join(field_lines) if field_lines else " # 添加字段"
551
+
552
+ # 选择基类:优先使用用户指定的,否则根据选项推断
553
+ if model.base_class:
554
+ base_class = model.base_class
555
+ base_info = MODEL_BASE_CLASSES.get(base_class, {})
556
+ features = base_info.get("features", [])
557
+ base_doc = f"继承 {base_class} 自动获得:\n - " + "\n - ".join(features) if features else f"继承 {base_class} 基类。"
558
+ elif model.soft_delete and model.timestamps:
559
+ base_class = "UUIDAuditableStateModel"
560
+ base_doc = """继承 UUIDAuditableStateModel 自动获得:
561
+ - id: UUID 主键
562
+ - created_at: 创建时间
563
+ - updated_at: 更新时间
564
+ - deleted_at: 软删除时间戳"""
565
+ elif model.timestamps:
566
+ base_class = "UUIDModel"
567
+ base_doc = """继承 UUIDModel 自动获得:
568
+ - id: UUID 主键
569
+ - created_at: 创建时间
570
+ - updated_at: 更新时间"""
571
+ else:
572
+ base_class = "Model"
573
+ base_doc = "继承 Model 基类。"
574
+
575
+ template = _read_generate_template("model.py.tpl")
576
+ return template.format(
577
+ class_name=model.class_name,
578
+ imports_str=imports_str,
579
+ base_class=base_class,
580
+ base_doc=base_doc,
581
+ table_name=model.table_name,
582
+ fields_str=fields_str,
583
+ )
584
+
585
+
586
+ def _generate_schema_content(model: ModelDefinition) -> str:
587
+ """生成 Schema 内容。"""
588
+ # 基础字段
589
+ base_fields = []
590
+ update_fields = []
591
+
592
+ for f in model.fields:
593
+ type_info = TYPE_MAPPING.get(f.type_name, ("String(255)", "str", ["String"]))
594
+ py_type = type_info[1]
595
+
596
+ # Base 字段(Create 继承)
597
+ if f.nullable:
598
+ field_type = f"{py_type} | None"
599
+ default = "None"
600
+ elif f.default is not None:
601
+ field_type = py_type
602
+ if f.type_name in ("str", "string", "text"):
603
+ default = f'"{f.default}"'
604
+ elif f.type_name in ("bool", "boolean"):
605
+ default = f.default.capitalize()
606
+ else:
607
+ default = f.default
608
+ else:
609
+ field_type = py_type
610
+ default = "..."
611
+
612
+ # 构建 Field 参数
613
+ field_args = [default]
614
+ if f.type_name in ("str", "string") and f.max_length:
615
+ field_args.append(f"max_length={f.max_length}")
616
+ field_args.append(f'description="{f.name}"')
617
+
618
+ field_args_str = ", ".join(field_args)
619
+ base_fields.append(f" {f.name}: {field_type} = Field({field_args_str})")
620
+
621
+ # Update 字段(全部可选)
622
+ update_field_args = ["None"]
623
+ if f.type_name in ("str", "string") and f.max_length:
624
+ update_field_args.append(f"max_length={f.max_length}")
625
+ update_field_args.append(f'description="{f.name}"')
626
+ update_field_args_str = ", ".join(update_field_args)
627
+ update_fields.append(
628
+ f" {f.name}: {py_type} | None = Field({update_field_args_str})"
629
+ )
630
+
631
+ base_fields_str = "\n".join(base_fields) if base_fields else " pass"
632
+ update_fields_str = "\n".join(update_fields) if update_fields else " pass"
633
+
634
+ # Response 字段(继承 Base,添加 id 和时间戳)
635
+ id_type = model.id_py_type # "int" 或 "UUID"
636
+
637
+ # 根据是否有时间戳生成不同的 response 字段
638
+ if model.has_timestamps:
639
+ response_extra = f''' id: {id_type} = Field(..., description="ID")
640
+ created_at: datetime = Field(..., description="创建时间")
641
+ updated_at: datetime = Field(..., description="更新时间")'''
642
+ else:
643
+ response_extra = f' id: {id_type} = Field(..., description="ID")'
644
+
645
+ # 导入语句
646
+ imports = ["from datetime import datetime"] if model.has_timestamps else []
647
+ if id_type == "UUID":
648
+ imports.append("from uuid import UUID")
649
+ imports_str = "\n".join(imports)
650
+ if imports_str:
651
+ imports_str += "\n"
652
+
653
+ template = _read_generate_template("schema.py.tpl")
654
+ return template.format(
655
+ class_name=model.class_name,
656
+ imports_str=imports_str,
657
+ base_fields_str=base_fields_str,
658
+ update_fields_str=update_fields_str,
659
+ response_extra=response_extra,
660
+ )
661
+
662
+
663
+ def _generate_repository_content(model: ModelDefinition, import_prefix: str = "") -> str:
664
+ """生成 Repository 内容。
665
+
666
+ Args:
667
+ model: 模型定义
668
+ import_prefix: import 前缀,如 "mypackage." 或 ""
669
+ """
670
+ # 查找唯一字段生成 get_by_xxx 方法
671
+ get_by_methods = []
672
+ for f in model.fields:
673
+ if f.unique:
674
+ type_info = TYPE_MAPPING.get(f.type_name, ("String(255)", "str", ["String"]))
675
+ py_type = type_info[1]
676
+ get_by_methods.append(f'''
677
+ async def get_by_{f.name}(self, {f.name}: {py_type}) -> {model.class_name} | None:
678
+ """按 {f.name} 获取。"""
679
+ return await self.get_by({f.name}={f.name})''')
680
+
681
+ methods_str = "\n".join(get_by_methods) if get_by_methods else ""
682
+
683
+ template = _read_generate_template("repository.py.tpl")
684
+ return template.format(
685
+ class_name=model.class_name,
686
+ import_prefix=import_prefix,
687
+ file_name=model.file_name,
688
+ methods_str=methods_str,
689
+ )
690
+
691
+
692
+ def _generate_service_content(model: ModelDefinition, import_prefix: str = "") -> str:
693
+ """生成 Service 内容。
694
+
695
+ Args:
696
+ model: 模型定义
697
+ import_prefix: import 前缀,如 "mypackage." 或 ""
698
+ """
699
+ # 检查唯一字段,生成重复检测
700
+ unique_checks = []
701
+ for f in model.fields:
702
+ if f.unique:
703
+ unique_checks.append(
704
+ f''' # 检查 {f.name} 是否已存在
705
+ existing = await self.repo.get_by_{f.name}(data.{f.name})
706
+ if existing:
707
+ raise AlreadyExistsError(f"{model.class_name} 已存在: {{data.{f.name}}}")
708
+ '''
709
+ )
710
+
711
+ unique_check_str = "\n".join(unique_checks) if unique_checks else ""
712
+
713
+ # UUID 类型需要导入
714
+ id_type = model.id_py_type
715
+ uuid_import = "from uuid import UUID\n\n" if id_type == "UUID" else ""
716
+
717
+ template = _read_generate_template("service.py.tpl")
718
+ return template.format(
719
+ class_name=model.class_name,
720
+ uuid_import=uuid_import,
721
+ import_prefix=import_prefix,
722
+ file_name=model.file_name,
723
+ id_py_type=model.id_py_type,
724
+ unique_check_str=unique_check_str,
725
+ )
726
+
727
+
728
+ def _generate_api_content(model: ModelDefinition, import_prefix: str = "") -> str:
729
+ """生成 API 内容。
730
+
731
+ Args:
732
+ model: 模型定义
733
+ import_prefix: import 前缀,如 "mypackage." 或 ""
734
+ """
735
+ id_type = model.id_py_type # "int" 或 "UUID"
736
+
737
+ # 导入语句
738
+ imports = []
739
+ if id_type == "UUID":
740
+ imports.append("from uuid import UUID")
741
+ imports_str = "\n".join(imports)
742
+ if imports_str:
743
+ imports_str += "\n"
744
+
745
+ template = _read_generate_template("api.py.tpl")
746
+ return template.format(
747
+ class_name=model.class_name,
748
+ uuid_import=imports_str, # 模板中使用 uuid_import 占位符,但实际传入的是 imports_str(可能为空)
749
+ import_prefix=import_prefix,
750
+ file_name=model.file_name,
751
+ var_name_plural=model.var_name_plural,
752
+ var_name=model.var_name,
753
+ id_type=id_type,
754
+ )
755
+
756
+
757
+ # ============================================================
758
+ # 命令
759
+ # ============================================================
760
+
761
+
762
+ @app.command(name="model")
763
+ def generate_model(
764
+ name: str = typer.Argument(..., help="模型名称(如 user, UserProfile)"),
765
+ fields: Annotated[
766
+ list[str] | None,
767
+ typer.Argument(
768
+ help="字段定义,格式: name:type:modifiers(如 email:str:unique age:int?)"
769
+ ),
770
+ ] = None,
771
+ interactive: bool = typer.Option(
772
+ False, "--interactive", "-i", help="交互式添加字段"
773
+ ),
774
+ base: str | None = typer.Option(
775
+ None, "--base", "-b",
776
+ help="模型基类(Model/UUIDModel/UUIDAuditableStateModel/VersionedModel 等)"
777
+ ),
778
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
779
+ no_soft_delete: bool = typer.Option(False, "--no-soft-delete", help="禁用软删除"),
780
+ no_timestamps: bool = typer.Option(False, "--no-timestamps", help="禁用时间戳"),
781
+ ) -> None:
782
+ """生成 SQLAlchemy 模型。
783
+
784
+ 支持两种模式:
785
+
786
+ 1. 命令行参数(AI 友好):
787
+ aury generate model user email:str:unique age:int? status:str=active
788
+
789
+ 2. 交互式(人类友好):
790
+ aury generate model user -i
791
+
792
+ 字段语法:
793
+ name:type:modifiers
794
+
795
+ 支持的类型:
796
+ str, text, int, bigint, float, decimal, bool, datetime, date, json
797
+
798
+ 修饰符:
799
+ ? 或 nullable - 可空
800
+ unique - 唯一约束
801
+ index - 索引
802
+ =value - 默认值
803
+ (length) - 字符串长度,如 str(100)
804
+
805
+ 可用基类:
806
+ Model, AuditableStateModel, UUIDModel, UUIDAuditableStateModel,
807
+ VersionedModel, VersionedTimestampedModel, VersionedUUIDModel,
808
+ FullFeaturedModel, FullFeaturedUUIDModel
809
+
810
+ 示例:
811
+ aury generate model user
812
+ aury generate model user -b VersionedUUIDModel
813
+ aury generate model user email:str:unique age:int?
814
+ aury generate model article title:str(200) content:text status:str=draft
815
+ """
816
+ base_path = Path.cwd()
817
+
818
+ # 读取项目配置
819
+ config = get_project_config(base_path)
820
+ code_root = config.get_package_dir(base_path)
821
+
822
+ # 解析字段
823
+ field_defs: list[FieldDefinition] = []
824
+ selected_base_class: str | None = base
825
+
826
+ if interactive:
827
+ # 交互式模式:先选择基类,再添加字段
828
+ selected_base_class = _collect_base_class_interactive()
829
+ field_defs = _collect_fields_interactive()
830
+ elif fields:
831
+ for spec in fields:
832
+ try:
833
+ field_defs.append(FieldDefinition.parse(spec))
834
+ except Exception as e:
835
+ console.print(f"[red]❌ 解析字段失败: {spec} - {e}[/red]")
836
+ raise typer.Exit(1) from e
837
+
838
+ # 验证基类名称
839
+ if selected_base_class and selected_base_class not in MODEL_BASE_CLASSES:
840
+ console.print(f"[red]❌ 无效的基类: {selected_base_class}[/red]")
841
+ console.print(f"[dim]可用基类: {', '.join(MODEL_BASE_CLASSES.keys())}[/dim]")
842
+ raise typer.Exit(1)
843
+
844
+ model = ModelDefinition(
845
+ name=name,
846
+ fields=field_defs,
847
+ soft_delete=not no_soft_delete,
848
+ timestamps=not no_timestamps,
849
+ base_class=selected_base_class,
850
+ )
851
+
852
+ content = _generate_model_content(model)
853
+ file_path = code_root / "models" / f"{model.file_name}.py"
854
+ rel_path = file_path.relative_to(base_path)
855
+
856
+ if _create_file(file_path, content, force):
857
+ console.print(f"[green]✅ 创建模型: {rel_path}[/green]")
858
+ _update_init_file(
859
+ code_root / "models" / "__init__.py",
860
+ f"from .{model.file_name} import {model.class_name}",
861
+ model.class_name,
862
+ )
863
+ else:
864
+ console.print(
865
+ f"[yellow]⚠️ 文件已存在: {rel_path}(使用 --force 覆盖)[/yellow]"
866
+ )
867
+
868
+
869
+ @app.command(name="repo")
870
+ def generate_repo(
871
+ name: str = typer.Argument(..., help="模型名称"),
872
+ fields: Annotated[
873
+ list[str] | None,
874
+ typer.Argument(help="字段定义(用于生成 get_by_xxx 方法)"),
875
+ ] = None,
876
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
877
+ ) -> None:
878
+ """生成 Repository 数据访问层。
879
+
880
+ 示例:
881
+ aury generate repo user
882
+ aury generate repo user email:str:unique # 生成 get_by_email 方法
883
+ """
884
+ base_path = Path.cwd()
885
+
886
+ # 读取项目配置
887
+ config = get_project_config(base_path)
888
+ code_root = config.get_package_dir(base_path)
889
+ import_prefix = config.get_import_prefix()
890
+
891
+ field_defs = [FieldDefinition.parse(spec) for spec in (fields or [])]
892
+ # 尝试从已生成的模型文件中读取基类信息
893
+ base_class = _get_base_class_from_model_file(code_root, _to_snake_case(name))
894
+ model = ModelDefinition(name=name, fields=field_defs, base_class=base_class)
895
+
896
+ content = _generate_repository_content(model, import_prefix)
897
+ file_path = code_root / "repositories" / f"{model.file_name}_repository.py"
898
+ rel_path = file_path.relative_to(base_path)
899
+
900
+ if _create_file(file_path, content, force):
901
+ console.print(f"[green]✅ 创建仓储: {rel_path}[/green]")
902
+ _update_init_file(
903
+ code_root / "repositories" / "__init__.py",
904
+ f"from .{model.file_name}_repository import {model.class_name}Repository",
905
+ f"{model.class_name}Repository",
906
+ )
907
+ else:
908
+ console.print("[yellow]⚠️ 文件已存在(使用 --force 覆盖)[/yellow]")
909
+
910
+
911
+ @app.command(name="service")
912
+ def generate_service(
913
+ name: str = typer.Argument(..., help="模型名称"),
914
+ fields: Annotated[
915
+ list[str] | None,
916
+ typer.Argument(help="字段定义(用于生成重复检测)"),
917
+ ] = None,
918
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
919
+ ) -> None:
920
+ """生成 Service 业务逻辑层。
921
+
922
+ 示例:
923
+ aury generate service user
924
+ aury generate service user email:str:unique # 创建时检查 email 重复
925
+ """
926
+ base_path = Path.cwd()
927
+
928
+ # 读取项目配置
929
+ config = get_project_config(base_path)
930
+ code_root = config.get_package_dir(base_path)
931
+ import_prefix = config.get_import_prefix()
932
+
933
+ field_defs = [FieldDefinition.parse(spec) for spec in (fields or [])]
934
+ # 尝试从已生成的模型文件中读取基类信息
935
+ base_class = _get_base_class_from_model_file(code_root, _to_snake_case(name))
936
+ model = ModelDefinition(name=name, fields=field_defs, base_class=base_class)
937
+
938
+ content = _generate_service_content(model, import_prefix)
939
+ file_path = code_root / "services" / f"{model.file_name}_service.py"
940
+ rel_path = file_path.relative_to(base_path)
941
+
942
+ if _create_file(file_path, content, force):
943
+ console.print(f"[green]✅ 创建服务: {rel_path}[/green]")
944
+ _update_init_file(
945
+ code_root / "services" / "__init__.py",
946
+ f"from .{model.file_name}_service import {model.class_name}Service",
947
+ f"{model.class_name}Service",
948
+ )
949
+ else:
950
+ console.print("[yellow]⚠️ 文件已存在(使用 --force 覆盖)[/yellow]")
951
+
952
+
953
+ def _register_router_in_api_init(code_root: Path, model: ModelDefinition) -> bool:
954
+ """自动在 api/__init__.py 中注册路由。
955
+
956
+ Args:
957
+ code_root: 代码根目录(包含 api/ 的目录)
958
+ model: 模型定义
959
+
960
+ Returns:
961
+ 是否成功注册
962
+ """
963
+ api_init_path = code_root / "api" / "__init__.py"
964
+ if not api_init_path.exists():
965
+ return False
966
+
967
+ content = api_init_path.read_text(encoding="utf-8")
968
+
969
+ # 检查是否已经注册
970
+ import_line = f"from . import {model.file_name}"
971
+ router_line = f"router.include_router({model.file_name}.router)"
972
+
973
+ if import_line in content or f"{model.file_name}.router" in content:
974
+ return False # 已经注册
975
+
976
+ # 查找插入位置:在 "# 注册子路由" 标记之后
977
+ marker = "# 注册子路由"
978
+
979
+ if marker not in content:
980
+ return False
981
+
982
+ try:
983
+ # 在标记之后插入
984
+ lines = content.split("\n")
985
+ new_lines = []
986
+ inserted = False
987
+
988
+ for line in lines:
989
+ new_lines.append(line)
990
+ if marker in line and not inserted:
991
+ new_lines.append(import_line)
992
+ new_lines.append(router_line)
993
+ inserted = True
994
+
995
+ if inserted:
996
+ api_init_path.write_text("\n".join(new_lines), encoding="utf-8")
997
+ return True
998
+ except Exception:
999
+ pass # 插入失败不影响主流程
1000
+
1001
+ return False
1002
+
1003
+
1004
+ @app.command(name="api")
1005
+ def generate_api(
1006
+ name: str = typer.Argument(..., help="模型名称"),
1007
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
1008
+ no_register: bool = typer.Option(False, "--no-register", help="不自动注册到 api/__init__.py"),
1009
+ ) -> None:
1010
+ """生成 FastAPI 路由。
1011
+
1012
+ 示例:
1013
+ aury generate api user
1014
+ aury generate api user --no-register # 不自动注册到 api/__init__.py
1015
+ """
1016
+ base_path = Path.cwd()
1017
+
1018
+ # 读取项目配置
1019
+ config = get_project_config(base_path)
1020
+ code_root = config.get_package_dir(base_path)
1021
+ import_prefix = config.get_import_prefix()
1022
+
1023
+ # 尝试从已生成的模型文件中读取基类信息
1024
+ base_class = _get_base_class_from_model_file(code_root, _to_snake_case(name))
1025
+ model = ModelDefinition(name=name, base_class=base_class)
1026
+
1027
+ content = _generate_api_content(model, import_prefix)
1028
+ file_path = code_root / "api" / f"{model.file_name}.py"
1029
+ rel_path = file_path.relative_to(base_path)
1030
+
1031
+ if _create_file(file_path, content, force):
1032
+ console.print(f"[green]✅ 创建 API: {rel_path}[/green]")
1033
+
1034
+ # 自动注册到 api/__init__.py
1035
+ if not no_register:
1036
+ if _register_router_in_api_init(code_root, model):
1037
+ console.print("[green]✅ 已自动注册到 api/__init__.py[/green]")
1038
+ else:
1039
+ console.print("[dim]提示: 请在 api/__init__.py 中注册路由:[/dim]")
1040
+ console.print(f"[dim] from . import {model.file_name}[/dim]")
1041
+ console.print(f"[dim] router.include_router({model.file_name}.router)[/dim]")
1042
+ else:
1043
+ console.print("[yellow]⚠️ 文件已存在(使用 --force 覆盖)[/yellow]")
1044
+
1045
+
1046
+ @app.command(name="schema")
1047
+ def generate_schema(
1048
+ name: str = typer.Argument(..., help="模型名称"),
1049
+ fields: Annotated[
1050
+ list[str] | None,
1051
+ typer.Argument(help="字段定义"),
1052
+ ] = None,
1053
+ interactive: bool = typer.Option(
1054
+ False, "--interactive", "-i", help="交互式添加字段"
1055
+ ),
1056
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
1057
+ ) -> None:
1058
+ """生成 Pydantic Schema。
1059
+
1060
+ 示例:
1061
+ aury generate schema user
1062
+ aury generate schema user email:str:unique age:int?
1063
+ """
1064
+ base_path = Path.cwd()
1065
+
1066
+ # 读取项目配置
1067
+ config = get_project_config(base_path)
1068
+ code_root = config.get_package_dir(base_path)
1069
+
1070
+ field_defs: list[FieldDefinition] = []
1071
+ if interactive:
1072
+ field_defs = _collect_fields_interactive()
1073
+ elif fields:
1074
+ field_defs = [FieldDefinition.parse(spec) for spec in fields]
1075
+
1076
+ # 尝试从已生成的模型文件中读取基类信息
1077
+ base_class = _get_base_class_from_model_file(code_root, _to_snake_case(name))
1078
+ model = ModelDefinition(name=name, fields=field_defs, base_class=base_class)
1079
+
1080
+ content = _generate_schema_content(model)
1081
+ file_path = code_root / "schemas" / f"{model.file_name}.py"
1082
+ rel_path = file_path.relative_to(base_path)
1083
+
1084
+ if _create_file(file_path, content, force):
1085
+ console.print(f"[green]✅ 创建 Schema: {rel_path}[/green]")
1086
+ _update_init_file(
1087
+ code_root / "schemas" / "__init__.py",
1088
+ f"from .{model.file_name} import {model.class_name}Create, {model.class_name}Response, {model.class_name}Update",
1089
+ f"{model.class_name}Create",
1090
+ )
1091
+ else:
1092
+ console.print("[yellow]⚠️ 文件已存在(使用 --force 覆盖)[/yellow]")
1093
+
1094
+
1095
+ @app.command(name="crud")
1096
+ def generate_crud(
1097
+ name: str = typer.Argument(..., help="模型名称"),
1098
+ fields: Annotated[
1099
+ list[str] | None,
1100
+ typer.Argument(help="字段定义"),
1101
+ ] = None,
1102
+ interactive: bool = typer.Option(
1103
+ False, "--interactive", "-i", help="交互式添加字段"
1104
+ ),
1105
+ base: str | None = typer.Option(
1106
+ None, "--base", "-b",
1107
+ help="模型基类(Model/UUIDModel/UUIDAuditableStateModel/VersionedModel 等)"
1108
+ ),
1109
+ force: bool = typer.Option(False, "--force", "-f", help="强制覆盖"),
1110
+ no_soft_delete: bool = typer.Option(False, "--no-soft-delete", help="禁用软删除"),
1111
+ no_timestamps: bool = typer.Option(False, "--no-timestamps", help="禁用时间戳"),
1112
+ ) -> None:
1113
+ """一键生成完整 CRUD(model + repo + service + api + schema)。
1114
+
1115
+ 支持两种模式:
1116
+
1117
+ 1. 命令行参数(AI 友好):
1118
+ aury generate crud user email:str:unique age:int? status:str=active
1119
+
1120
+ 2. 交互式(人类友好):
1121
+ aury generate crud user -i
1122
+
1123
+ 示例:
1124
+ aury generate crud user
1125
+ aury generate crud user --base Model # 使用 int 主键
1126
+ aury generate crud user --base UUIDAuditableStateModel # 使用 UUID 主键(推荐)
1127
+ aury generate crud user email:str:unique age:int? --force
1128
+ aury generate crud article title:str(200) content:text status:str=draft
1129
+ """
1130
+ base_path = Path.cwd()
1131
+
1132
+ # 读取项目配置
1133
+ config = get_project_config(base_path)
1134
+ code_root = config.get_package_dir(base_path)
1135
+
1136
+ # 解析字段和基类
1137
+ field_defs: list[FieldDefinition] = []
1138
+ selected_base_class: str | None = base
1139
+
1140
+ if interactive:
1141
+ # 交互式模式:先选择基类,再添加字段
1142
+ selected_base_class = _collect_base_class_interactive()
1143
+ field_defs = _collect_fields_interactive()
1144
+ elif fields:
1145
+ for spec in fields:
1146
+ try:
1147
+ field_defs.append(FieldDefinition.parse(spec))
1148
+ except Exception as e:
1149
+ console.print(f"[red]❌ 解析字段失败: {spec} - {e}[/red]")
1150
+ raise typer.Exit(1) from e
1151
+
1152
+ # 验证基类名称
1153
+ if selected_base_class and selected_base_class not in MODEL_BASE_CLASSES:
1154
+ console.print(f"[red]❌ 无效的基类: {selected_base_class}[/red]")
1155
+ console.print(f"[dim]可用基类: {', '.join(MODEL_BASE_CLASSES.keys())}[/dim]")
1156
+ raise typer.Exit(1)
1157
+
1158
+ model = ModelDefinition(
1159
+ name=name,
1160
+ fields=field_defs,
1161
+ soft_delete=not no_soft_delete,
1162
+ timestamps=not no_timestamps,
1163
+ base_class=selected_base_class,
1164
+ )
1165
+
1166
+ console.print(
1167
+ Panel.fit(
1168
+ f"[bold cyan]⚡ 生成 CRUD: {model.class_name}[/bold cyan]",
1169
+ border_style="cyan",
1170
+ )
1171
+ )
1172
+
1173
+ # 显示字段信息
1174
+ if model.fields:
1175
+ console.print("\n[bold]字段列表:[/bold]")
1176
+ for f in model.fields:
1177
+ modifiers = []
1178
+ if f.nullable:
1179
+ modifiers.append("nullable")
1180
+ if f.unique:
1181
+ modifiers.append("unique")
1182
+ if f.index:
1183
+ modifiers.append("index")
1184
+ if f.default:
1185
+ modifiers.append(f"default={f.default}")
1186
+ mod_str = f" [{', '.join(modifiers)}]" if modifiers else ""
1187
+ console.print(f" • {f.name}: {f.type_name}{mod_str}")
1188
+
1189
+ console.print()
1190
+
1191
+ # 获取 import_prefix(项目配置已在前面读取)
1192
+ import_prefix = config.get_import_prefix()
1193
+
1194
+ # 生成所有文件,直接使用已创建的 ModelDefinition 对象,确保基类信息一致
1195
+ # 1. 生成 Model
1196
+ model_content = _generate_model_content(model)
1197
+ model_file_path = code_root / "models" / f"{model.file_name}.py"
1198
+ model_rel_path = model_file_path.relative_to(base_path)
1199
+ if _create_file(model_file_path, model_content, force):
1200
+ console.print(f"[green]✅ 创建模型: {model_rel_path}[/green]")
1201
+ _update_init_file(
1202
+ code_root / "models" / "__init__.py",
1203
+ f"from .{model.file_name} import {model.class_name}",
1204
+ model.class_name,
1205
+ )
1206
+ else:
1207
+ console.print(f"[yellow]⚠️ 文件已存在: {model_rel_path}(使用 --force 覆盖)[/yellow]")
1208
+
1209
+ # 2. 生成 Repository
1210
+ repo_content = _generate_repository_content(model, import_prefix)
1211
+ repo_file_path = code_root / "repositories" / f"{model.file_name}_repository.py"
1212
+ repo_rel_path = repo_file_path.relative_to(base_path)
1213
+ if _create_file(repo_file_path, repo_content, force):
1214
+ console.print(f"[green]✅ 创建仓储: {repo_rel_path}[/green]")
1215
+ _update_init_file(
1216
+ code_root / "repositories" / "__init__.py",
1217
+ f"from .{model.file_name}_repository import {model.class_name}Repository",
1218
+ f"{model.class_name}Repository",
1219
+ )
1220
+ else:
1221
+ console.print(f"[yellow]⚠️ 文件已存在: {repo_rel_path}(使用 --force 覆盖)[/yellow]")
1222
+
1223
+ # 3. 生成 Service
1224
+ service_content = _generate_service_content(model, import_prefix)
1225
+ service_file_path = code_root / "services" / f"{model.file_name}_service.py"
1226
+ service_rel_path = service_file_path.relative_to(base_path)
1227
+ if _create_file(service_file_path, service_content, force):
1228
+ console.print(f"[green]✅ 创建服务: {service_rel_path}[/green]")
1229
+ _update_init_file(
1230
+ code_root / "services" / "__init__.py",
1231
+ f"from .{model.file_name}_service import {model.class_name}Service",
1232
+ f"{model.class_name}Service",
1233
+ )
1234
+ else:
1235
+ console.print(f"[yellow]⚠️ 文件已存在: {service_rel_path}(使用 --force 覆盖)[/yellow]")
1236
+
1237
+ # 4. 生成 Schema
1238
+ schema_content = _generate_schema_content(model)
1239
+ schema_file_path = code_root / "schemas" / f"{model.file_name}.py"
1240
+ schema_rel_path = schema_file_path.relative_to(base_path)
1241
+ if _create_file(schema_file_path, schema_content, force):
1242
+ console.print(f"[green]✅ 创建 Schema: {schema_rel_path}[/green]")
1243
+ _update_init_file(
1244
+ code_root / "schemas" / "__init__.py",
1245
+ f"from .{model.file_name} import {model.class_name}Create, {model.class_name}Response, {model.class_name}Update",
1246
+ f"{model.class_name}Create",
1247
+ )
1248
+ else:
1249
+ console.print(f"[yellow]⚠️ 文件已存在: {schema_rel_path}(使用 --force 覆盖)[/yellow]")
1250
+
1251
+ # 5. 生成 API
1252
+ api_content = _generate_api_content(model, import_prefix)
1253
+ api_file_path = code_root / "api" / f"{model.file_name}.py"
1254
+ api_rel_path = api_file_path.relative_to(base_path)
1255
+ if _create_file(api_file_path, api_content, force):
1256
+ console.print(f"[green]✅ 创建 API: {api_rel_path}[/green]")
1257
+ # 自动注册到 api/__init__.py
1258
+ if _register_router_in_api_init(code_root, model):
1259
+ console.print("[green]✅ 已自动注册到 api/__init__.py[/green]")
1260
+ else:
1261
+ console.print("[dim]提示: 请在 api/__init__.py 中注册路由:[/dim]")
1262
+ console.print(f"[dim] from . import {model.file_name}[/dim]")
1263
+ console.print(f"[dim] router.include_router({model.file_name}.router)[/dim]")
1264
+ else:
1265
+ console.print(f"[yellow]⚠️ 文件已存在: {api_rel_path}(使用 --force 覆盖)[/yellow]")
1266
+
1267
+ console.print()
1268
+ console.print("[bold green]✨ CRUD 生成完成![/bold green]")
1269
+ console.print()
1270
+ console.print("[bold]下一步:[/bold]")
1271
+ console.print(" 1. 生成数据库迁移:")
1272
+ console.print(f' [cyan]aury migrate make -m "add {model.file_name} table"[/cyan]')
1273
+ console.print(" 2. 执行迁移:")
1274
+ console.print(" [cyan]aury migrate up[/cyan]")
1275
+
1276
+
1277
+ __all__ = ["app"]