create-dwy 0.1.0
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.
- package/bin/index.js +31 -0
- package/package.json +29 -0
- package/src/create.js +93 -0
- package/src/sync.js +75 -0
- package/src/utils.js +61 -0
- package/templates/claude-global/commands/.gitkeep +0 -0
- package/templates/claude-global/hooks/.gitkeep +0 -0
- package/templates/claude-global/rules/git-security.md +144 -0
- package/templates/claude-global/rules/python-code-style.md +875 -0
- package/templates/claude-global/rules/server-security.md +376 -0
- package/templates/claude-global/skills/.gitkeep +0 -0
- package/templates/project/web/.claude/commands/deploy.md +4 -0
- package/templates/project/web/.dwy.hbs +5 -0
- package/templates/project/web/CLAUDE.md.hbs +52 -0
- package/templates/project/web/backend/.python-version +1 -0
- package/templates/project/web/backend/Dockerfile +17 -0
- package/templates/project/web/backend/alembic/env.py.hbs +44 -0
- package/templates/project/web/backend/alembic/script.py.mako +24 -0
- package/templates/project/web/backend/alembic/versions/.gitkeep +0 -0
- package/templates/project/web/backend/alembic.ini.hbs +36 -0
- package/templates/project/web/backend/pyproject.toml.hbs +52 -0
- package/templates/project/web/backend/src/app/__init__.py +0 -0
- package/templates/project/web/backend/src/app/config.py.hbs +12 -0
- package/templates/project/web/backend/src/app/main.py.hbs +40 -0
- package/templates/project/web/backend/src/app/models/__init__.py +0 -0
- package/templates/project/web/backend/src/app/models/base.py +5 -0
- package/templates/project/web/backend/src/app/routers/__init__.py +0 -0
- package/templates/project/web/backend/src/app/routers/health.py +11 -0
- package/templates/project/web/backend/src/app/schemas/__init__.py +0 -0
- package/templates/project/web/backend/src/app/services/__init__.py +0 -0
- package/templates/project/web/backend/tests/__init__.py +0 -0
- package/templates/project/web/backend/tests/conftest.py.hbs +16 -0
- package/templates/project/web/deploy.sh.hbs +113 -0
- package/templates/project/web/docker-compose.dev.yml.hbs +19 -0
- package/templates/project/web/docker-compose.prod.yml.hbs +31 -0
- package/templates/project/web/frontend/index.html.hbs +12 -0
- package/templates/project/web/frontend/package.json.hbs +28 -0
- package/templates/project/web/frontend/src/App.vue +3 -0
- package/templates/project/web/frontend/src/api/client.ts +13 -0
- package/templates/project/web/frontend/src/components/.gitkeep +0 -0
- package/templates/project/web/frontend/src/main.ts +10 -0
- package/templates/project/web/frontend/src/router/index.ts +14 -0
- package/templates/project/web/frontend/src/stores/auth.ts +20 -0
- package/templates/project/web/frontend/src/style.css +1 -0
- package/templates/project/web/frontend/src/views/HomeView.vue +5 -0
- package/templates/project/web/frontend/tsconfig.app.json +14 -0
- package/templates/project/web/frontend/tsconfig.json +4 -0
- package/templates/project/web/frontend/vite.config.ts.hbs +16 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-code-style
|
|
3
|
+
description: Python + FastAPI 代码规范
|
|
4
|
+
type: claude-rule
|
|
5
|
+
tags: [python, fastapi, code-style]
|
|
6
|
+
version: 1.0.0
|
|
7
|
+
author: chances
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Python + FastAPI 代码规范
|
|
11
|
+
|
|
12
|
+
所有 Python / FastAPI 项目必须遵循以下规范。AI 生成或修改代码时,必须严格按照这些规则执行。
|
|
13
|
+
|
|
14
|
+
## 一、项目结构
|
|
15
|
+
|
|
16
|
+
### 强制规则
|
|
17
|
+
- 使用 `pyproject.toml` 作为项目元数据和依赖管理的唯一入口(不再使用 `setup.py` / `setup.cfg`)
|
|
18
|
+
- 推荐使用 `uv` 管理依赖,次选 `pip` + `requirements.txt`
|
|
19
|
+
- 必须有 `.gitignore`(至少包含 `__pycache__/`、`*.pyc`、`.env`、`dist/`、`*.egg-info/`)
|
|
20
|
+
- 必须有 `.python-version` 文件声明 Python 版本
|
|
21
|
+
|
|
22
|
+
### 标准目录结构
|
|
23
|
+
```
|
|
24
|
+
project/
|
|
25
|
+
├── src/
|
|
26
|
+
│ └── app/
|
|
27
|
+
│ ├── __init__.py
|
|
28
|
+
│ ├── main.py # FastAPI 应用入口
|
|
29
|
+
│ ├── config.py # Settings 配置
|
|
30
|
+
│ ├── dependencies.py # 全局依赖(数据库会话、认证等)
|
|
31
|
+
│ ├── exceptions.py # 自定义异常 + 异常处理器
|
|
32
|
+
│ ├── models/ # SQLAlchemy / ORM 模型
|
|
33
|
+
│ │ ├── __init__.py
|
|
34
|
+
│ │ └── user.py
|
|
35
|
+
│ ├── schemas/ # Pydantic 请求/响应 schema
|
|
36
|
+
│ │ ├── __init__.py
|
|
37
|
+
│ │ └── user.py
|
|
38
|
+
│ ├── routers/ # 路由模块(按业务域拆分)
|
|
39
|
+
│ │ ├── __init__.py
|
|
40
|
+
│ │ └── user.py
|
|
41
|
+
│ ├── services/ # 业务逻辑层
|
|
42
|
+
│ │ ├── __init__.py
|
|
43
|
+
│ │ └── user.py
|
|
44
|
+
│ └── utils/ # 工具函数
|
|
45
|
+
│ └── __init__.py
|
|
46
|
+
├── tests/
|
|
47
|
+
│ ├── __init__.py
|
|
48
|
+
│ ├── conftest.py
|
|
49
|
+
│ └── test_*.py
|
|
50
|
+
├── alembic/ # 数据库迁移
|
|
51
|
+
│ ├── env.py
|
|
52
|
+
│ └── versions/
|
|
53
|
+
├── alembic.ini
|
|
54
|
+
├── pyproject.toml
|
|
55
|
+
├── Dockerfile
|
|
56
|
+
├── docker-compose.yml
|
|
57
|
+
├── .python-version
|
|
58
|
+
├── .env.example
|
|
59
|
+
└── .gitignore
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- 小型脚本/单文件项目可以不使用 `src/` 布局
|
|
63
|
+
- 测试文件统一放在 `tests/` 目录,文件名以 `test_` 开头
|
|
64
|
+
|
|
65
|
+
### 分层原则
|
|
66
|
+
|
|
67
|
+
| 层 | 职责 | 禁止 |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `routers/` | 接收请求、参数校验、调用 service、返回响应 | 不写业务逻辑、不直接操作数据库 |
|
|
70
|
+
| `services/` | 业务逻辑、事务编排 | 不感知 HTTP 概念(Request/Response/状态码) |
|
|
71
|
+
| `models/` | ORM 模型定义 | 不包含业务逻辑 |
|
|
72
|
+
| `schemas/` | Pydantic 请求/响应模型 | 不依赖 ORM 模型 |
|
|
73
|
+
| `dependencies.py` | FastAPI 依赖注入(DB session、当前用户等) | 不写业务逻辑 |
|
|
74
|
+
|
|
75
|
+
## 二、Python 版本与兼容性
|
|
76
|
+
|
|
77
|
+
### 强制规则
|
|
78
|
+
- 新项目最低支持 Python 3.10+
|
|
79
|
+
- 使用现代语法特性:
|
|
80
|
+
|
|
81
|
+
| 旧写法 | 新写法(强制) |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `typing.List[int]` | `list[int]` |
|
|
84
|
+
| `typing.Dict[str, int]` | `dict[str, int]` |
|
|
85
|
+
| `typing.Optional[str]` | `str \| None` |
|
|
86
|
+
| `typing.Union[str, int]` | `str \| int` |
|
|
87
|
+
| `typing.Tuple[int, ...]` | `tuple[int, ...]` |
|
|
88
|
+
| `from __future__ import annotations` | 不需要(3.10+ 原生支持) |
|
|
89
|
+
|
|
90
|
+
## 三、代码风格
|
|
91
|
+
|
|
92
|
+
### 格式化与 Lint
|
|
93
|
+
- 使用 `ruff` 作为唯一的 linter + formatter(替代 flake8 / black / isort)
|
|
94
|
+
- `pyproject.toml` 中的最小 ruff 配置:
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
[tool.ruff]
|
|
98
|
+
target-version = "py310"
|
|
99
|
+
line-length = 120
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint]
|
|
102
|
+
select = [
|
|
103
|
+
"E", # pycodestyle errors
|
|
104
|
+
"W", # pycodestyle warnings
|
|
105
|
+
"F", # pyflakes
|
|
106
|
+
"I", # isort
|
|
107
|
+
"N", # pep8-naming
|
|
108
|
+
"UP", # pyupgrade
|
|
109
|
+
"B", # flake8-bugbear
|
|
110
|
+
"SIM", # flake8-simplify
|
|
111
|
+
"RUF", # ruff-specific
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.lint.isort]
|
|
115
|
+
known-first-party = ["app"]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 命名规范
|
|
119
|
+
|
|
120
|
+
| 类型 | 风格 | 示例 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| 模块/包 | `snake_case` | `user_service.py` |
|
|
123
|
+
| 函数/方法 | `snake_case` | `get_user_by_id()` |
|
|
124
|
+
| 变量 | `snake_case` | `user_count` |
|
|
125
|
+
| 类 | `PascalCase` | `UserService` |
|
|
126
|
+
| 常量 | `UPPER_SNAKE_CASE` | `MAX_RETRY_COUNT` |
|
|
127
|
+
| 私有属性/方法 | `_leading_underscore` | `_parse_token()` |
|
|
128
|
+
| 类型变量 | `PascalCase` | `T = TypeVar("T")` |
|
|
129
|
+
| 协议/抽象基类 | `PascalCase` + 语义后缀 | `Serializable`、`BaseRepository` |
|
|
130
|
+
| 路由函数 | `snake_case` 动词开头 | `create_user()`、`list_orders()` |
|
|
131
|
+
| Pydantic Schema | `PascalCase` + 用途后缀 | `UserCreate`、`UserResponse`、`UserUpdate` |
|
|
132
|
+
|
|
133
|
+
### 禁止的写法
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# ❌ 单字母变量(循环索引除外)
|
|
137
|
+
d = get_data()
|
|
138
|
+
|
|
139
|
+
# ✅ 有意义的名称
|
|
140
|
+
user_data = get_data()
|
|
141
|
+
|
|
142
|
+
# ❌ 匈牙利命名法
|
|
143
|
+
str_name = "Alice"
|
|
144
|
+
lst_users = []
|
|
145
|
+
|
|
146
|
+
# ✅ 直接命名
|
|
147
|
+
name = "Alice"
|
|
148
|
+
users = []
|
|
149
|
+
|
|
150
|
+
# ❌ 布尔变量不带 is/has/can/should 前缀
|
|
151
|
+
active = True
|
|
152
|
+
|
|
153
|
+
# ✅ 布尔变量语义明确
|
|
154
|
+
is_active = True
|
|
155
|
+
has_permission = False
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 四、类型标注
|
|
159
|
+
|
|
160
|
+
### 强制规则
|
|
161
|
+
- 所有公开函数/方法**必须**有参数和返回值类型标注
|
|
162
|
+
- 私有函数**推荐**有类型标注
|
|
163
|
+
- 类属性**必须**有类型标注
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# ❌ 缺少类型标注
|
|
167
|
+
def get_user(user_id):
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
# ✅ 完整标注
|
|
171
|
+
def get_user(user_id: int) -> User | None:
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
# ✅ 复杂类型使用 TypeAlias
|
|
175
|
+
type UserMap = dict[str, list[User]]
|
|
176
|
+
|
|
177
|
+
# ✅ 数据类必须标注
|
|
178
|
+
@dataclass
|
|
179
|
+
class UserProfile:
|
|
180
|
+
name: str
|
|
181
|
+
age: int
|
|
182
|
+
email: str | None = None
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 禁止 `Any`
|
|
186
|
+
- 不得使用 `Any` 作为"懒标注",除非与外部动态库交互且无法确定类型
|
|
187
|
+
- 使用 `Any` 时必须附注释说明原因
|
|
188
|
+
|
|
189
|
+
## 五、FastAPI 路由与接口设计
|
|
190
|
+
|
|
191
|
+
### 路由组织
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# routers/user.py
|
|
195
|
+
from fastapi import APIRouter, Depends, status
|
|
196
|
+
|
|
197
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
198
|
+
|
|
199
|
+
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
|
|
200
|
+
async def create_user(
|
|
201
|
+
body: UserCreate,
|
|
202
|
+
service: UserService = Depends(get_user_service),
|
|
203
|
+
) -> UserResponse:
|
|
204
|
+
return await service.create(body)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### 强制规则
|
|
208
|
+
- 每个路由模块使用独立 `APIRouter`,在 `main.py` 中统一注册
|
|
209
|
+
- **禁止**所有路由写在 `main.py` 中(超过 2 个路由必须拆分)
|
|
210
|
+
- 路由函数只做:参数接收 → 调用 service → 返回结果,**不写业务逻辑**
|
|
211
|
+
- 必须显式声明 `status_code` 和 `response_model`
|
|
212
|
+
- 路由路径使用复数名词 + RESTful 风格
|
|
213
|
+
|
|
214
|
+
### RESTful 路由规范
|
|
215
|
+
|
|
216
|
+
| 操作 | 方法 | 路径 | 状态码 |
|
|
217
|
+
|---|---|---|---|
|
|
218
|
+
| 列表查询 | `GET` | `/users` | 200 |
|
|
219
|
+
| 详情查询 | `GET` | `/users/{user_id}` | 200 |
|
|
220
|
+
| 创建 | `POST` | `/users` | 201 |
|
|
221
|
+
| 全量更新 | `PUT` | `/users/{user_id}` | 200 |
|
|
222
|
+
| 部分更新 | `PATCH` | `/users/{user_id}` | 200 |
|
|
223
|
+
| 删除 | `DELETE` | `/users/{user_id}` | 204 |
|
|
224
|
+
|
|
225
|
+
### 分页查询
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# schemas/common.py
|
|
229
|
+
class PaginationParams(BaseModel):
|
|
230
|
+
page: int = Field(default=1, ge=1)
|
|
231
|
+
page_size: int = Field(default=20, ge=1, le=100)
|
|
232
|
+
|
|
233
|
+
class PaginatedResponse[T](BaseModel):
|
|
234
|
+
items: list[T]
|
|
235
|
+
total: int
|
|
236
|
+
page: int
|
|
237
|
+
page_size: int
|
|
238
|
+
|
|
239
|
+
# routers/user.py
|
|
240
|
+
@router.get("/", response_model=PaginatedResponse[UserResponse])
|
|
241
|
+
async def list_users(
|
|
242
|
+
pagination: PaginationParams = Depends(),
|
|
243
|
+
service: UserService = Depends(get_user_service),
|
|
244
|
+
) -> PaginatedResponse[UserResponse]:
|
|
245
|
+
return await service.list(pagination)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## 六、Pydantic Schema 规范
|
|
249
|
+
|
|
250
|
+
### 强制规则
|
|
251
|
+
- 请求和响应使用不同的 Schema,**禁止**复用同一个 Model
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# schemas/user.py
|
|
255
|
+
|
|
256
|
+
class UserBase(BaseModel):
|
|
257
|
+
"""共享字段基类"""
|
|
258
|
+
name: str = Field(max_length=50)
|
|
259
|
+
email: EmailStr
|
|
260
|
+
|
|
261
|
+
class UserCreate(UserBase):
|
|
262
|
+
"""创建请求"""
|
|
263
|
+
password: str = Field(min_length=8)
|
|
264
|
+
|
|
265
|
+
class UserUpdate(BaseModel):
|
|
266
|
+
"""更新请求 - 所有字段可选"""
|
|
267
|
+
name: str | None = Field(default=None, max_length=50)
|
|
268
|
+
email: EmailStr | None = None
|
|
269
|
+
|
|
270
|
+
class UserResponse(UserBase):
|
|
271
|
+
"""响应"""
|
|
272
|
+
id: int
|
|
273
|
+
is_active: bool
|
|
274
|
+
created_at: datetime
|
|
275
|
+
|
|
276
|
+
model_config = ConfigDict(from_attributes=True)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- **禁止**在响应 Schema 中暴露密码、内部 ID、敏感字段
|
|
280
|
+
- 使用 `Field()` 添加校验约束(`min_length`、`ge`、`le`、`pattern` 等)
|
|
281
|
+
- 更新 Schema 的字段必须全部可选(`None` 默认值)
|
|
282
|
+
|
|
283
|
+
## 七、依赖注入
|
|
284
|
+
|
|
285
|
+
### 强制规则
|
|
286
|
+
- 数据库会话、当前用户、配置等通过 `Depends()` 注入
|
|
287
|
+
- **禁止**在路由函数中直接创建数据库会话或实例化 service
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
# dependencies.py
|
|
291
|
+
from collections.abc import AsyncGenerator
|
|
292
|
+
|
|
293
|
+
async def get_db() -> AsyncGenerator[AsyncSession]:
|
|
294
|
+
async with async_session_factory() as session:
|
|
295
|
+
yield session
|
|
296
|
+
|
|
297
|
+
async def get_current_user(
|
|
298
|
+
token: str = Depends(oauth2_scheme),
|
|
299
|
+
db: AsyncSession = Depends(get_db),
|
|
300
|
+
) -> User:
|
|
301
|
+
user = await authenticate(token, db)
|
|
302
|
+
if not user:
|
|
303
|
+
raise HTTPException(status_code=401, detail="认证失败")
|
|
304
|
+
return user
|
|
305
|
+
|
|
306
|
+
def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService:
|
|
307
|
+
return UserService(db)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
# ❌ 路由中直接创建会话
|
|
312
|
+
@router.get("/users/{user_id}")
|
|
313
|
+
async def get_user(user_id: int):
|
|
314
|
+
async with async_session_factory() as db:
|
|
315
|
+
user = await db.get(User, user_id)
|
|
316
|
+
return user
|
|
317
|
+
|
|
318
|
+
# ✅ 依赖注入
|
|
319
|
+
@router.get("/users/{user_id}", response_model=UserResponse)
|
|
320
|
+
async def get_user(
|
|
321
|
+
user_id: int,
|
|
322
|
+
service: UserService = Depends(get_user_service),
|
|
323
|
+
) -> UserResponse:
|
|
324
|
+
return await service.get_by_id(user_id)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## 八、异常处理
|
|
328
|
+
|
|
329
|
+
### 统一异常体系
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
# exceptions.py
|
|
333
|
+
from fastapi import FastAPI, Request, status
|
|
334
|
+
from fastapi.responses import JSONResponse
|
|
335
|
+
|
|
336
|
+
# 业务异常基类
|
|
337
|
+
class AppError(Exception):
|
|
338
|
+
def __init__(self, message: str, code: str = "UNKNOWN_ERROR") -> None:
|
|
339
|
+
self.message = message
|
|
340
|
+
self.code = code
|
|
341
|
+
|
|
342
|
+
class NotFoundError(AppError):
|
|
343
|
+
def __init__(self, resource: str, resource_id: int | str) -> None:
|
|
344
|
+
super().__init__(
|
|
345
|
+
message=f"{resource} 不存在",
|
|
346
|
+
code="NOT_FOUND",
|
|
347
|
+
)
|
|
348
|
+
self.resource = resource
|
|
349
|
+
self.resource_id = resource_id
|
|
350
|
+
|
|
351
|
+
class BusinessError(AppError):
|
|
352
|
+
"""业务规则校验失败"""
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
class PermissionDeniedError(AppError):
|
|
356
|
+
"""权限不足"""
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
# 注册异常处理器
|
|
360
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
361
|
+
@app.exception_handler(NotFoundError)
|
|
362
|
+
async def not_found_handler(request: Request, exc: NotFoundError) -> JSONResponse:
|
|
363
|
+
return JSONResponse(
|
|
364
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
365
|
+
content={"code": exc.code, "message": exc.message},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
@app.exception_handler(BusinessError)
|
|
369
|
+
async def business_error_handler(request: Request, exc: BusinessError) -> JSONResponse:
|
|
370
|
+
return JSONResponse(
|
|
371
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
372
|
+
content={"code": exc.code, "message": exc.message},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
@app.exception_handler(PermissionDeniedError)
|
|
376
|
+
async def permission_denied_handler(request: Request, exc: PermissionDeniedError) -> JSONResponse:
|
|
377
|
+
return JSONResponse(
|
|
378
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
379
|
+
content={"code": exc.code, "message": exc.message},
|
|
380
|
+
)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### 强制规则
|
|
384
|
+
- **禁止**在 service 层抛出 `HTTPException`(service 不感知 HTTP)
|
|
385
|
+
- service 层抛出业务异常,由异常处理器统一转换为 HTTP 响应
|
|
386
|
+
- **禁止**裸 `except:` 或 `except Exception: pass`
|
|
387
|
+
- 错误响应格式统一为 `{"code": "ERROR_CODE", "message": "描述"}`
|
|
388
|
+
- **禁止**在错误消息中回显用户输入
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
# ❌ service 中抛 HTTPException
|
|
392
|
+
class UserService:
|
|
393
|
+
async def get_by_id(self, user_id: int) -> User:
|
|
394
|
+
user = await self.db.get(User, user_id)
|
|
395
|
+
if not user:
|
|
396
|
+
raise HTTPException(status_code=404) # 耦合 HTTP
|
|
397
|
+
return user
|
|
398
|
+
|
|
399
|
+
# ✅ service 中抛业务异常
|
|
400
|
+
class UserService:
|
|
401
|
+
async def get_by_id(self, user_id: int) -> User:
|
|
402
|
+
user = await self.db.get(User, user_id)
|
|
403
|
+
if not user:
|
|
404
|
+
raise NotFoundError("用户", user_id)
|
|
405
|
+
return user
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## 九、数据库与 ORM
|
|
409
|
+
|
|
410
|
+
### 强制规则
|
|
411
|
+
- 使用 **SQLAlchemy 2.0** 风格(声明式映射 + async)
|
|
412
|
+
- 数据库迁移使用 **Alembic**,**禁止**手动执行 DDL
|
|
413
|
+
- **禁止**在路由层直接写 SQL 查询
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
# models/base.py
|
|
417
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
418
|
+
from sqlalchemy import func
|
|
419
|
+
from datetime import datetime
|
|
420
|
+
|
|
421
|
+
class Base(DeclarativeBase):
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
class TimestampMixin:
|
|
425
|
+
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
426
|
+
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
|
427
|
+
|
|
428
|
+
# models/user.py
|
|
429
|
+
class User(Base, TimestampMixin):
|
|
430
|
+
__tablename__ = "users"
|
|
431
|
+
|
|
432
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
433
|
+
name: Mapped[str] = mapped_column(String(50))
|
|
434
|
+
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
|
435
|
+
hashed_password: Mapped[str] = mapped_column(String(128))
|
|
436
|
+
is_active: Mapped[bool] = mapped_column(default=True)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### 异步数据库会话
|
|
440
|
+
|
|
441
|
+
```python
|
|
442
|
+
# config.py / database.py
|
|
443
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
444
|
+
|
|
445
|
+
engine = create_async_engine(settings.database_url, echo=settings.debug)
|
|
446
|
+
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### 查询规范
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
# ❌ 旧式查询
|
|
453
|
+
session.query(User).filter(User.id == user_id).first()
|
|
454
|
+
|
|
455
|
+
# ✅ SQLAlchemy 2.0 风格
|
|
456
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
457
|
+
user = result.scalar_one_or_none()
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## 十、异步编程
|
|
461
|
+
|
|
462
|
+
### 强制规则
|
|
463
|
+
- FastAPI 路由函数统一使用 `async def`
|
|
464
|
+
- 异步函数内**禁止**调用同步阻塞操作(IO、网络、sleep)
|
|
465
|
+
- 使用 `asyncio.to_thread()` 包装不可避免的同步调用
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
# ❌ 异步函数中调用同步阻塞
|
|
469
|
+
async def get_data():
|
|
470
|
+
result = requests.get(url) # 阻塞事件循环
|
|
471
|
+
data = open("file.txt").read() # 阻塞事件循环
|
|
472
|
+
|
|
473
|
+
# ✅ 使用异步库
|
|
474
|
+
async def get_data():
|
|
475
|
+
async with httpx.AsyncClient() as client:
|
|
476
|
+
result = await client.get(url)
|
|
477
|
+
data = await asyncio.to_thread(Path("file.txt").read_text)
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
- HTTP 客户端使用 `httpx`,**禁止** `requests`
|
|
481
|
+
- 数据库使用异步驱动(`asyncpg`、`aiomysql`)
|
|
482
|
+
|
|
483
|
+
## 十一、中间件与生命周期
|
|
484
|
+
|
|
485
|
+
### 强制规则
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
# main.py
|
|
489
|
+
from contextlib import asynccontextmanager
|
|
490
|
+
from collections.abc import AsyncGenerator
|
|
491
|
+
|
|
492
|
+
@asynccontextmanager
|
|
493
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
494
|
+
# 启动时执行
|
|
495
|
+
logger.info("应用启动")
|
|
496
|
+
yield
|
|
497
|
+
# 关闭时执行
|
|
498
|
+
await engine.dispose()
|
|
499
|
+
logger.info("应用关闭")
|
|
500
|
+
|
|
501
|
+
app = FastAPI(
|
|
502
|
+
title="My API",
|
|
503
|
+
version="1.0.0",
|
|
504
|
+
lifespan=lifespan,
|
|
505
|
+
)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
- 使用 `lifespan` 管理应用生命周期,**禁止** `@app.on_event("startup")`(已废弃)
|
|
509
|
+
- CORS 中间件必须明确指定允许的域名,**禁止**生产环境使用 `allow_origins=["*"]`
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
513
|
+
|
|
514
|
+
app.add_middleware(
|
|
515
|
+
CORSMiddleware,
|
|
516
|
+
allow_origins=settings.allowed_origins, # 从环境变量读取
|
|
517
|
+
allow_credentials=True,
|
|
518
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
519
|
+
allow_headers=["*"],
|
|
520
|
+
)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
## 十二、配置管理
|
|
524
|
+
|
|
525
|
+
### 强制规则
|
|
526
|
+
- 使用 Pydantic Settings 管理配置
|
|
527
|
+
|
|
528
|
+
```python
|
|
529
|
+
# config.py
|
|
530
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
531
|
+
|
|
532
|
+
class Settings(BaseSettings):
|
|
533
|
+
# 应用
|
|
534
|
+
app_name: str = "My API"
|
|
535
|
+
debug: bool = False
|
|
536
|
+
|
|
537
|
+
# 数据库
|
|
538
|
+
database_url: str
|
|
539
|
+
db_pool_size: int = 5
|
|
540
|
+
|
|
541
|
+
# Redis
|
|
542
|
+
redis_url: str = "redis://localhost:6379"
|
|
543
|
+
|
|
544
|
+
# 认证
|
|
545
|
+
secret_key: str
|
|
546
|
+
access_token_expire_minutes: int = 30
|
|
547
|
+
|
|
548
|
+
# CORS
|
|
549
|
+
allowed_origins: list[str] = ["http://localhost:3000"]
|
|
550
|
+
|
|
551
|
+
model_config = SettingsConfigDict(
|
|
552
|
+
env_file=".env",
|
|
553
|
+
env_file_encoding="utf-8",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
settings = Settings()
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- **禁止**在代码中硬编码配置值(数据库地址、密钥、端口等)
|
|
560
|
+
- **禁止**在模块顶层执行副作用(网络请求、数据库连接、文件读取)
|
|
561
|
+
|
|
562
|
+
## 十三、日志
|
|
563
|
+
|
|
564
|
+
### 强制规则
|
|
565
|
+
- 使用标准库 `logging` 或 `structlog`,**禁止** `print()` 出现在非调试代码中
|
|
566
|
+
- 使用 `%s` 占位符或 structlog 的结构化日志,**禁止** f-string 拼接日志
|
|
567
|
+
|
|
568
|
+
```python
|
|
569
|
+
# ❌ print 调试
|
|
570
|
+
print(f"User {user_id} not found")
|
|
571
|
+
|
|
572
|
+
# ❌ f-string 日志(参数不会延迟求值,且无法被日志聚合工具解析)
|
|
573
|
+
logger.info(f"User {user_id} created")
|
|
574
|
+
|
|
575
|
+
# ✅ 占位符日志
|
|
576
|
+
logger.info("User %s created", user_id)
|
|
577
|
+
|
|
578
|
+
# ✅ structlog
|
|
579
|
+
logger.info("user_created", user_id=user_id)
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### 日志级别使用规范
|
|
583
|
+
|
|
584
|
+
| 级别 | 使用场景 |
|
|
585
|
+
|---|---|
|
|
586
|
+
| `DEBUG` | 开发调试信息,生产环境不输出 |
|
|
587
|
+
| `INFO` | 关键业务流程节点(用户注册、订单创建等) |
|
|
588
|
+
| `WARNING` | 异常但可恢复的情况(重试、降级) |
|
|
589
|
+
| `ERROR` | 错误但服务仍可用(单次请求失败) |
|
|
590
|
+
| `CRITICAL` | 服务不可用(数据库连接断开、必要服务宕机) |
|
|
591
|
+
|
|
592
|
+
## 十四、测试
|
|
593
|
+
|
|
594
|
+
### 强制规则
|
|
595
|
+
- 使用 `pytest` + `pytest-asyncio` + `httpx`
|
|
596
|
+
- 使用 `TestClient` 或 `AsyncClient` 测试 API
|
|
597
|
+
|
|
598
|
+
```python
|
|
599
|
+
# conftest.py
|
|
600
|
+
import pytest
|
|
601
|
+
from httpx import ASGITransport, AsyncClient
|
|
602
|
+
from app.main import app
|
|
603
|
+
|
|
604
|
+
@pytest.fixture
|
|
605
|
+
async def client():
|
|
606
|
+
async with AsyncClient(
|
|
607
|
+
transport=ASGITransport(app=app),
|
|
608
|
+
base_url="http://test",
|
|
609
|
+
) as ac:
|
|
610
|
+
yield ac
|
|
611
|
+
|
|
612
|
+
# test_user.py
|
|
613
|
+
@pytest.mark.anyio
|
|
614
|
+
async def test_create_user_returns_201(client: AsyncClient):
|
|
615
|
+
response = await client.post("/users/", json={
|
|
616
|
+
"name": "Alice",
|
|
617
|
+
"email": "alice@example.com",
|
|
618
|
+
"password": "securepass123",
|
|
619
|
+
})
|
|
620
|
+
assert response.status_code == 201
|
|
621
|
+
data = response.json()
|
|
622
|
+
assert data["name"] == "Alice"
|
|
623
|
+
assert "password" not in data # 响应不包含密码
|
|
624
|
+
|
|
625
|
+
@pytest.mark.anyio
|
|
626
|
+
async def test_get_nonexistent_user_returns_404(client: AsyncClient):
|
|
627
|
+
response = await client.get("/users/99999")
|
|
628
|
+
assert response.status_code == 404
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
- 测试函数名描述行为而非实现
|
|
632
|
+
- 测试数据使用 `pytest.fixture`,不要硬编码
|
|
633
|
+
- 测试结构遵循 Arrange-Act-Assert
|
|
634
|
+
|
|
635
|
+
## 十五、函数与类设计
|
|
636
|
+
|
|
637
|
+
### 强制规则
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
# ❌ 超过 3 个位置参数
|
|
641
|
+
def create_user(name, age, email, role, department):
|
|
642
|
+
...
|
|
643
|
+
|
|
644
|
+
# ✅ 使用关键字参数或 Pydantic model
|
|
645
|
+
def create_user(request: CreateUserRequest) -> User:
|
|
646
|
+
...
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- 函数体不超过 50 行(超过则拆分)
|
|
650
|
+
- 单个文件不超过 500 行(超过则拆分模块)
|
|
651
|
+
- 嵌套不超过 3 层(使用 early return 降低嵌套)
|
|
652
|
+
|
|
653
|
+
```python
|
|
654
|
+
# ❌ 深层嵌套
|
|
655
|
+
def process(data):
|
|
656
|
+
if data:
|
|
657
|
+
if data.is_valid:
|
|
658
|
+
if data.has_items:
|
|
659
|
+
for item in data.items:
|
|
660
|
+
...
|
|
661
|
+
|
|
662
|
+
# ✅ 提前返回
|
|
663
|
+
def process(data):
|
|
664
|
+
if not data:
|
|
665
|
+
return
|
|
666
|
+
if not data.is_valid:
|
|
667
|
+
return
|
|
668
|
+
if not data.has_items:
|
|
669
|
+
return
|
|
670
|
+
for item in data.items:
|
|
671
|
+
...
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
## 十六、数据校验与序列化
|
|
675
|
+
|
|
676
|
+
### 强制规则
|
|
677
|
+
- API 输入/输出使用 **Pydantic v2** 进行校验
|
|
678
|
+
- 内部数据结构优先使用 `dataclass`,需要校验时用 Pydantic
|
|
679
|
+
- **禁止**在业务逻辑中手动校验字典字段
|
|
680
|
+
|
|
681
|
+
```python
|
|
682
|
+
# ❌ 手动校验字典
|
|
683
|
+
def create_user(data: dict):
|
|
684
|
+
if "name" not in data:
|
|
685
|
+
raise ValueError("name required")
|
|
686
|
+
|
|
687
|
+
# ✅ Pydantic 自动校验
|
|
688
|
+
class CreateUserRequest(BaseModel):
|
|
689
|
+
name: str
|
|
690
|
+
age: int = Field(ge=0, le=150)
|
|
691
|
+
email: EmailStr
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## 十七、常见反模式(禁止)
|
|
695
|
+
|
|
696
|
+
| 反模式 | 正确做法 |
|
|
697
|
+
|---|---|
|
|
698
|
+
| 可变默认参数 `def f(items=[])` | `def f(items: list \| None = None)` |
|
|
699
|
+
| `import *` | 显式导入 |
|
|
700
|
+
| 循环导入 | 重构模块或延迟导入 |
|
|
701
|
+
| 全局可变状态 | 依赖注入 |
|
|
702
|
+
| `os.path` 字符串拼接 | `pathlib.Path` |
|
|
703
|
+
| `datetime.now()` 无时区 | `datetime.now(tz=UTC)` |
|
|
704
|
+
| 手动拼接 SQL | ORM 或参数化查询 |
|
|
705
|
+
| `== True` / `== None` | `is True` / `is None` |
|
|
706
|
+
| `len(x) == 0` | `not x` |
|
|
707
|
+
| `type(x) == SomeType` | `isinstance(x, SomeType)` |
|
|
708
|
+
| `dict.keys()` 遍历 | 直接 `for k in dict` |
|
|
709
|
+
| 嵌套字典传递数据 | `dataclass` / `TypedDict` / Pydantic model |
|
|
710
|
+
| `@app.on_event("startup")` | `lifespan` context manager |
|
|
711
|
+
| service 层抛 `HTTPException` | 抛业务异常,异常处理器转换 |
|
|
712
|
+
| 路由里写数据库查询 | 调用 service 层 |
|
|
713
|
+
| `allow_origins=["*"]`(生产) | 明确列出允许的域名 |
|
|
714
|
+
|
|
715
|
+
## 十八、注释与文档
|
|
716
|
+
|
|
717
|
+
### 强制规则
|
|
718
|
+
- 注释用于解释 **为什么(why)**,而非 **做了什么(what)**
|
|
719
|
+
- 每个 `.py` 文件**必须**在顶部添加模块级 docstring,说明该文件的主要功能
|
|
720
|
+
- 所有类**必须**有 docstring,说明类的职责和用途
|
|
721
|
+
- 所有方法/函数(包括公开和私有)**必须**有 docstring,说明功能、参数和返回值
|
|
722
|
+
- docstring 统一使用 **Google 风格**
|
|
723
|
+
|
|
724
|
+
### 模块级 docstring
|
|
725
|
+
|
|
726
|
+
每个 `.py` 文件的第一行必须是模块 docstring:
|
|
727
|
+
|
|
728
|
+
```python
|
|
729
|
+
# ✅ 模块 docstring
|
|
730
|
+
"""用户相关的路由模块。
|
|
731
|
+
|
|
732
|
+
提供用户的 CRUD 接口,包括注册、查询、更新和删除。
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
from fastapi import APIRouter
|
|
736
|
+
...
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
```python
|
|
740
|
+
# ❌ 缺少模块 docstring
|
|
741
|
+
from fastapi import APIRouter
|
|
742
|
+
...
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### 类 docstring
|
|
746
|
+
|
|
747
|
+
```python
|
|
748
|
+
# ✅ 类 docstring
|
|
749
|
+
class UserService:
|
|
750
|
+
"""用户业务逻辑服务。
|
|
751
|
+
|
|
752
|
+
负责用户的创建、查询、更新和删除操作,
|
|
753
|
+
封装所有与用户相关的业务规则。
|
|
754
|
+
"""
|
|
755
|
+
|
|
756
|
+
def __init__(self, db: AsyncSession) -> None:
|
|
757
|
+
"""初始化 UserService。
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
db: 异步数据库会话。
|
|
761
|
+
"""
|
|
762
|
+
self.db = db
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
```python
|
|
766
|
+
# ❌ 缺少类 docstring
|
|
767
|
+
class UserService:
|
|
768
|
+
def __init__(self, db: AsyncSession) -> None:
|
|
769
|
+
self.db = db
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### 方法/函数 docstring
|
|
773
|
+
|
|
774
|
+
```python
|
|
775
|
+
# ✅ 完整的 Google 风格 docstring
|
|
776
|
+
async def get_by_id(self, user_id: int) -> User:
|
|
777
|
+
"""根据 ID 查询用户。
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
user_id: 用户唯一标识。
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
匹配的用户对象。
|
|
784
|
+
|
|
785
|
+
Raises:
|
|
786
|
+
NotFoundError: 用户不存在时抛出。
|
|
787
|
+
"""
|
|
788
|
+
user = await self.db.get(User, user_id)
|
|
789
|
+
if not user:
|
|
790
|
+
raise NotFoundError("用户", user_id)
|
|
791
|
+
return user
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
```python
|
|
795
|
+
# ✅ 简单函数可以使用单行 docstring
|
|
796
|
+
def _build_cache_key(self, user_id: int) -> str:
|
|
797
|
+
"""根据用户 ID 构建缓存键。"""
|
|
798
|
+
return f"user:{user_id}"
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
```python
|
|
802
|
+
# ❌ 缺少 docstring
|
|
803
|
+
async def get_by_id(self, user_id: int) -> User:
|
|
804
|
+
user = await self.db.get(User, user_id)
|
|
805
|
+
if not user:
|
|
806
|
+
raise NotFoundError("用户", user_id)
|
|
807
|
+
return user
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### docstring 规范
|
|
811
|
+
|
|
812
|
+
| 场景 | 要求 |
|
|
813
|
+
|---|---|
|
|
814
|
+
| 单行可描述清楚 | 使用单行 docstring:`"""根据用户 ID 构建缓存键。"""` |
|
|
815
|
+
| 有参数/返回值/异常 | 使用多行 Google 风格,包含 `Args`/`Returns`/`Raises` |
|
|
816
|
+
| `__init__` 方法 | 必须有 docstring,说明初始化参数 |
|
|
817
|
+
| 魔术方法(`__str__` 等) | 必须有 docstring,说明行为 |
|
|
818
|
+
| 私有方法 | 必须有 docstring,至少单行说明 |
|
|
819
|
+
|
|
820
|
+
### 行内注释
|
|
821
|
+
```python
|
|
822
|
+
# ❌ 无用注释
|
|
823
|
+
# 获取用户
|
|
824
|
+
user = get_user(user_id)
|
|
825
|
+
|
|
826
|
+
# ✅ 解释原因
|
|
827
|
+
# 此处使用缓存查询,因为用户表 QPS 超过 5000
|
|
828
|
+
user = cache.get_user(user_id)
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### API 文档
|
|
832
|
+
- 路由函数的 docstring 会自动生成 OpenAPI 描述,保持简洁
|
|
833
|
+
- 使用 `summary` 参数替代 docstring 标题
|
|
834
|
+
|
|
835
|
+
```python
|
|
836
|
+
@router.post(
|
|
837
|
+
"/",
|
|
838
|
+
status_code=status.HTTP_201_CREATED,
|
|
839
|
+
response_model=UserResponse,
|
|
840
|
+
summary="创建用户",
|
|
841
|
+
)
|
|
842
|
+
async def create_user(body: UserCreate) -> UserResponse:
|
|
843
|
+
"""根据提交的信息创建新用户,邮箱不可重复。"""
|
|
844
|
+
...
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
## 十九、安全编码
|
|
848
|
+
|
|
849
|
+
### 强制规则
|
|
850
|
+
- SQL 查询必须使用参数化绑定,**禁止**字符串拼接
|
|
851
|
+
- 用户输入必须校验后使用,**禁止**直接信任
|
|
852
|
+
- 文件路径操作必须防止路径遍历攻击
|
|
853
|
+
- 序列化/反序列化**禁止** `pickle.loads()` 处理不可信数据
|
|
854
|
+
- 密码存储必须使用 `bcrypt` / `argon2`,**禁止**明文或 MD5/SHA
|
|
855
|
+
- JWT 密钥从环境变量读取,**禁止**硬编码
|
|
856
|
+
|
|
857
|
+
```python
|
|
858
|
+
# ❌ SQL 注入
|
|
859
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
860
|
+
|
|
861
|
+
# ✅ 参数化查询
|
|
862
|
+
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
863
|
+
|
|
864
|
+
# ❌ 路径遍历
|
|
865
|
+
file_path = f"/uploads/{user_input_filename}"
|
|
866
|
+
|
|
867
|
+
# ✅ 路径安全校验
|
|
868
|
+
safe_path = Path("/uploads") / Path(user_input_filename).name
|
|
869
|
+
|
|
870
|
+
# ❌ 硬编码密钥
|
|
871
|
+
SECRET_KEY = "my-super-secret-key"
|
|
872
|
+
|
|
873
|
+
# ✅ 环境变量
|
|
874
|
+
SECRET_KEY = settings.secret_key
|
|
875
|
+
```
|