docs: add coding/API/testing standards
- CODING-STANDARD.md: PEP 8, type hints, Clean Architecture constraints, security - API-SPEC.md: RESTful design, HTTP methods, status codes, request/response format - TESTING-GUIDE.md: test strategy, AAA pattern, fixtures, coverage targets - complete examples included
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
# API 规范
|
||||
|
||||
本文档定义新 SaaS 项目的 REST API 设计规范。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基本原则
|
||||
|
||||
- **RESTful** 设计
|
||||
- **JSON** 数据格式
|
||||
- **HTTP 状态码** 语义化
|
||||
- **版本化** API(未来)
|
||||
- **文档化** (Swagger/ReDoc)
|
||||
|
||||
---
|
||||
|
||||
## 2. URL 设计
|
||||
|
||||
### 2.1 资源命名
|
||||
|
||||
**使用复数名词**:
|
||||
```
|
||||
✅ /api/projects
|
||||
✅ /api/asset-libraries
|
||||
✅ /api/assets
|
||||
✅ /api/ingest-jobs
|
||||
|
||||
❌ /api/project
|
||||
❌ /api/assetLibrary
|
||||
❌ /api/get_assets
|
||||
```
|
||||
|
||||
**使用 kebab-case**:
|
||||
```
|
||||
✅ /api/asset-libraries
|
||||
✅ /api/ingest-jobs
|
||||
|
||||
❌ /api/assetLibraries
|
||||
❌ /api/ingest_jobs
|
||||
```
|
||||
|
||||
### 2.2 路径层级
|
||||
|
||||
**浅层级**(推荐):
|
||||
```
|
||||
✅ GET /api/projects?workspace_id=ws-1
|
||||
✅ GET /api/assets?library_id=lib-1
|
||||
|
||||
❌ GET /api/workspaces/ws-1/projects
|
||||
❌ GET /api/projects/proj-1/libraries/lib-1/assets
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- 避免深层嵌套
|
||||
- 查询参数更灵活
|
||||
- URL 更简洁
|
||||
|
||||
---
|
||||
|
||||
## 3. HTTP 方法
|
||||
|
||||
| 方法 | 用途 | 幂等性 | 安全性 |
|
||||
|------|------|--------|--------|
|
||||
| GET | 查询资源 | ✅ | ✅ |
|
||||
| POST | 创建资源 | ❌ | ❌ |
|
||||
| PUT | 完整更新 | ✅ | ❌ |
|
||||
| PATCH | 部分更新 | ❌ | ❌ |
|
||||
| DELETE | 删除资源 | ✅ | ❌ |
|
||||
|
||||
### 3.1 示例
|
||||
|
||||
```http
|
||||
# 查询项目列表
|
||||
GET /api/projects?workspace_id=ws-1
|
||||
|
||||
# 创建项目
|
||||
POST /api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"workspace_id": "ws-1",
|
||||
"name": "新项目",
|
||||
"description": "描述"
|
||||
}
|
||||
|
||||
# 更新项目
|
||||
PUT /api/projects/{project_id}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "更新后的名称",
|
||||
"description": "更新后的描述"
|
||||
}
|
||||
|
||||
# 删除项目
|
||||
DELETE /api/projects/{project_id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTP 状态码
|
||||
|
||||
### 4.1 成功响应
|
||||
|
||||
| 状态码 | 含义 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 200 OK | 成功 | GET、PUT、PATCH |
|
||||
| 201 Created | 创建成功 | POST |
|
||||
| 204 No Content | 成功无内容 | DELETE |
|
||||
|
||||
### 4.2 客户端错误
|
||||
|
||||
| 状态码 | 含义 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 400 Bad Request | 请求参数错误 | 参数验证失败 |
|
||||
| 401 Unauthorized | 未认证 | 缺少 token |
|
||||
| 403 Forbidden | 无权限 | 权限不足 |
|
||||
| 404 Not Found | 资源不存在 | 资源未找到 |
|
||||
| 409 Conflict | 冲突 | 资源已存在 |
|
||||
| 422 Unprocessable Entity | 业务逻辑错误 | 业务规则违反 |
|
||||
|
||||
### 4.3 服务器错误
|
||||
|
||||
| 状态码 | 含义 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 500 Internal Server Error | 服务器错误 | 未预期的异常 |
|
||||
| 503 Service Unavailable | 服务不可用 | 维护中 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 请求格式
|
||||
|
||||
### 5.1 查询参数(GET)
|
||||
|
||||
```http
|
||||
GET /api/projects?workspace_id=ws-1&page=1&page_size=20
|
||||
```
|
||||
|
||||
**命名**:snake_case
|
||||
|
||||
**分页参数**:
|
||||
- `page` - 页码(从 1 开始)
|
||||
- `page_size` - 每页数量(默认 20,最大 100)
|
||||
|
||||
**过滤参数**:
|
||||
- `workspace_id` - 按工作空间过滤
|
||||
- `kind` - 按类型过滤
|
||||
- `status` - 按状态过滤
|
||||
|
||||
**排序参数**:
|
||||
- `sort_by` - 排序字段(如 `created_at`)
|
||||
- `sort_order` - 排序方向(`asc` / `desc`)
|
||||
|
||||
### 5.2 请求体(POST/PUT/PATCH)
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace_id": "ws-1",
|
||||
"name": "项目名称",
|
||||
"description": "项目描述"
|
||||
}
|
||||
```
|
||||
|
||||
**命名**:snake_case
|
||||
|
||||
---
|
||||
|
||||
## 6. 响应格式
|
||||
|
||||
### 6.1 单个资源
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "proj-123",
|
||||
"workspace_id": "ws-1",
|
||||
"name": "项目名称",
|
||||
"description": "项目描述",
|
||||
"created_at": "2026-06-15T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 资源列表
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "proj-123",
|
||||
"workspace_id": "ws-1",
|
||||
"name": "项目 1"
|
||||
},
|
||||
{
|
||||
"id": "proj-456",
|
||||
"workspace_id": "ws-1",
|
||||
"name": "项目 2"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "项目名称不能为空",
|
||||
"details": {
|
||||
"field": "name",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**:
|
||||
- `VALIDATION_ERROR` - 参数验证失败
|
||||
- `NOT_FOUND` - 资源不存在
|
||||
- `CONFLICT` - 资源冲突
|
||||
- `PERMISSION_DENIED` - 权限不足
|
||||
- `INTERNAL_ERROR` - 服务器错误
|
||||
|
||||
---
|
||||
|
||||
## 7. 认证与授权
|
||||
|
||||
### 7.1 认证(Phase 2)
|
||||
|
||||
```http
|
||||
GET /api/projects
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### 7.2 授权(Phase 3)
|
||||
|
||||
```http
|
||||
GET /api/projects/{project_id}
|
||||
Authorization: Bearer {token}
|
||||
X-Workspace-ID: ws-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本化(Phase 3)
|
||||
|
||||
```http
|
||||
GET /api/v1/projects
|
||||
GET /api/v2/projects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 接口示例
|
||||
|
||||
### 9.1 项目管理
|
||||
|
||||
**创建项目**:
|
||||
```http
|
||||
POST /api/projects
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"workspace_id": "ws-1",
|
||||
"name": "新项目",
|
||||
"description": "描述"
|
||||
}
|
||||
|
||||
Response 201:
|
||||
{
|
||||
"id": "proj-123",
|
||||
"workspace_id": "ws-1",
|
||||
"name": "新项目",
|
||||
"description": "描述",
|
||||
"created_at": "2026-06-15T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**查询项目列表**:
|
||||
```http
|
||||
GET /api/projects?workspace_id=ws-1&page=1&page_size=20
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"items": [...],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 素材上传
|
||||
|
||||
**上传素材**:
|
||||
```http
|
||||
POST /api/upload
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"workspace_id": "ws-1",
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "video.mp4"
|
||||
}
|
||||
|
||||
Response 201:
|
||||
{
|
||||
"storage_key": "uploads/abc123/video.mp4",
|
||||
"ingest_job_id": "job-456"
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 任务查询
|
||||
|
||||
**查询入库任务**:
|
||||
```http
|
||||
GET /api/ingest-jobs/{job_id}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"id": "job-456",
|
||||
"workspace_id": "ws-1",
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"storage_key": "uploads/abc123/video.mp4",
|
||||
"status": "completed",
|
||||
"result_asset_id": "asset-789",
|
||||
"created_at": "2026-06-15T08:00:00Z",
|
||||
"updated_at": "2026-06-15T08:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 性能优化
|
||||
|
||||
### 10.1 分页
|
||||
|
||||
**强制分页**(避免返回大量数据):
|
||||
```http
|
||||
GET /api/assets?library_id=lib-1&page=1&page_size=20
|
||||
```
|
||||
|
||||
### 10.2 字段过滤(Phase 2)
|
||||
|
||||
```http
|
||||
GET /api/projects?fields=id,name
|
||||
```
|
||||
|
||||
### 10.3 批量操作(Phase 2)
|
||||
|
||||
```http
|
||||
POST /api/assets/batch
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"operations": [
|
||||
{"action": "delete", "id": "asset-1"},
|
||||
{"action": "delete", "id": "asset-2"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 文档化
|
||||
|
||||
使用 **FastAPI** 自动生成文档:
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-15
|
||||
**版本**: v1.0
|
||||
@@ -0,0 +1,449 @@
|
||||
# 编码规范
|
||||
|
||||
本文档定义新 SaaS 项目的 Python 编码规范。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础规范
|
||||
|
||||
遵循 [PEP 8](https://peps.python.org/pep-0008/),以下是重点和补充。
|
||||
|
||||
### 1.1 命名规范
|
||||
|
||||
**模块/包**:小写 + 下划线
|
||||
```python
|
||||
# ✅ 正确
|
||||
from packages.domain import entities
|
||||
from packages.adapters.in_memory import project_repository
|
||||
|
||||
# ❌ 错误
|
||||
from packages.Domain import Entities
|
||||
from packages.adapters.InMemory import ProjectRepository
|
||||
```
|
||||
|
||||
**类**:PascalCase
|
||||
```python
|
||||
# ✅ 正确
|
||||
class Project:
|
||||
pass
|
||||
|
||||
class InMemoryProjectRepository:
|
||||
pass
|
||||
|
||||
# ❌ 错误
|
||||
class project:
|
||||
pass
|
||||
|
||||
class in_memory_project_repository:
|
||||
pass
|
||||
```
|
||||
|
||||
**函数/变量**:小写 + 下划线
|
||||
```python
|
||||
# ✅ 正确
|
||||
def create_project(workspace_id: str, name: str) -> Project:
|
||||
pass
|
||||
|
||||
user_count = 10
|
||||
|
||||
# ❌ 错误
|
||||
def CreateProject(workspace_id: str, name: str) -> Project:
|
||||
pass
|
||||
|
||||
UserCount = 10
|
||||
```
|
||||
|
||||
**常量**:大写 + 下划线
|
||||
```python
|
||||
# ✅ 正确
|
||||
MAX_PROJECT_NAME_LENGTH = 100
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
# ❌ 错误
|
||||
maxProjectNameLength = 100
|
||||
default_page_size = 20
|
||||
```
|
||||
|
||||
**私有属性/方法**:前缀 `_`
|
||||
```python
|
||||
class Project:
|
||||
def __init__(self):
|
||||
self._internal_state = {}
|
||||
|
||||
def _validate(self):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Type Hints
|
||||
|
||||
**强制使用** type hints,提升代码可读性和 IDE 支持。
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
def create_project(workspace_id: str, name: str, description: str = "") -> Project:
|
||||
pass
|
||||
|
||||
def list_projects(workspace_id: str) -> list[Project]:
|
||||
pass
|
||||
|
||||
def get_project(project_id: str) -> Project | None:
|
||||
pass
|
||||
|
||||
# ❌ 错误
|
||||
def create_project(workspace_id, name, description=""):
|
||||
pass
|
||||
```
|
||||
|
||||
**复杂类型**:
|
||||
```python
|
||||
from typing import Protocol, Any
|
||||
|
||||
# Dict/List
|
||||
def update_metadata(metadata: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
# Optional (Python 3.10+ 用 | None)
|
||||
def get_user(user_id: str) -> User | None:
|
||||
pass
|
||||
|
||||
# Protocol
|
||||
class Repository(Protocol):
|
||||
def get(self, id: str) -> Entity | None:
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dataclass
|
||||
|
||||
**优先使用** `dataclass` 定义实体和值对象。
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ✅ 正确
|
||||
@dataclass(slots=True)
|
||||
class Project:
|
||||
id: str
|
||||
workspace_id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# ❌ 错误(不用 dataclass)
|
||||
class Project:
|
||||
def __init__(self, id: str, workspace_id: str, name: str, description: str = ""):
|
||||
self.id = id
|
||||
self.workspace_id = workspace_id
|
||||
self.name = name
|
||||
self.description = description
|
||||
```
|
||||
|
||||
**为什么用 `slots=True`?**
|
||||
- 节省内存
|
||||
- 防止意外添加属性
|
||||
- 提升性能
|
||||
|
||||
---
|
||||
|
||||
## 4. 注释与文档
|
||||
|
||||
### 4.1 模块/类/函数注释
|
||||
|
||||
**使用中文注释**。
|
||||
|
||||
```python
|
||||
def create_project(workspace_id: str, name: str, description: str = "") -> Project:
|
||||
"""
|
||||
创建项目。
|
||||
|
||||
Args:
|
||||
workspace_id: 工作空间 ID
|
||||
name: 项目名称(不能为空)
|
||||
description: 项目描述(可选)
|
||||
|
||||
Returns:
|
||||
创建的项目实体
|
||||
|
||||
Raises:
|
||||
ValueError: 项目名称为空时
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 4.2 复杂逻辑注释
|
||||
|
||||
```python
|
||||
# 正确做法:复杂逻辑加注释
|
||||
def calculate_priority(job: IngestJob) -> int:
|
||||
# 优先级规则:
|
||||
# 1. FAILED 状态最高(需要重试)
|
||||
# 2. PENDING 状态次高(等待处理)
|
||||
# 3. PROCESSING 状态最低(正在处理)
|
||||
if job.status == IngestJobStatus.FAILED:
|
||||
return 100
|
||||
elif job.status == IngestJobStatus.PENDING:
|
||||
return 50
|
||||
else:
|
||||
return 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 异常处理
|
||||
|
||||
### 5.1 使用具体异常
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
def get_project(project_id: str) -> Project:
|
||||
if not project_id:
|
||||
raise ValueError("项目 ID 不能为空")
|
||||
|
||||
project = repository.get(project_id)
|
||||
if project is None:
|
||||
raise KeyError(f"项目 {project_id} 不存在")
|
||||
|
||||
return project
|
||||
|
||||
# ❌ 错误
|
||||
def get_project(project_id: str) -> Project:
|
||||
if not project_id:
|
||||
raise Exception("错误") # 太宽泛
|
||||
```
|
||||
|
||||
### 5.2 自定义异常
|
||||
|
||||
```python
|
||||
class ProjectNotFoundError(Exception):
|
||||
"""项目不存在异常。"""
|
||||
pass
|
||||
|
||||
class ProjectNameTooLongError(ValueError):
|
||||
"""项目名称过长异常。"""
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Clean Architecture 约束
|
||||
|
||||
### 6.1 依赖方向
|
||||
|
||||
```
|
||||
Apps (api/worker/web)
|
||||
↓
|
||||
Application (use cases)
|
||||
↓
|
||||
Ports (interfaces) ← Adapters (implementations)
|
||||
↓
|
||||
Domain (entities/rules)
|
||||
```
|
||||
|
||||
**Domain 层**:
|
||||
- ❌ 不能依赖任何外层
|
||||
- ❌ 不能依赖 SQLAlchemy、FastAPI、Celery
|
||||
- ✅ 只能依赖 Python 标准库
|
||||
|
||||
```python
|
||||
# ✅ 正确(Domain 层)
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Project:
|
||||
id: str
|
||||
name: str
|
||||
|
||||
# ❌ 错误(Domain 层)
|
||||
from sqlalchemy import Column, String # ❌ 不能依赖 SQLAlchemy
|
||||
from fastapi import HTTPException # ❌ 不能依赖 FastAPI
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Project:
|
||||
id: str
|
||||
name: str
|
||||
```
|
||||
|
||||
**Application 层**:
|
||||
- ✅ 可以依赖 Domain + Ports
|
||||
- ❌ 不能依赖 Adapters
|
||||
|
||||
**Adapters 层**:
|
||||
- ✅ 可以依赖 Domain + Ports
|
||||
- ✅ 可以使用外部库(SQLAlchemy、Redis 等)
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试
|
||||
|
||||
### 7.1 测试文件命名
|
||||
|
||||
```
|
||||
tests/
|
||||
├── integration/
|
||||
│ ├── test_projects.py
|
||||
│ ├── test_ingest_pipeline.py
|
||||
│ └── test_classification_pipeline.py
|
||||
└── unit/
|
||||
├── test_project_entity.py
|
||||
└── test_asset_validation.py
|
||||
```
|
||||
|
||||
### 7.2 测试函数命名
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
def test_create_project_with_valid_name():
|
||||
pass
|
||||
|
||||
def test_create_project_with_empty_name_should_fail():
|
||||
pass
|
||||
|
||||
def test_list_projects_by_workspace():
|
||||
pass
|
||||
|
||||
# ❌ 错误
|
||||
def test1():
|
||||
pass
|
||||
|
||||
def test_project():
|
||||
pass
|
||||
```
|
||||
|
||||
### 7.3 测试结构(AAA 模式)
|
||||
|
||||
```python
|
||||
def test_create_project():
|
||||
# Arrange(准备)
|
||||
workspace_id = "ws-1"
|
||||
name = "测试项目"
|
||||
repository = InMemoryProjectRepository()
|
||||
use_case = CreateProjectUseCase(repository)
|
||||
|
||||
# Act(执行)
|
||||
project = use_case.execute(
|
||||
CreateProjectCommand(workspace_id=workspace_id, name=name)
|
||||
)
|
||||
|
||||
# Assert(断言)
|
||||
assert project.name == name
|
||||
assert project.workspace_id == workspace_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 代码格式化
|
||||
|
||||
### 8.1 行长度
|
||||
|
||||
- 最大 120 字符
|
||||
- 优先 88 字符(Black 默认)
|
||||
|
||||
### 8.2 导入顺序
|
||||
|
||||
```python
|
||||
# 1. 标准库
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
# 2. 第三方库
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import Column
|
||||
|
||||
# 3. 本地模块
|
||||
from packages.domain import Project
|
||||
from packages.application import CreateProjectUseCase
|
||||
```
|
||||
|
||||
### 8.3 空行
|
||||
|
||||
```python
|
||||
# 类之间:2 行
|
||||
class User:
|
||||
pass
|
||||
|
||||
|
||||
class Workspace:
|
||||
pass
|
||||
|
||||
|
||||
# 函数之间:1 行
|
||||
def create_user():
|
||||
pass
|
||||
|
||||
def list_users():
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全规范
|
||||
|
||||
### 9.1 禁止硬编码敏感信息
|
||||
|
||||
```python
|
||||
# ❌ 错误
|
||||
DATABASE_URL = "postgresql://admin:password123@localhost/db"
|
||||
API_KEY = "sk-1234567890abcdef"
|
||||
|
||||
# ✅ 正确
|
||||
import os
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
API_KEY = os.getenv("API_KEY")
|
||||
```
|
||||
|
||||
### 9.2 输入验证
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
def create_project(name: str) -> Project:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
if len(clean_name) > 100:
|
||||
raise ValueError("项目名称不能超过 100 字符")
|
||||
|
||||
return Project(id=uuid4().hex, name=clean_name)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 性能规范
|
||||
|
||||
### 10.1 避免 N+1 查询
|
||||
|
||||
```python
|
||||
# ❌ 错误
|
||||
projects = repository.list_by_workspace("ws-1")
|
||||
for project in projects:
|
||||
assets = asset_repository.list_by_project(project.id) # N+1
|
||||
|
||||
# ✅ 正确
|
||||
projects = repository.list_by_workspace("ws-1")
|
||||
project_ids = [p.id for p in projects]
|
||||
assets = asset_repository.list_by_projects(project_ids) # 一次查询
|
||||
```
|
||||
|
||||
### 10.2 使用生成器
|
||||
|
||||
```python
|
||||
# ✅ 正确(大数据集)
|
||||
def list_all_assets() -> Generator[Asset, None, None]:
|
||||
for asset in repository.stream():
|
||||
yield asset
|
||||
|
||||
# ❌ 错误(加载全部到内存)
|
||||
def list_all_assets() -> list[Asset]:
|
||||
return repository.list_all() # 可能 OOM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-15
|
||||
**版本**: v1.0
|
||||
@@ -0,0 +1,529 @@
|
||||
# 测试指南
|
||||
|
||||
本文档定义新 SaaS 项目的测试策略、测试规范和最佳实践。
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试策略
|
||||
|
||||
### 1.1 测试金字塔
|
||||
|
||||
```
|
||||
E2E Tests (少量)
|
||||
/ \
|
||||
Integration Tests (中量)
|
||||
/ \
|
||||
Unit Tests (大量,按需)
|
||||
```
|
||||
|
||||
**当前阶段(Phase 1)**:
|
||||
- **集成测试优先** - 覆盖完整业务流程
|
||||
- 单元测试辅助 - 覆盖复杂逻辑
|
||||
- E2E 测试占位 - Phase 2-3 补充
|
||||
|
||||
**原因**:
|
||||
- 集成测试验证架构正确性
|
||||
- 集成测试覆盖核心业务流程
|
||||
- In-Memory 实现使得集成测试成本低
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试工具
|
||||
|
||||
**测试框架**: pytest
|
||||
**测试环境**: in-memory 或 SQLite
|
||||
**覆盖率**: pytest-cov
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest tests/integration/ -v
|
||||
|
||||
# 运行指定测试
|
||||
pytest tests/integration/test_projects.py -v
|
||||
|
||||
# 运行带覆盖率
|
||||
pytest --cov=packages --cov=apps --cov-report=html
|
||||
|
||||
# 运行快速测试(跳过慢速测试)
|
||||
pytest -m "not slow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 测试结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # 共享 fixtures
|
||||
├── integration/ # 集成测试
|
||||
│ ├── test_projects.py
|
||||
│ ├── test_ingest_pipeline.py
|
||||
│ ├── test_classification_pipeline.py
|
||||
│ └── test_upload_pipeline.py
|
||||
├── unit/ # 单元测试
|
||||
│ ├── test_project_entity.py
|
||||
│ └── test_asset_validation.py
|
||||
└── e2e/ # 端到端测试(占位)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试命名
|
||||
|
||||
### 4.1 测试文件
|
||||
|
||||
```
|
||||
test_{module_name}.py
|
||||
```
|
||||
|
||||
### 4.2 测试函数
|
||||
|
||||
**推荐格式**:`test_{what}_{condition}`
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
def test_create_project_with_valid_name():
|
||||
pass
|
||||
|
||||
def test_create_project_with_empty_name_should_fail():
|
||||
pass
|
||||
|
||||
def test_list_projects_by_workspace():
|
||||
pass
|
||||
|
||||
def test_ingest_asset_updates_job_status_to_completed():
|
||||
pass
|
||||
|
||||
# ❌ 错误
|
||||
def test1():
|
||||
pass
|
||||
|
||||
def test_project():
|
||||
pass
|
||||
|
||||
def test_stuff():
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试结构(AAA 模式)
|
||||
|
||||
**Arrange - Act - Assert**
|
||||
|
||||
```python
|
||||
def test_create_project():
|
||||
# Arrange(准备)
|
||||
workspace_id = "ws-1"
|
||||
name = "测试项目"
|
||||
repository = InMemoryProjectRepository()
|
||||
use_case = CreateProjectUseCase(repository)
|
||||
|
||||
# Act(执行)
|
||||
project = use_case.execute(
|
||||
CreateProjectCommand(workspace_id=workspace_id, name=name)
|
||||
)
|
||||
|
||||
# Assert(断言)
|
||||
assert project.name == name
|
||||
assert project.workspace_id == workspace_id
|
||||
assert project.id != ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 集成测试
|
||||
|
||||
### 6.1 测试完整业务流程
|
||||
|
||||
```python
|
||||
def test_upload_to_asset_full_pipeline():
|
||||
"""测试完整上传链路:上传 → storage → 入库任务 → worker → asset 创建。"""
|
||||
# Arrange
|
||||
job_repo = InMemoryIngestJobRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
|
||||
# Act - 提交入库任务
|
||||
use_case = SubmitIngestJobUseCase(job_repo)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
workspace_id="ws-1",
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
storage_key="uploads/abc123/video.mp4",
|
||||
)
|
||||
)
|
||||
|
||||
# Act - 模拟 worker 处理
|
||||
result = simulate_ingest_asset(job.id, job_repo, asset_repo)
|
||||
|
||||
# Assert - 验证任务完成
|
||||
assert result["status"] == "completed"
|
||||
updated_job = job_repo.get(job.id)
|
||||
assert updated_job.status == IngestJobStatus.COMPLETED
|
||||
|
||||
# Assert - 验证 asset 创建
|
||||
assets = asset_repo.list_by_library("lib-1")
|
||||
assert len(assets) == 1
|
||||
assert assets[0].storage_key == "uploads/abc123/video.mp4"
|
||||
```
|
||||
|
||||
### 6.2 使用 In-Memory 实现
|
||||
|
||||
```python
|
||||
from packages.adapters.in_memory import (
|
||||
InMemoryProjectRepository,
|
||||
InMemoryAssetRepository,
|
||||
InMemoryIngestJobRepository,
|
||||
)
|
||||
|
||||
def test_something():
|
||||
# 使用 in-memory 实现,快速且无外部依赖
|
||||
repository = InMemoryProjectRepository()
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试场景覆盖
|
||||
|
||||
### 7.1 正常场景(Happy Path)
|
||||
|
||||
```python
|
||||
def test_create_project_with_valid_data():
|
||||
"""测试创建项目(正常场景)。"""
|
||||
pass
|
||||
|
||||
def test_list_projects_returns_all_projects():
|
||||
"""测试查询项目列表(正常场景)。"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 7.2 边界条件(Boundary Cases)
|
||||
|
||||
```python
|
||||
def test_create_project_with_empty_name_should_fail():
|
||||
"""测试创建项目(项目名为空应失败)。"""
|
||||
with pytest.raises(ValueError, match="项目名称不能为空"):
|
||||
Project.create(workspace_id="ws-1", name="")
|
||||
|
||||
def test_create_project_with_max_length_name():
|
||||
"""测试创建项目(项目名最大长度)。"""
|
||||
long_name = "a" * 100
|
||||
project = Project.create(workspace_id="ws-1", name=long_name)
|
||||
assert len(project.name) == 100
|
||||
|
||||
def test_create_project_with_too_long_name_should_fail():
|
||||
"""测试创建项目(项目名超长应失败)。"""
|
||||
too_long_name = "a" * 101
|
||||
with pytest.raises(ValueError, match="项目名称不能超过"):
|
||||
Project.create(workspace_id="ws-1", name=too_long_name)
|
||||
```
|
||||
|
||||
### 7.3 异常场景(Error Cases)
|
||||
|
||||
```python
|
||||
def test_get_nonexistent_project_returns_none():
|
||||
"""测试查询不存在的项目(应返回 None)。"""
|
||||
repository = InMemoryProjectRepository()
|
||||
project = repository.get("nonexistent-id")
|
||||
assert project is None
|
||||
|
||||
def test_update_nonexistent_project_should_fail():
|
||||
"""测试更新不存在的项目(应失败)。"""
|
||||
repository = InMemoryProjectRepository()
|
||||
with pytest.raises(ValueError, match="项目.*不存在"):
|
||||
repository.update(Project(id="nonexistent-id", ...))
|
||||
```
|
||||
|
||||
### 7.4 并发场景(Concurrency)
|
||||
|
||||
```python
|
||||
def test_concurrent_create_same_project():
|
||||
"""测试并发创建相同项目(应处理冲突)。"""
|
||||
# Phase 2 补充
|
||||
pass
|
||||
```
|
||||
|
||||
### 7.5 回归场景(Regression)
|
||||
|
||||
```python
|
||||
def test_regression_ingest_job_status_not_reset():
|
||||
"""回归测试:验证 IngestJob 状态不会被意外重置(bug #123)。"""
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Fixtures
|
||||
|
||||
### 8.1 共享 Fixtures
|
||||
|
||||
在 `conftest.py` 中定义:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from packages.adapters.in_memory import InMemoryProjectRepository
|
||||
|
||||
@pytest.fixture
|
||||
def project_repository():
|
||||
"""项目仓储 fixture。"""
|
||||
return InMemoryProjectRepository()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project():
|
||||
"""示例项目 fixture。"""
|
||||
return Project.create(
|
||||
workspace_id="ws-1",
|
||||
name="测试项目",
|
||||
description="这是一个测试项目"
|
||||
)
|
||||
```
|
||||
|
||||
使用:
|
||||
|
||||
```python
|
||||
def test_create_project(project_repository):
|
||||
"""测试创建项目。"""
|
||||
project = Project.create(workspace_id="ws-1", name="新项目")
|
||||
saved = project_repository.create(project)
|
||||
assert saved.id == project.id
|
||||
|
||||
def test_list_projects(project_repository, sample_project):
|
||||
"""测试查询项目列表。"""
|
||||
project_repository.create(sample_project)
|
||||
projects = project_repository.list_by_workspace("ws-1")
|
||||
assert len(projects) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试标记(Markers)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_large_dataset():
|
||||
"""慢速测试(大数据集)。"""
|
||||
pass
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_full_pipeline():
|
||||
"""集成测试。"""
|
||||
pass
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_entity_validation():
|
||||
"""单元测试。"""
|
||||
pass
|
||||
```
|
||||
|
||||
运行特定标记的测试:
|
||||
|
||||
```bash
|
||||
# 只运行集成测试
|
||||
pytest -m integration
|
||||
|
||||
# 跳过慢速测试
|
||||
pytest -m "not slow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 断言(Assertions)
|
||||
|
||||
### 10.1 基本断言
|
||||
|
||||
```python
|
||||
# 相等
|
||||
assert result == expected
|
||||
|
||||
# 不相等
|
||||
assert result != unexpected
|
||||
|
||||
# 包含
|
||||
assert item in collection
|
||||
assert key in dictionary
|
||||
|
||||
# 真值
|
||||
assert condition
|
||||
assert not condition
|
||||
```
|
||||
|
||||
### 10.2 异常断言
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# 验证抛出异常
|
||||
with pytest.raises(ValueError):
|
||||
Project.create(workspace_id="ws-1", name="")
|
||||
|
||||
# 验证异常消息
|
||||
with pytest.raises(ValueError, match="项目名称不能为空"):
|
||||
Project.create(workspace_id="ws-1", name="")
|
||||
```
|
||||
|
||||
### 10.3 近似断言
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# 浮点数近似相等
|
||||
assert result == pytest.approx(0.85, rel=1e-2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试数据
|
||||
|
||||
### 11.1 避免硬编码 ID
|
||||
|
||||
```python
|
||||
# ❌ 错误
|
||||
def test_create_project():
|
||||
project = Project(id="proj-123", ...)
|
||||
|
||||
# ✅ 正确
|
||||
def test_create_project():
|
||||
project = Project.create(workspace_id="ws-1", name="测试项目")
|
||||
assert project.id != "" # ID 由系统生成
|
||||
```
|
||||
|
||||
### 11.2 使用有意义的测试数据
|
||||
|
||||
```python
|
||||
# ❌ 错误
|
||||
def test_create_project():
|
||||
project = Project.create(workspace_id="a", name="b")
|
||||
|
||||
# ✅ 正确
|
||||
def test_create_project():
|
||||
project = Project.create(
|
||||
workspace_id="ws-test-001",
|
||||
name="电商平台项目",
|
||||
description="2026 年 Q2 电商平台重构项目"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 测试覆盖率
|
||||
|
||||
### 12.1 目标覆盖率
|
||||
|
||||
- **Domain 层**: 100%
|
||||
- **Application 层**: 90%+
|
||||
- **Adapters 层**: 80%+
|
||||
- **API 层**: 70%+
|
||||
|
||||
### 12.2 查看覆盖率
|
||||
|
||||
```bash
|
||||
# 生成 HTML 报告
|
||||
pytest --cov=packages --cov=apps --cov-report=html
|
||||
|
||||
# 打开报告
|
||||
open htmlcov/index.html # macOS/Linux
|
||||
start htmlcov/index.html # Windows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试最佳实践
|
||||
|
||||
### 13.1 每个测试独立
|
||||
|
||||
```python
|
||||
# ✅ 正确 - 每个测试独立
|
||||
def test_create_project():
|
||||
repository = InMemoryProjectRepository()
|
||||
project = Project.create(workspace_id="ws-1", name="项目 1")
|
||||
repository.create(project)
|
||||
|
||||
def test_list_projects():
|
||||
repository = InMemoryProjectRepository() # 新实例
|
||||
project = Project.create(workspace_id="ws-1", name="项目 2")
|
||||
repository.create(project)
|
||||
projects = repository.list_by_workspace("ws-1")
|
||||
assert len(projects) == 1
|
||||
```
|
||||
|
||||
### 13.2 测试一件事
|
||||
|
||||
```python
|
||||
# ❌ 错误 - 测试多件事
|
||||
def test_project_crud():
|
||||
repository = InMemoryProjectRepository()
|
||||
# 创建
|
||||
project = Project.create(...)
|
||||
repository.create(project)
|
||||
# 查询
|
||||
found = repository.get(project.id)
|
||||
# 更新
|
||||
found.name = "新名称"
|
||||
repository.update(found)
|
||||
# 删除
|
||||
repository.delete(project.id)
|
||||
|
||||
# ✅ 正确 - 拆分成多个测试
|
||||
def test_create_project():
|
||||
pass
|
||||
|
||||
def test_get_project():
|
||||
pass
|
||||
|
||||
def test_update_project():
|
||||
pass
|
||||
|
||||
def test_delete_project():
|
||||
pass
|
||||
```
|
||||
|
||||
### 13.3 避免测试实现细节
|
||||
|
||||
```python
|
||||
# ❌ 错误 - 测试实现细节
|
||||
def test_project_repository_uses_dict():
|
||||
repository = InMemoryProjectRepository()
|
||||
assert isinstance(repository._items, dict)
|
||||
|
||||
# ✅ 正确 - 测试行为
|
||||
def test_project_repository_stores_project():
|
||||
repository = InMemoryProjectRepository()
|
||||
project = Project.create(workspace_id="ws-1", name="项目")
|
||||
repository.create(project)
|
||||
|
||||
found = repository.get(project.id)
|
||||
assert found is not None
|
||||
assert found.name == "项目"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 持续集成(Phase 2)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest tests/ --cov=packages --cov=apps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-15
|
||||
**版本**: v1.0
|
||||
Reference in New Issue
Block a user