chore: import zh skill fullstack-dev
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- Skill 名称:`fullstack-dev`
|
||||
- 中文类目:通用后端系统设计与全栈脚手架
|
||||
- 上游仓库:`minimax-ai__skills`
|
||||
- 上游路径:`skills/fullstack-dev/SKILL.md`
|
||||
- 上游链接:https://github.com/minimax-ai/skills/blob/HEAD/skills/fullstack-dev/SKILL.md
|
||||
- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理
|
||||
- 原作者、版权和许可证信息以上游仓库为准
|
||||
@@ -0,0 +1,444 @@
|
||||
---
|
||||
name: fullstack-dev-api-design
|
||||
description: "API 设计模式与最佳实践。适用于创建端点、选择方法/状态码、实现分页或编写 OpenAPI 规范。可避免常见的 REST/GraphQL/gRPC 错误。"
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "2.0.0"
|
||||
sources:
|
||||
- Microsoft REST API Guidelines
|
||||
- Google API Design Guide
|
||||
- Zalando RESTful API Guidelines
|
||||
- JSON:API Specification
|
||||
- RFC 9457 (Problem Details for HTTP APIs)
|
||||
- RFC 9110 (HTTP Semantics)
|
||||
---
|
||||
|
||||
# API 设计指南
|
||||
|
||||
面向后端与全栈工程师的框架无关 API 设计指南。涵盖 10 大类共 50 余条规则,按影响程度排序。覆盖 REST、GraphQL 和 gRPC。
|
||||
|
||||
## 适用范围
|
||||
|
||||
**使用此技能的场景:**
|
||||
- 设计新 API 或添加端点
|
||||
- 审查 API 拉取请求
|
||||
- 在 REST / GraphQL / gRPC 之间做选择
|
||||
- 编写 OpenAPI 规范
|
||||
- 迁移或对现有 API 进行版本管理
|
||||
|
||||
**不适用的场景:**
|
||||
- 框架特定的实现细节(请使用对应框架的技能/文档)
|
||||
- 前端数据获取模式(请使用 React Query / SWR 文档)
|
||||
- 认证实现细节(请使用认证库的文档)
|
||||
- 数据库 schema 设计(→ `database-schema-design`)
|
||||
|
||||
## 所需上下文
|
||||
|
||||
在应用此技能前,请收集以下信息:
|
||||
|
||||
| 必填项 | 可选项 |
|
||||
|----------|----------|
|
||||
| 目标消费者(浏览器、移动端、服务) | 项目中已有的 API 约定 |
|
||||
| 预期请求量(RPS 预估) | 当前的 OpenAPI / Swagger 规范 |
|
||||
| 认证方式(JWT、API Key、OAuth) | 限流需求 |
|
||||
| 数据模型 / 领域实体 | 缓存策略 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始清单
|
||||
|
||||
新建 API 端点?写代码前逐一检查:
|
||||
|
||||
- [ ] 资源命名为**复数名词**(`/orders`,而不是 `/getOrders`)
|
||||
- [ ] URL 使用**短横线命名**,请求体字段使用**驼峰命名**
|
||||
- [ ] 使用正确的 **HTTP 方法**(GET=读取,POST=创建,PUT=全量替换,PATCH=部分更新,DELETE=删除)
|
||||
- [ ] 使用正确的**状态码**(201 Created、422 Validation、404 Not Found……)
|
||||
- [ ] 错误响应遵循 **RFC 9457** 格式
|
||||
- [ ] 所有列表端点实现**分页**(默认 20,最多 100)
|
||||
- [ ] 要求**认证**(Bearer Token,而非查询参数)
|
||||
- [ ] 响应头中包含**请求 ID**(`X-Request-Id`)
|
||||
- [ ] 包含**限流**响应头
|
||||
- [ ] 端点已在 **OpenAPI 规范**中记录
|
||||
|
||||
---
|
||||
|
||||
## 快速导航
|
||||
|
||||
| 需要…… | 跳转至 |
|
||||
|----------|---------|
|
||||
| 命名资源 URL | [1. 资源建模](#1-资源建模关键) |
|
||||
| 选择 HTTP 方法与状态码 | [3. HTTP 方法与状态码](#3-http-方法与状态码关键) |
|
||||
| 格式化错误响应 | [4. 错误处理](#4-错误处理高优先级) |
|
||||
| 添加分页或过滤 | [6. 分页与过滤](#6-分页与过滤高优先级) |
|
||||
| 选择 API 风格(REST vs GraphQL vs gRPC) | [10. API 风格决策](#10-api-风格决策树) |
|
||||
| 对现有 API 进行版本管理 | [7. 版本管理](#7-版本管理中高优先级) |
|
||||
| 避免常见错误 | [反模式清单](#反模式清单) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 资源建模(关键)
|
||||
|
||||
### 核心规则
|
||||
|
||||
```
|
||||
✅ /users — 复数名词
|
||||
✅ /users/{id}/orders — 1 层嵌套
|
||||
✅ /reviews?orderId={oid} — 使用查询参数展平深层嵌套
|
||||
|
||||
❌ /getUsers — URL 中包含动词
|
||||
❌ /user — 单数
|
||||
❌ /users/{uid}/orders/{oid}/items/{iid}/reviews — 超过 3 层嵌套
|
||||
```
|
||||
|
||||
**最大嵌套层级:2 层。** 超过此限制时,提升为顶层资源并通过过滤参数查询。
|
||||
|
||||
### 领域对齐
|
||||
|
||||
资源映射到**领域概念**,而非数据库表:
|
||||
|
||||
```
|
||||
✅ /checkout-sessions (领域聚合)
|
||||
✅ /shipping-labels (领域概念)
|
||||
|
||||
❌ /tbl_order_header (数据库表泄露)
|
||||
❌ /join_user_role (内部 schema 泄露)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. URL 与命名(关键)
|
||||
|
||||
| 上下文 | 规范 | 示例 |
|
||||
|---------|-----------|---------|
|
||||
| URL 路径 | 短横线命名 | `/order-items` |
|
||||
| JSON 请求体字段 | 驼峰命名 | `{ "firstName": "Jane" }` |
|
||||
| 查询参数 | 驼峰命名或下划线命名(保持一致) | `?sortBy=createdAt` |
|
||||
| 请求头 | 连字符大写命名 | `X-Request-Id` |
|
||||
|
||||
**Python 例外:** 如果整个技术栈都是 Python/下划线命名,可以在 JSON 中使用 `snake_case`——但必须在**所有端点间保持一致**。
|
||||
|
||||
```
|
||||
✅ GET /users ❌ GET /users/
|
||||
✅ GET /reports/annual ❌ GET /reports/annual.json
|
||||
✅ POST /users ❌ POST /users/create
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. HTTP 方法与状态码(关键)
|
||||
|
||||
### 方法语义
|
||||
|
||||
| 方法 | 语义 | 幂等 | 安全 | 请求体 |
|
||||
|--------|-----------|-----------|------|-------------|
|
||||
| GET | 读取 | ✅ | ✅ | ❌ 从不 |
|
||||
| POST | 创建 / 动作 | ❌ | ❌ | ✅ 始终 |
|
||||
| PUT | 全量替换 | ✅ | ❌ | ✅ 始终 |
|
||||
| PATCH | 部分更新 | ❌* | ❌ | ✅ 始终 |
|
||||
| DELETE | 删除 | ✅ | ❌ | ❌ 极少 |
|
||||
|
||||
### 状态码快速参考
|
||||
|
||||
**成功类:**
|
||||
|
||||
| 状态码 | 使用时机 | 响应体 |
|
||||
|------|------|--------------|
|
||||
| 200 OK | GET、PUT、PATCH 成功 | 资源 / 结果 |
|
||||
| 201 Created | POST 创建了资源 | 已创建的资源 + `Location` 请求头 |
|
||||
| 202 Accepted | 异步操作已启动 | 任务 ID / 状态 URL |
|
||||
| 204 No Content | DELETE 成功、PUT 无请求体 | 无 |
|
||||
|
||||
**客户端错误类:**
|
||||
|
||||
| 状态码 | 使用时机 | 关键区别 |
|
||||
|------|------|-----------------|
|
||||
| 400 Bad Request | 语法格式错误 | 无法解析 |
|
||||
| 401 Unauthorized | 缺少或无效的认证凭据 | "你是谁?" |
|
||||
| 403 Forbidden | 已认证但无权限 | "我认识你,但不行" |
|
||||
| 404 Not Found | 资源不存在 | 也可用于隐藏 403 |
|
||||
| 409 Conflict | 重复、版本不匹配 | 状态冲突 |
|
||||
| 422 Unprocessable | 语法正确但验证失败 | 语义错误 |
|
||||
| 429 Too Many Requests | 触发限流 | 包含 `Retry-After` |
|
||||
|
||||
**服务端错误类:** 500(意外错误)、502(上游失败)、503(过载)、504(上游超时)
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理(高优先级)
|
||||
|
||||
### 标准错误格式(RFC 9457)
|
||||
|
||||
所有错误响应均使用此格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/errors/insufficient-funds",
|
||||
"title": "余额不足",
|
||||
"status": 422,
|
||||
"detail": "账户余额 $10.00 低于提现金额 $50.00。",
|
||||
"instance": "/transactions/txn_abc123",
|
||||
"request_id": "req_7f3a8b2c",
|
||||
"errors": [
|
||||
{ "field": "amount", "message": "超出余额", "code": "INSUFFICIENT_BALANCE" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 多语言实现
|
||||
|
||||
**TypeScript(Express):**
|
||||
```typescript
|
||||
class AppError extends Error {
|
||||
constructor(
|
||||
public readonly title: string,
|
||||
public readonly status: number,
|
||||
public readonly detail: string,
|
||||
public readonly code: string,
|
||||
) { super(detail); }
|
||||
}
|
||||
|
||||
// 中间件
|
||||
app.use((err, req, res, next) => {
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.status).json({
|
||||
type: `https://api.example.com/errors/${err.code}`,
|
||||
title: err.title, status: err.status,
|
||||
detail: err.detail, request_id: req.id,
|
||||
});
|
||||
}
|
||||
res.status(500).json({ title: '内部错误', status: 500, request_id: req.id });
|
||||
});
|
||||
```
|
||||
|
||||
**Python(FastAPI):**
|
||||
```python
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
class AppError(Exception):
|
||||
def __init__(self, title: str, status: int, detail: str, code: str):
|
||||
self.title, self.status, self.detail, self.code = title, status, detail, code
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError):
|
||||
return JSONResponse(status_code=exc.status, content={
|
||||
"type": f"https://api.example.com/errors/{exc.code}",
|
||||
"title": exc.title, "status": exc.status,
|
||||
"detail": exc.detail, "request_id": request.state.request_id,
|
||||
})
|
||||
```
|
||||
|
||||
### 铁律
|
||||
|
||||
```
|
||||
✅ 所有错误均返回 RFC 9457 错误格式
|
||||
✅ 每个错误响应都包含 request_id
|
||||
✅ 字段级验证错误在 `errors` 数组中返回
|
||||
|
||||
❌ 绝不在生产环境暴露堆栈信息
|
||||
❌ 绝不为错误返回 200 状态码
|
||||
❌ 绝不静默吞掉错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 认证与授权(高优先级)
|
||||
|
||||
```
|
||||
✅ Authorization: Bearer eyJhbGci... (请求头)
|
||||
❌ GET /users?token=eyJhbGci... (URL——会出现在日志中)
|
||||
|
||||
✅ 401 → "你是谁?" (缺少或无效的凭据)
|
||||
✅ 403 → "你不能这样做" (已认证,无权限)
|
||||
✅ 404 → 隐藏资源存在性 (需要时用来替代 403)
|
||||
```
|
||||
|
||||
**限流响应头(始终包含):**
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 42
|
||||
X-RateLimit-Reset: 1625097600
|
||||
Retry-After: 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 分页与过滤(高优先级)
|
||||
|
||||
### 游标分页 vs 偏移分页
|
||||
|
||||
| 策略 | 适用场景 | 优点 | 缺点 |
|
||||
|----------|------|------|------|
|
||||
| **游标分页**(推荐) | 大型/动态数据集 | 数据一致,无跳变 | 无法跳转到第 N 页 |
|
||||
| **偏移分页** | 小型/稳定数据集、管理后台 | 简单,支持页面跳转 | 插入/删除时数据漂移 |
|
||||
|
||||
**游标分页响应:**
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": { "next_cursor": "eyJpZCI6MTIwfQ", "has_more": true }
|
||||
}
|
||||
```
|
||||
|
||||
**偏移分页响应:**
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": { "page": 3, "per_page": 20, "total": 256, "total_pages": 13 }
|
||||
}
|
||||
```
|
||||
|
||||
**始终执行:** 默认 20 条,最多 100 条。
|
||||
|
||||
### 标准过滤模式
|
||||
|
||||
```
|
||||
GET /orders?status=shipped&created_after=2025-01-01&sort=-created_at&fields=id,status
|
||||
```
|
||||
|
||||
| 模式 | 规范 |
|
||||
|---------|-----------|
|
||||
| 精确匹配 | `?status=shipped` |
|
||||
| 范围查询 | `?price_gte=10&price_lte=100` |
|
||||
| 日期范围 | `?created_after=2025-01-01&created_before=2025-12-31` |
|
||||
| 排序 | `?sort=field`(升序)、`?sort=-field`(降序) |
|
||||
| 稀疏字段 | `?fields=id,name,email` |
|
||||
| 搜索 | `?q=搜索+关键词` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本管理(中高优先级)
|
||||
|
||||
| 策略 | 格式 | 最佳适用 |
|
||||
|----------|--------|----------|
|
||||
| **URL 路径**(推荐) | `/v1/users` | 公开 API |
|
||||
| **请求头** | `Api-Version: 2` | 内部 API |
|
||||
| **查询参数** | `?version=2` | 遗留系统(避免使用) |
|
||||
|
||||
**非破坏性变更(无需升级版本号):** 新增可选的响应字段、新增端点、新增可选参数。
|
||||
|
||||
**破坏性变更(需新版本):** 删除/重命名字段、更改类型、收紧验证规则、删除端点。
|
||||
|
||||
**弃用响应头:**
|
||||
```
|
||||
Sunset: Sat, 01 Mar 2026 00:00:00 GMT
|
||||
Deprecation: true
|
||||
Link: <https://api.example.com/v2/users>; rel="successor-version"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 请求/响应设计(中优先级)
|
||||
|
||||
### 统一的响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { "id": "ord_123", "status": "pending", "total": 99.50 },
|
||||
"meta": { "request_id": "req_abc123", "timestamp": "2025-06-15T10:30:00Z" }
|
||||
}
|
||||
```
|
||||
|
||||
### 关键规则
|
||||
|
||||
| 规则 | 正确 | 错误 |
|
||||
|------|---------|-------|
|
||||
| 时间戳 | `"2025-06-15T10:30:00Z"`(ISO 8601) | `"06/15/2025"` 或 `1718447400` |
|
||||
| 公开 ID | UUID `"550e8400-..."` | 自增 `42` |
|
||||
| Null 与缺省(PATCH) | `{ "nickname": null }` = 清空字段 | 缺省字段 = 不做更改 |
|
||||
| HATEOAS(公开 API) | `"links": { "cancel": "/orders/123/cancel" }` | 无可发现性 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 文档——OpenAPI(中优先级)
|
||||
|
||||
**设计优先的工作流程:**
|
||||
|
||||
```
|
||||
1. 编写 OpenAPI 3.1 规范
|
||||
2. 与相关方审查规范
|
||||
3. 生成服务端桩代码 + 客户端 SDK
|
||||
4. 实现处理器
|
||||
5. 在 CI 中对照规范验证响应
|
||||
```
|
||||
|
||||
每个端点均需记录:摘要、所有参数、请求体及示例、所有响应码及 schema、认证要求。
|
||||
|
||||
---
|
||||
|
||||
## 10. API 风格决策树
|
||||
|
||||
```
|
||||
什么样的 API?
|
||||
│
|
||||
├─ 浏览器 + 移动端客户端,需要灵活查询
|
||||
│ └─ GraphQL
|
||||
│ 规则:DataLoader(避免 N+1)、深度限制 ≤7、Relay 分页
|
||||
│
|
||||
├─ 标准 CRUD,面向公开消费者,缓存很重要
|
||||
│ └─ REST(本指南)
|
||||
│ 规则:资源、HTTP 方法、状态码、OpenAPI
|
||||
│
|
||||
├─ 服务间通信,高吞吐量,强类型
|
||||
│ └─ gRPC
|
||||
│ 规则:Protobuf schema、大数据流式传输、截止时间
|
||||
│
|
||||
├─ 全栈 TypeScript,同一团队拥有客户端和服务端
|
||||
│ └─ tRPC
|
||||
│ 规则:共享类型,无需代码生成
|
||||
│
|
||||
└─ 实时双向通信
|
||||
└─ WebSocket / SSE
|
||||
规则:心跳、重连、消息排序
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 反模式清单
|
||||
|
||||
| # | ❌ 不要这样做 | ✅ 应该这样做 |
|
||||
|---|---------|--------------|
|
||||
| 1 | URL 中包含动词(`/getUser`) | HTTP 方法 + 名词资源 |
|
||||
| 2 | 错误时返回 200 | 正确的 4xx/5xx 状态码 |
|
||||
| 3 | 混合使用多种命名风格 | 每个上下文保持一种规范 |
|
||||
| 4 | 暴露数据库 ID | 使用 UUID 作为公开标识符 |
|
||||
| 5 | 列表不进行分页 | 始终分页(默认 20 条) |
|
||||
| 6 | 静默吞掉错误 | 结构化的 RFC 9457 错误 |
|
||||
| 7 | Token 放在 URL 查询参数中 | Authorization 请求头 |
|
||||
| 8 | 深层嵌套(3 层以上) | 使用查询参数展平 |
|
||||
| 9 | 破坏性变更不升级版本 | 保持兼容或使用版本管理 |
|
||||
| 10 | 不设限流 | 实现限流并通过请求头告知 |
|
||||
| 11 | 无请求 ID | 每个响应都包含 `X-Request-Id` |
|
||||
| 12 | 生产环境暴露堆栈信息 | 安全的错误消息 + 内部日志 |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:"这应该是新资源还是子资源?"
|
||||
|
||||
**现象:** URL 路径不断增长(`/users/{id}/orders/{id}/items/{id}/reviews`)
|
||||
|
||||
**规则:** 如果子实体本身有意义,则提升为顶层资源。如果它只在父级上下文中存在,则保持嵌套(最多 2 层)。
|
||||
|
||||
```
|
||||
/reviews?orderId=123 ✅ (reviews 独立存在)
|
||||
/orders/{id}/items ✅ (items 属于 orders,1 层)
|
||||
```
|
||||
|
||||
### 问题 2:"PUT 还是 PATCH?"
|
||||
|
||||
**现象:** 团队对更新语义无法达成一致。
|
||||
|
||||
**规则:**
|
||||
- PUT = 客户端发送**完整**资源(缺失字段 → 设为默认值/null)
|
||||
- PATCH = 客户端只发送**发生变化的字段**(缺失字段 → 保持不变)
|
||||
- 不确定时 → **PATCH**(更安全,更少意外)
|
||||
|
||||
### 问题 3:"400 还是 422?"
|
||||
|
||||
**现象:** 验证错误码不一致。
|
||||
|
||||
**规则:**
|
||||
- 400 = 完全无法解析请求(格式错误的 JSON、错误的 content-type)
|
||||
- 422 = 解析成功,但值未通过验证(无效的 email、负数数量)
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: auth-flow-patterns
|
||||
description: 完整的认证流程模式,涵盖前端与后端
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# 认证流程模式
|
||||
|
||||
前端和后端的完整认证流程。涵盖 JWT Bearer 流程、自动令牌刷新、Next.js 服务端认证、RBAC 以及后端中间件顺序。
|
||||
|
||||
---
|
||||
|
||||
## JWT Bearer 流程(最常见)
|
||||
|
||||
```
|
||||
1. 登录
|
||||
客户端 → POST /api/auth/login { email, password }
|
||||
服务端 → { accessToken(15分钟), refreshToken(7天,httpOnly cookie) }
|
||||
|
||||
2. 认证请求
|
||||
客户端 → GET /api/orders Authorization: Bearer <accessToken>
|
||||
服务端 → 验证 JWT → 返回数据
|
||||
|
||||
3. 令牌刷新(无感透明)
|
||||
客户端 → 收到 401 → POST /api/auth/refresh(cookie 自动发送)
|
||||
服务端 → 新的 accessToken
|
||||
客户端 → 使用新令牌重试原始请求
|
||||
|
||||
4. 登出
|
||||
客户端 → POST /api/auth/logout
|
||||
服务端 → 使 refresh token 失效 → 清除 cookie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端:自动令牌刷新
|
||||
|
||||
```typescript
|
||||
// lib/api-client.ts — 添加到现有的 fetch 封装中
|
||||
async function apiWithRefresh<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
return await api<T>(path, options);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
// 尝试刷新
|
||||
const refreshed = await api<{ accessToken: string }>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // 发送 httpOnly cookie
|
||||
});
|
||||
setAuthToken(refreshed.accessToken);
|
||||
// 重试原始请求
|
||||
return api<T>(path, options);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js:服务端认证(App Router)
|
||||
|
||||
```typescript
|
||||
// middleware.ts — 在服务端保护路由
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get('session')?.value;
|
||||
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// app/dashboard/page.tsx — 带认证的服务端组件
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export default async function Dashboard() {
|
||||
const token = (await cookies()).get('session')?.value;
|
||||
const user = await fetch(`${process.env.API_URL}/api/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then(r => r.json());
|
||||
|
||||
return <DashboardContent user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端:标准中间件顺序
|
||||
|
||||
```
|
||||
请求 → 1.请求ID → 2.日志 → 3.CORS → 4.限流 → 5.请求体解析
|
||||
→ 6.认证 → 7.授权 → 8.校验 → 9.处理器 → 10.错误处理 → 响应
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端:JWT 规则
|
||||
|
||||
```
|
||||
✅ 短时效的 access token(15分钟)+ refresh token(服务端存储)
|
||||
✅ 最小化声明:userId、roles(不包含整个用户对象)
|
||||
✅ 定期轮换签名密钥
|
||||
|
||||
❌ 切勿将令牌存储在 localStorage 中(存在 XSS 风险)
|
||||
❌ 切勿在 URL 查询参数中传递令牌(会被记录)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端:RBAC 模式
|
||||
|
||||
```typescript
|
||||
function authorize(...roles: Role[]) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) throw new UnauthorizedError();
|
||||
if (!roles.some(r => req.user.roles.includes(r))) throw new ForbiddenError();
|
||||
next();
|
||||
};
|
||||
}
|
||||
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 认证方式决策表
|
||||
|
||||
| 方法 | 适用场景 | 前端 |
|
||||
|--------|------|----------|
|
||||
| Session | 同域、SSR、Django 模板 | Django templates / htmx |
|
||||
| JWT | 跨域、SPA、移动端 | React, Vue, 移动端应用 |
|
||||
| OAuth2 | 第三方登录、API 消费者 | 任何 |
|
||||
|
||||
---
|
||||
|
||||
## 铁律
|
||||
|
||||
```
|
||||
✅ Access token:短时效(15分钟),存于内存
|
||||
✅ Refresh token:httpOnly cookie(XSS 安全)
|
||||
✅ 收到 401 时自动无感刷新
|
||||
✅ 刷新失败时重定向到登录页
|
||||
|
||||
❌ 切勿将令牌存储在 localStorage 中(存在 XSS 风险)
|
||||
❌ 切勿在 URL 查询参数中传递令牌(会被记录)
|
||||
❌ 切勿仅依赖客户端认证检查(服务端必须验证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:「页面加载时认证正常,导航后失效」
|
||||
|
||||
**原因:** 令牌存储在组件状态中(组件卸载后丢失)。
|
||||
|
||||
**解决方案:** 将 access token 存储在持久化位置:
|
||||
- React Context(导航后仍存活,页面刷新后丢失)
|
||||
- Cookie(刷新后仍存活)
|
||||
- React Query 缓存,对 session 设置 `staleTime: Infinity`
|
||||
|
||||
### 问题 2:「认证请求出现 CORS 错误」
|
||||
|
||||
**原因:** 前端缺少 `credentials: 'include'` 或后端 CORS 配置中缺少 `credentials: true`。
|
||||
|
||||
**解决方案:**
|
||||
1. 前端:`fetch(url, { credentials: 'include' })`
|
||||
2. 后端:`cors({ origin: 'https://your-frontend.com', credentials: true })`
|
||||
3. 后端:使用凭据时必须指定明确的 origin(不能使用 `*`)
|
||||
@@ -0,0 +1,705 @@
|
||||
---
|
||||
name: fullstack-dev-db-schema
|
||||
description: "数据库表结构设计与迁移。适用于创建表、定义 ORM 模型、添加索引或设计关系。涵盖零停机迁移与多租户。"
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
sources:
|
||||
- PostgreSQL 官方文档
|
||||
- Use The Index, Luke (use-the-index-luke.com)
|
||||
- 设计数据密集型应用(Martin Kleppmann)
|
||||
- 数据库可靠性工程(Laine Campbell & Charity Majors)
|
||||
---
|
||||
|
||||
# 数据库表结构设计
|
||||
|
||||
ORM 无关的关系型数据库表结构设计指南。涵盖数据建模、规范化、索引、迁移、多租户以及常见应用模式。主要面向 PostgreSQL,但原则同样适用于 MySQL/MariaDB。
|
||||
|
||||
## 适用范围
|
||||
|
||||
**使用该技能的场景:**
|
||||
- 为新项目或新功能设计表结构
|
||||
- 在规范化和反规范化之间做决策
|
||||
- 选择需要创建的索引
|
||||
- 规划在线数据库的零停机迁移
|
||||
- 实现多租户数据隔离
|
||||
- 添加审计追踪、软删除或版本控制
|
||||
- 诊断因表结构问题导致的慢查询
|
||||
|
||||
**不适用的场景:**
|
||||
- 选择使用哪种数据库技术(→ `technology-selection`)
|
||||
- PostgreSQL 特定的查询调优(请使用 PostgreSQL 性能文档)
|
||||
- ORM 特定的配置(→ `django-best-practices` 或对应 ORM 的文档)
|
||||
- 应用层缓存(→ `fullstack-dev-practices`)
|
||||
|
||||
## 所需上下文
|
||||
|
||||
| 必需 | 可选 |
|
||||
|----------|----------|
|
||||
| 数据库引擎(PostgreSQL / MySQL) | 预期数据量(行数、增长率) |
|
||||
| 领域实体及其关系 | 读写比例 |
|
||||
| 关键访问模式(查询) | 多租户需求 |
|
||||
|
||||
---
|
||||
|
||||
## 快速入门清单
|
||||
|
||||
设计新表结构:
|
||||
|
||||
- [ ] **识别领域实体**——每个实体映射一张表(而非每个类一张表)
|
||||
- [ ] **主键**:公开 ID 使用 UUID,仅内部使用使用 serial/bigserial
|
||||
- [ ] **外键**带有显式的 `ON DELETE` 行为
|
||||
- [ ] **默认 NOT NULL**——仅在业务逻辑要求时才设为可空
|
||||
- [ ] **时间戳**:每张表都包含 `created_at` + `updated_at`
|
||||
- [ ] **索引**:为每个 WHERE、JOIN、ORDER BY 列创建索引
|
||||
- [ ] **不提前反规范化**——从规范化开始,在有测量依据时才反规范化
|
||||
- [ ] **命名约定**保持一致:`snake_case`,表名使用复数
|
||||
|
||||
---
|
||||
|
||||
## 快速导航
|
||||
|
||||
| 需要… | 跳转至 |
|
||||
|----------|---------|
|
||||
| 建模实体与关系 | [1. 数据建模](#1-数据建模关键) |
|
||||
| 决定规范化还是反规范化 | [2. 规范化](#2-规范化-vs-反规范化关键) |
|
||||
| 选择合适的索引 | [3. 索引](#3-索引策略关键) |
|
||||
| 在在线数据库上安全运行迁移 | [4. 迁移](#4-零停机迁移高) |
|
||||
| 设计多租户表结构 | [5. 多租户](#5-多租户设计高) |
|
||||
| 添加软删除/审计追踪 | [6. 常见模式](#6-常见表结构模式中) |
|
||||
| 对大表进行分区 | [7. 分区](#7-表分区中) |
|
||||
| 查看反模式 | [反模式](#反模式) |
|
||||
|
||||
---
|
||||
|
||||
## 核心原则(7 条规则)
|
||||
|
||||
```
|
||||
1. ✅ 从规范化(3NF)开始——只有在有测量证据时才反规范化
|
||||
2. ✅ 每张表都有主键、created_at、updated_at
|
||||
3. ✅ 对外公开的 ID 使用 UUID,内部连接键使用 serial
|
||||
4. ✅ 默认 NOT NULL——null 是业务决策,而非偷懒的默认值
|
||||
5. ✅ 为每个用在 WHERE、JOIN、ORDER BY 中的列创建索引
|
||||
6. ✅ 外键在数据库中强制执行(而非仅在应用代码中)
|
||||
7. ✅ 迁移是增量的——绝不在生产中一步完成删除/重命名,必须有多步骤计划
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据建模(关键)
|
||||
|
||||
### 表命名
|
||||
|
||||
```sql
|
||||
-- ✅ 复数、snake_case
|
||||
CREATE TABLE orders (...);
|
||||
CREATE TABLE order_items (...);
|
||||
CREATE TABLE user_profiles (...);
|
||||
|
||||
-- ❌ 单数、大小写混用
|
||||
CREATE TABLE Order (...);
|
||||
CREATE TABLE OrderItem (...);
|
||||
CREATE TABLE tbl_usr_prof (...); -- 晦涩的缩写
|
||||
```
|
||||
|
||||
### 主键
|
||||
|
||||
| 策略 | 适用场景 | 优点 | 缺点 |
|
||||
|----------|------|------|------|
|
||||
| `bigserial`(自增) | 内部表、外键连接 | 紧凑、连接快 | 可枚举,不适合公开 ID |
|
||||
| `uuid`(v4 随机) | 面向公开的资源 | 不可猜测、全局唯一 | 体积更大(16 字节)、B-Tree 随机 I/O |
|
||||
| `uuid` v7(按时间排序) | 公开 + 需要排序 | 不可猜测 + 插入友好 | 较新,生态系统支持较少 |
|
||||
| `text` slug | URL 友好的资源 | 人类可读 | 必须强制唯一性,更新成本高 |
|
||||
|
||||
**推荐的默认方案:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
id bigserial PRIMARY KEY, -- 内部外键目标
|
||||
public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE, -- API 对外
|
||||
-- ...
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 关系
|
||||
|
||||
```sql
|
||||
-- 一对多:user → orders
|
||||
CREATE TABLE orders (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- ...
|
||||
);
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
|
||||
-- 多对多:orders ↔ products(通过连接表)
|
||||
CREATE TABLE order_items (
|
||||
id bigserial PRIMARY KEY,
|
||||
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id bigint NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
|
||||
quantity int NOT NULL CHECK (quantity > 0),
|
||||
unit_price numeric(10,2) NOT NULL,
|
||||
UNIQUE (order_id, product_id) -- 防止重复的行项目
|
||||
);
|
||||
|
||||
-- 一对一:user → profile
|
||||
CREATE TABLE user_profiles (
|
||||
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
bio text,
|
||||
avatar_url text,
|
||||
-- ...
|
||||
);
|
||||
```
|
||||
|
||||
### ON DELETE 行为
|
||||
|
||||
| 行为 | 适用场景 | 示例 |
|
||||
|----------|------|---------|
|
||||
| `CASCADE` | 子表数据脱离父表无意义 | 删除订单时同时删除 order_items |
|
||||
| `RESTRICT` | 防止意外删除 | 被 order_items 引用的 products |
|
||||
| `SET NULL` | 保留子表数据,清除引用 | 员工离职时 orders.assigned_to 置空 |
|
||||
| `SET DEFAULT` | 回退为默认值 | 较少使用,用于状态列 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 规范化 vs 反规范化(关键)
|
||||
|
||||
### 从规范化开始(3NF)
|
||||
|
||||
**实际中的范式:**
|
||||
|
||||
| 范式 | 规则 | 违反示例 |
|
||||
|------|------|-------------------|
|
||||
| 1NF | 无重复组、原子值 | 一列中存 `tags = "go,python,rust"` |
|
||||
| 2NF | 无部分依赖(组合键) | `order_items.product_name` 仅依赖于 `product_id` |
|
||||
| 3NF | 无传递依赖 | `orders.customer_city` 依赖于 `customer_id` 而非 `order_id` |
|
||||
|
||||
**1NF 违反修复:**
|
||||
```sql
|
||||
-- ❌ 标签作为逗号分隔的字符串
|
||||
CREATE TABLE posts (id serial, tags text); -- tags = "go,python"
|
||||
|
||||
-- ✅ 单独的表(若简单也可用数组或 JSONB)
|
||||
CREATE TABLE post_tags (
|
||||
post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
|
||||
tag_id bigint REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
|
||||
-- ✅ 替代方案:PostgreSQL 数组(如果标签仅是字符串,无元数据)
|
||||
CREATE TABLE posts (id serial, tags text[] NOT NULL DEFAULT '{}');
|
||||
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);
|
||||
```
|
||||
|
||||
### 何时反规范化
|
||||
|
||||
**仅在以下条件同时满足时才反规范化:**
|
||||
1. 你已经**测量到**性能问题(EXPLAIN ANALYZE,而非「我觉得慢」)
|
||||
2. 反规范化后的数据是**读密集**型(读写比例 > 100:1)
|
||||
3. 你接受**一致性维护成本**(触发器、应用逻辑或物化视图)
|
||||
|
||||
**安全的反规范化模式:**
|
||||
|
||||
```sql
|
||||
-- 模式 1:物化视图(计算后、可刷新)
|
||||
CREATE MATERIALIZED VIEW order_summary AS
|
||||
SELECT o.id, o.user_id, o.total,
|
||||
COUNT(oi.id) AS item_count,
|
||||
u.email AS user_email
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN users u ON u.id = o.user_id
|
||||
GROUP BY o.id, u.email;
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary; -- 非阻塞
|
||||
|
||||
-- 模式 2:缓存聚合列(由应用程序维护)
|
||||
ALTER TABLE orders ADD COLUMN item_count int NOT NULL DEFAULT 0;
|
||||
-- 通过触发器或在 order_item 插入/删除时的应用代码来更新
|
||||
|
||||
-- 模式 3:JSONB 快照(写入时冻结)
|
||||
-- 在购买时存储产品信息的副本
|
||||
CREATE TABLE order_items (
|
||||
id bigserial PRIMARY KEY,
|
||||
order_id bigint NOT NULL REFERENCES orders(id),
|
||||
product_id bigint REFERENCES products(id),
|
||||
quantity int NOT NULL,
|
||||
unit_price numeric(10,2) NOT NULL, -- 冻结的价格
|
||||
product_snapshot jsonb NOT NULL -- 冻结的名称、描述、图片
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 索引策略(关键)
|
||||
|
||||
### 索引类型(PostgreSQL)
|
||||
|
||||
| 类型 | 适用场景 | 示例 |
|
||||
|------|------|---------|
|
||||
| **B-Tree**(默认) | 等值、范围、ORDER BY | `WHERE status = 'active'`、`WHERE created_at > '2025-01-01'` |
|
||||
| **Hash** | 仅等值(很少使用,B-Tree 通常更好) | `WHERE id = 123`(大表,Postgres 10+) |
|
||||
| **GIN** | 数组、JSONB、全文搜索 | `WHERE tags @> '{go}'`、`WHERE data->>'key' = 'val'` |
|
||||
| **GiST** | 几何、范围、最近邻 | PostGIS、tsrange、ltree |
|
||||
| **BRIN** | 带有自然顺序的超大表 | 按时间戳排序的时间序列数据 |
|
||||
|
||||
### 索引决策规则
|
||||
|
||||
```
|
||||
规则 1:为 WHERE 子句中的每个列创建索引
|
||||
规则 2:为 JOIN ON 条件中的每个列创建索引
|
||||
规则 3:为 ORDER BY 中的每个列创建索引(如果与 LIMIT 一起查询)
|
||||
规则 4:多列 WHERE 使用复合索引(最左前缀规则)
|
||||
规则 5:仅过滤子集时使用部分索引(例如,仅活跃记录)
|
||||
规则 6:使用覆盖索引(INCLUDE)以避免回表查询
|
||||
规则 7:不要单独为低基数列建索引(例如布尔值)
|
||||
```
|
||||
|
||||
### 复合索引:列顺序很重要
|
||||
|
||||
```sql
|
||||
-- 查询:WHERE user_id = ? AND status = ? ORDER BY created_at DESC
|
||||
-- ✅ 最优:从左到右匹配查询模式
|
||||
CREATE INDEX idx_orders_user_status_created
|
||||
ON orders(user_id, status, created_at DESC);
|
||||
|
||||
-- ❌ 顺序错误:无法高效用于该查询
|
||||
CREATE INDEX idx_orders_created_user_status
|
||||
ON orders(created_at DESC, user_id, status);
|
||||
```
|
||||
|
||||
**最左前缀规则:** `(A, B, C)` 上的索引支持对 `(A)`、`(A, B)`、`(A, B, C)` 的查询,但不支持 `(B)`、`(C)` 或 `(B, C)` 的查询。
|
||||
|
||||
### 部分索引(仅索引所需数据)
|
||||
|
||||
```sql
|
||||
-- 只有 5% 的订单是 'pending',但频繁查询
|
||||
CREATE INDEX idx_orders_pending
|
||||
ON orders(created_at DESC)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- 只有活跃用户对登录有意义
|
||||
CREATE INDEX idx_users_active_email
|
||||
ON users(email)
|
||||
WHERE is_active = true;
|
||||
```
|
||||
|
||||
### 覆盖索引(避免回表查询)
|
||||
|
||||
```sql
|
||||
-- 查询只需要 id 和 status,无需读取表行
|
||||
CREATE INDEX idx_orders_user_covering
|
||||
ON orders(user_id) INCLUDE (status, total);
|
||||
|
||||
-- 现在该查询是仅索引查询:
|
||||
SELECT status, total FROM orders WHERE user_id = 123;
|
||||
```
|
||||
|
||||
### 何时不要建索引
|
||||
|
||||
```
|
||||
❌ 很少用于 WHERE/JOIN/ORDER BY 的列
|
||||
❌ 行数 < 1000 的表(顺序扫描更快)
|
||||
❌ 单独来看基数值很低的列(例如布尔值 is_active)
|
||||
❌ 写密集的表,索引维护成本超过读收益
|
||||
❌ 重复索引(检查 pg_stat_user_indexes 中的未使用索引)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 零停机迁移(高)
|
||||
|
||||
### 黄金法则
|
||||
|
||||
```
|
||||
绝不要在一个步骤中做破坏性变更。
|
||||
始终:添加 → 迁移数据 → 移除旧数据(分多次部署)。
|
||||
```
|
||||
|
||||
### 安全的迁移模式
|
||||
|
||||
**重命名列(3 次部署):**
|
||||
|
||||
```
|
||||
部署 1:添加新列
|
||||
ALTER TABLE users ADD COLUMN full_name text;
|
||||
UPDATE users SET full_name = name; -- 回填
|
||||
-- 应用同时写入 name 和 full_name
|
||||
|
||||
部署 2:将读取切换到新列
|
||||
-- 应用从 full_name 读取,仍然同时写入两个字段
|
||||
|
||||
部署 3:删除旧列
|
||||
ALTER TABLE users DROP COLUMN name;
|
||||
-- 应用仅使用 full_name
|
||||
```
|
||||
|
||||
**添加 NOT NULL 列(2 次部署):**
|
||||
|
||||
```sql
|
||||
-- 部署 1:添加可空列,回填
|
||||
ALTER TABLE orders ADD COLUMN currency text; -- 先设为可空
|
||||
UPDATE orders SET currency = 'USD' WHERE currency IS NULL; -- 回填
|
||||
|
||||
-- 部署 2:添加约束(所有行回填完成后)
|
||||
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
|
||||
ALTER TABLE orders ALTER COLUMN currency SET DEFAULT 'USD';
|
||||
```
|
||||
|
||||
**无锁添加索引:**
|
||||
|
||||
```sql
|
||||
-- ✅ CONCURRENTLY:不锁表,可在在线数据库上运行
|
||||
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
|
||||
|
||||
-- ❌ 不加 CONCURRENTLY:建索引期间锁住写入
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
```
|
||||
|
||||
### 迁移安全清单
|
||||
|
||||
```
|
||||
✅ 迁移在生产数据量级上运行时间 < 30 秒
|
||||
✅ 无排他表锁(索引使用 CONCURRENTLY)
|
||||
✅ 回滚方案已记录并测试
|
||||
✅ 回填分批执行(而非一次巨型 UPDATE)
|
||||
✅ 新列先以可空方式添加,稍后添加约束
|
||||
✅ 旧列保留到所有代码引用移除为止
|
||||
|
||||
❌ 绝不在一次部署中重命名/删除列
|
||||
❌ 绝不在大表上不做时间测试就执行 ALTER TYPE
|
||||
❌ 绝不在事务中运行数据回填(大表上会 OOM)
|
||||
```
|
||||
|
||||
### 分批回填模板
|
||||
|
||||
```sql
|
||||
-- 以 10,000 行为一批进行回填(避免长时间运行的事务)
|
||||
DO $$
|
||||
DECLARE
|
||||
batch_size int := 10000;
|
||||
affected int;
|
||||
BEGIN
|
||||
LOOP
|
||||
UPDATE orders
|
||||
SET currency = 'USD'
|
||||
WHERE id IN (
|
||||
SELECT id FROM orders WHERE currency IS NULL LIMIT batch_size
|
||||
);
|
||||
GET DIAGNOSTICS affected = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated % rows', affected;
|
||||
EXIT WHEN affected = 0;
|
||||
PERFORM pg_sleep(0.1); -- 短暂暂停以降低负载
|
||||
END LOOP;
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 多租户设计(高)
|
||||
|
||||
### 三种方案
|
||||
|
||||
| 方案 | 隔离性 | 复杂度 | 适用场景 |
|
||||
|----------|-----------|------------|------|
|
||||
| **行级**(共享表 + `tenant_id`) | 低 | 低 | SaaS MVP,租户数 < 1000 |
|
||||
| **每租户独立 Schema** | 中 | 中 | 受监管行业,中等规模 |
|
||||
| **每租户独立数据库** | 高 | 高 | 企业级,严格数据隔离 |
|
||||
|
||||
### 行级租户(最常见)
|
||||
|
||||
```sql
|
||||
-- 每张表都有 tenant_id
|
||||
CREATE TABLE orders (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id bigint NOT NULL REFERENCES tenants(id),
|
||||
user_id bigint NOT NULL REFERENCES users(id),
|
||||
total numeric(10,2) NOT NULL,
|
||||
-- ...
|
||||
);
|
||||
|
||||
-- 复合索引:租户在前(大多数查询按租户过滤)
|
||||
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
|
||||
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);
|
||||
|
||||
-- 行级安全(PostgreSQL)
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON orders
|
||||
USING (tenant_id = current_setting('app.tenant_id')::bigint);
|
||||
```
|
||||
|
||||
**应用层强制:**
|
||||
|
||||
```typescript
|
||||
// 中间件:在每个请求上设置租户上下文
|
||||
app.use((req, res, next) => {
|
||||
const tenantId = req.headers['x-tenant-id'];
|
||||
if (!tenantId) return res.status(400).json({ error: '缺少租户标识' });
|
||||
req.tenantId = tenantId;
|
||||
next();
|
||||
});
|
||||
|
||||
// 仓库层:始终按租户过滤
|
||||
async findOrders(tenantId: string, userId: string) {
|
||||
return db.order.findMany({
|
||||
where: { tenantId, userId }, // ← 每条查询中都有 tenant_id
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
```
|
||||
✅ 每张包含租户数据的表都有 tenant_id
|
||||
✅ tenant_id 作为每个复合索引的第一列
|
||||
✅ 应用中间件强制租户上下文
|
||||
✅ 使用 RLS(PostgreSQL)作为纵深防御,而非唯一保护
|
||||
✅ 用 2 个以上的租户进行测试以验证隔离
|
||||
|
||||
❌ 绝不允许在应用代码中进行跨租户查询
|
||||
❌ 绝不在 WHERE 子句中遗漏 tenant_id(即便在管理工具中)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见表结构模式(中)
|
||||
|
||||
### 软删除
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders ADD COLUMN deleted_at timestamptz;
|
||||
|
||||
-- 所有查询过滤已删除记录
|
||||
CREATE VIEW active_orders AS
|
||||
SELECT * FROM orders WHERE deleted_at IS NULL;
|
||||
|
||||
-- 部分索引:仅索引未删除的行
|
||||
CREATE INDEX idx_orders_active_status
|
||||
ON orders(status, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**ORM 集成:**
|
||||
|
||||
```typescript
|
||||
// Prisma 中间件:自动过滤软删除记录
|
||||
prisma.$use(async (params, next) => {
|
||||
if (params.action === 'findMany' || params.action === 'findFirst') {
|
||||
params.args.where = { ...params.args.where, deletedAt: null };
|
||||
}
|
||||
return next(params);
|
||||
});
|
||||
```
|
||||
|
||||
### 审计追踪
|
||||
|
||||
```sql
|
||||
-- 方案 A:每张表上的审计列
|
||||
ALTER TABLE orders ADD COLUMN created_by bigint REFERENCES users(id);
|
||||
ALTER TABLE orders ADD COLUMN updated_by bigint REFERENCES users(id);
|
||||
|
||||
-- 方案 B:独立的审计日志表(更详细)
|
||||
CREATE TABLE audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
table_name text NOT NULL,
|
||||
record_id bigint NOT NULL,
|
||||
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
|
||||
old_data jsonb,
|
||||
new_data jsonb,
|
||||
changed_by bigint REFERENCES users(id),
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id);
|
||||
CREATE INDEX idx_audit_changed_at ON audit_log(changed_at DESC);
|
||||
```
|
||||
|
||||
### 枚举列
|
||||
|
||||
```sql
|
||||
-- 方案 A:PostgreSQL 枚举类型(严格,但 ALTER TYPE 很麻烦)
|
||||
CREATE TYPE order_status AS ENUM ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled');
|
||||
ALTER TABLE orders ADD COLUMN status order_status NOT NULL DEFAULT 'pending';
|
||||
|
||||
-- 方案 B:文本 + CHECK 约束(更容易迁移)
|
||||
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled'));
|
||||
|
||||
-- 方案 C:查找表(最灵活,适合 UI 驱动的列表)
|
||||
CREATE TABLE order_statuses (
|
||||
id serial PRIMARY KEY,
|
||||
name text UNIQUE NOT NULL,
|
||||
label text NOT NULL -- 显示名称
|
||||
);
|
||||
```
|
||||
|
||||
**推荐:** 大多数情况下使用方案 B(文本 + CHECK)。如果状态由非开发人员管理,则使用方案 C。
|
||||
|
||||
### 多态关联
|
||||
|
||||
```sql
|
||||
-- ❌ 反模式:多态外键(无参照完整性)
|
||||
CREATE TABLE comments (
|
||||
id bigserial PRIMARY KEY,
|
||||
commentable_type text, -- 'Post' 或 'Photo'
|
||||
commentable_id bigint, -- 无法建立外键约束!
|
||||
body text
|
||||
);
|
||||
|
||||
-- ✅ 模式 A:单独的外键列(可空)
|
||||
CREATE TABLE comments (
|
||||
id bigserial PRIMARY KEY,
|
||||
post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
|
||||
photo_id bigint REFERENCES photos(id) ON DELETE CASCADE,
|
||||
body text NOT NULL,
|
||||
CHECK (
|
||||
(post_id IS NOT NULL AND photo_id IS NULL) OR
|
||||
(post_id IS NULL AND photo_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- ✅ 模式 B:独立的表(最清晰,适合不同表结构)
|
||||
CREATE TABLE post_comments (..., post_id bigint REFERENCES posts(id));
|
||||
CREATE TABLE photo_comments (..., photo_id bigint REFERENCES photos(id));
|
||||
```
|
||||
|
||||
### JSONB 列(半结构化数据)
|
||||
|
||||
```sql
|
||||
-- 好的用途:元数据、设置、灵活属性
|
||||
CREATE TABLE products (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
price numeric(10,2) NOT NULL,
|
||||
attributes jsonb NOT NULL DEFAULT '{}' -- 颜色、尺寸、重量…
|
||||
);
|
||||
|
||||
-- JSONB 查询的索引
|
||||
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);
|
||||
|
||||
-- 查询
|
||||
SELECT * FROM products WHERE attributes->>'color' = 'red';
|
||||
SELECT * FROM products WHERE attributes @> '{"size": "XL"}';
|
||||
```
|
||||
|
||||
```
|
||||
✅ 对真正灵活/可选的数据(元数据、设置、偏好)使用 JSONB
|
||||
✅ 在查询 JSONB 列时使用 GIN 索引
|
||||
|
||||
❌ 绝不对本应作为列的数据(email、status、price)使用 JSONB
|
||||
❌ 绝不用 JSONB 来逃避表结构设计(它不是 PostgreSQL 里的 MongoDB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 表分区(中)
|
||||
|
||||
### 何时分区
|
||||
|
||||
```
|
||||
✅ 表行数 > 1 亿且仍在增长
|
||||
✅ 大多数查询按分区键过滤(日期范围、租户)
|
||||
✅ 旧数据可按分区删除/归档(高效的 DELETE)
|
||||
|
||||
❌ 表行数 < 1000 万(开销不值得)
|
||||
❌ 查询不按分区键过滤(扫描所有分区)
|
||||
```
|
||||
|
||||
### 范围分区(时间序列)
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id bigserial,
|
||||
tenant_id bigint NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
payload jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
-- 按月分区
|
||||
CREATE TABLE events_2025_01 PARTITION OF events
|
||||
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
|
||||
CREATE TABLE events_2025_02 PARTITION OF events
|
||||
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
|
||||
|
||||
-- 使用 pg_partman 或 cron 自动创建分区
|
||||
```
|
||||
|
||||
### 列表分区(多租户)
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
id bigserial,
|
||||
tenant_id bigint NOT NULL,
|
||||
total numeric(10,2)
|
||||
) PARTITION BY LIST (tenant_id);
|
||||
|
||||
CREATE TABLE orders_tenant_1 PARTITION OF orders FOR VALUES IN (1);
|
||||
CREATE TABLE orders_tenant_2 PARTITION OF orders FOR VALUES IN (2);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| # | ❌ 不要这样做 | ✅ 而应该这样做 |
|
||||
|---|---------|--------------|
|
||||
| 1 | 提前反规范化 | 从 3NF 开始,有测量依据时再反规范化 |
|
||||
| 2 | 自增 ID 作为公开 API 标识符 | 公开用 UUID,内部用 serial |
|
||||
| 3 | 无外键约束 | 始终在数据库中强制执行外键 |
|
||||
| 4 | 默认可为空 | 默认 NOT NULL,需要时再设为可空 |
|
||||
| 5 | 外键列上无索引 | 为每个外键列创建索引 |
|
||||
| 6 | 一步完成破坏性迁移 | 分多次部署完成:添加 → 迁移 → 移除 |
|
||||
| 7 | 不加 `CONCURRENTLY` 的 `CREATE INDEX` | 在线表上始终使用 `CONCURRENTLY` |
|
||||
| 8 | 多态外键(`commentable_type + commentable_id`) | 单独的外键列或独立的表 |
|
||||
| 9 | 什么都用 JSONB | 灵活数据用 JSONB,结构化数据用列 |
|
||||
| 10 | 没有 `created_at` / `updated_at` | 每张表都配有时间戳对 |
|
||||
| 11 | 一列中存逗号分隔的值 | 单独的表或 PostgreSQL 数组 |
|
||||
| 12 | 不带长度验证的 `text` | CHECK 约束或应用层验证 |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:「查询很慢,但我已经有索引了」
|
||||
|
||||
**症状:** 尽管已有索引,`EXPLAIN ANALYZE` 仍然显示顺序扫描。
|
||||
|
||||
**原因:**
|
||||
1. **索引列顺序错误**——复合索引 `(A, B)` 对 `WHERE B = ?` 无效
|
||||
2. **选择性低**——布尔列上的索引(50% 的行匹配),优化器更倾向于顺序扫描
|
||||
3. **统计信息过时**——运行 `ANALYZE table_name;`
|
||||
4. **类型不匹配**——用 `integer` 参数比较 `varchar` 列 → 无法使用索引
|
||||
|
||||
**修复方法:** 检查 `EXPLAIN (ANALYZE, BUFFERS)`,确认索引与查询模式匹配,运行 `ANALYZE`。
|
||||
|
||||
### 问题 2:「迁移锁住表数分钟」
|
||||
|
||||
**症状:** 执行期间 `ALTER TABLE` 阻塞所有写入。
|
||||
|
||||
**原因:** 添加 NOT NULL 约束、更改列类型、或者创建索引时未使用 `CONCURRENTLY`。
|
||||
|
||||
**修复方法:**
|
||||
```sql
|
||||
-- 无锁添加索引
|
||||
CREATE INDEX CONCURRENTLY idx_name ON table(col);
|
||||
|
||||
-- 无锁添加 NOT NULL 约束(Postgres 12+)
|
||||
ALTER TABLE t ADD CONSTRAINT t_col_nn CHECK (col IS NOT NULL) NOT VALID;
|
||||
ALTER TABLE t VALIDATE CONSTRAINT t_col_nn; -- 非阻塞验证
|
||||
```
|
||||
|
||||
### 问题 3:「多少个索引算太多?」
|
||||
|
||||
**经验法则:**
|
||||
- 读密集表(报表、产品目录):5-10 个索引没问题
|
||||
- 写密集表(事件、日志):最多 2-3 个索引
|
||||
- 通过 `pg_stat_user_indexes` 监控——删除 `idx_scan = 0` 的索引
|
||||
|
||||
```sql
|
||||
-- 查找未使用的索引
|
||||
SELECT schemaname, relname, indexrelname, idx_scan
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE idx_scan = 0 AND indexrelname NOT LIKE '%pkey%'
|
||||
ORDER BY pg_relation_size(indexrelid) DESC;
|
||||
@@ -0,0 +1,466 @@
|
||||
# Django 最佳实践
|
||||
|
||||
适用于 Django 5.x 和 Django REST Framework 的生产级指南。涵盖 8 个类别共 40 余条规则。
|
||||
|
||||
## 核心原则(7 条)
|
||||
|
||||
```
|
||||
1. ✅ 在首次迁移前配置自定义用户模型(之后无法更改)
|
||||
2. ✅ 每个领域概念对应一个 Django 应用(用户、订单、支付)
|
||||
3. ✅ 胖模型、瘦视图——业务逻辑放在模型/管理器里,而不是视图里
|
||||
4. ✅ 始终使用 select_related/prefetch_related(避免 N+1)
|
||||
5. ✅ 按环境拆分配置(base + dev + prod)
|
||||
6. ✅ 使用 pytest-django + factory_boy 进行测试(而不是 fixtures)
|
||||
7. ✅ 生产环境绝不使用 runserver(使用 Gunicorn + Nginx)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目结构(关键)
|
||||
|
||||
### 按领域划分应用
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── config/ # 项目配置
|
||||
│ ├── __init__.py
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py # 共享配置
|
||||
│ │ ├── dev.py # DEBUG=True,可接受 SQLite
|
||||
│ │ └── prod.py # DEBUG=False,Postgres,HTTPS
|
||||
│ ├── urls.py
|
||||
│ ├── wsgi.py
|
||||
│ └── asgi.py
|
||||
├── apps/
|
||||
│ ├── users/ # 自定义用户模型
|
||||
│ │ ├── models.py
|
||||
│ │ ├── serializers.py
|
||||
│ │ ├── views.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── services.py # 业务逻辑
|
||||
│ │ ├── selectors.py # 复杂查询
|
||||
│ │ └── tests/
|
||||
│ │ ├── test_models.py
|
||||
│ │ ├── test_views.py
|
||||
│ │ └── factories.py
|
||||
│ ├── orders/
|
||||
│ └── payments/
|
||||
├── manage.py
|
||||
├── requirements/
|
||||
│ ├── base.txt
|
||||
│ ├── dev.txt
|
||||
│ └── prod.txt
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
```
|
||||
✅ 一个应用 = 一个限界上下文(用户、订单、支付)
|
||||
✅ 业务逻辑放在 services.py / selectors.py 中,而不是视图里
|
||||
✅ 每个应用拥有自己的 urls.py、admin.py、tests/
|
||||
|
||||
❌ 绝不要把一切塞进同一个应用
|
||||
❌ 绝不在模型层面跨应用边界导入(使用 ID)
|
||||
❌ 绝不要把业务逻辑放在视图或序列化器里
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 模型与迁移(关键)
|
||||
|
||||
### 自定义用户模型(第一天就要做!)
|
||||
|
||||
```python
|
||||
# apps/users/models.py
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
import uuid
|
||||
|
||||
class User(AbstractUser):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
|
||||
# config/settings/base.py
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
```
|
||||
|
||||
**这必须在 `migrate` 之前完成。之后无法更改。**
|
||||
|
||||
### 模型最佳实践
|
||||
|
||||
```python
|
||||
class TimeStampedModel(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
class Order(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
|
||||
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING, db_index=True)
|
||||
total = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
db_table = 'orders'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'status']),
|
||||
]
|
||||
|
||||
def can_cancel(self) -> bool:
|
||||
return self.status in [OrderStatus.PENDING, OrderStatus.CONFIRMED]
|
||||
|
||||
def cancel(self):
|
||||
if not self.can_cancel():
|
||||
raise ValueError(f"Cannot cancel order in {self.status} status")
|
||||
self.status = OrderStatus.CANCELLED
|
||||
self.save(update_fields=['status', 'updated_at'])
|
||||
```
|
||||
|
||||
### 迁移规则
|
||||
|
||||
```
|
||||
✅ 审查迁移 SQL:python manage.py sqlmigrate app_name 0001
|
||||
✅ 为迁移起描述性名称:--name add_status_index_to_orders
|
||||
✅ 将数据迁移与 schema 迁移分开
|
||||
✅ 先非破坏性操作:添加列 → 回填数据 → 移除旧列
|
||||
|
||||
❌ 绝不编辑或删除已应用的迁移
|
||||
❌ 绝不使用不带 reverse 函数的 RunPython
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 视图与序列化器——DRF(高优先级)
|
||||
|
||||
### 服务层模式
|
||||
|
||||
```python
|
||||
# apps/orders/services.py
|
||||
from django.db import transaction
|
||||
|
||||
class OrderService:
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_order(user, items_data: list[dict]) -> Order:
|
||||
total = sum(item['price'] * item['quantity'] for item in items_data)
|
||||
order = Order.objects.create(user=user, total=total)
|
||||
OrderItem.objects.bulk_create([
|
||||
OrderItem(order=order, **item) for item in items_data
|
||||
])
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def cancel_order(order_id: str, user) -> Order:
|
||||
order = Order.objects.select_for_update().get(id=order_id, user=user)
|
||||
order.cancel()
|
||||
return order
|
||||
```
|
||||
|
||||
### 序列化器
|
||||
|
||||
```python
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
items = OrderItemSerializer(many=True, read_only=True)
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['id', 'status', 'total', 'items', 'created_at']
|
||||
read_only_fields = ['id', 'total', 'created_at']
|
||||
|
||||
class CreateOrderSerializer(serializers.Serializer):
|
||||
"""仅用于输入的序列化器——与输出序列化器分开。"""
|
||||
items = serializers.ListField(
|
||||
child=serializers.DictField(), min_length=1, max_length=50,
|
||||
)
|
||||
def validate_items(self, items):
|
||||
for item in items:
|
||||
if item.get('quantity', 0) < 1:
|
||||
raise serializers.ValidationError("Quantity must be at least 1")
|
||||
return items
|
||||
```
|
||||
|
||||
### 视图(保持轻薄!)
|
||||
|
||||
```python
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_order(request):
|
||||
serializer = CreateOrderSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
order = OrderService.create_order(request.user, serializer.validated_data['items'])
|
||||
return Response({'data': OrderSerializer(order).data}, status=status.HTTP_201_CREATED)
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
```
|
||||
✅ 将输入序列化器与输出序列化器分开
|
||||
✅ 视图只做:校验 → 调用服务 → 序列化 → 响应
|
||||
✅ 多模型写入使用 @transaction.atomic
|
||||
|
||||
❌ 绝不把业务逻辑放在视图或序列化器里
|
||||
❌ 绝不使用 ModelSerializer 进行写操作(过于隐式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 认证(高优先级)
|
||||
|
||||
| 方法 | 适用场景 | 前端 |
|
||||
|--------|------|----------|
|
||||
| Session | 同域、SSR、Django 模板 | Django 模板 / htmx |
|
||||
| JWT | 不同域、SPA、移动端 | React、Vue、移动应用 |
|
||||
| OAuth2 | 第三方登录、API 消费者 | 任意 |
|
||||
|
||||
### JWT 配置(djangorestframework-simplejwt)
|
||||
|
||||
```python
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能优化(高优先级)
|
||||
|
||||
### 预防 N+1 查询
|
||||
|
||||
```python
|
||||
# ❌ N+1:1 次查询获取订单 + N 次查询获取用户
|
||||
orders = Order.objects.all()
|
||||
for o in orders:
|
||||
print(o.user.email) # 每次循环都访问数据库
|
||||
|
||||
# ✅ select_related(外键/一对一——JOIN)
|
||||
orders = Order.objects.select_related('user').all()
|
||||
|
||||
# ✅ prefetch_related(多对多/反向外键——2 次查询)
|
||||
orders = Order.objects.prefetch_related('items').all()
|
||||
|
||||
# ✅ 组合使用
|
||||
orders = Order.objects.select_related('user').prefetch_related('items').all()
|
||||
```
|
||||
|
||||
### 查询优化工具集
|
||||
|
||||
```python
|
||||
# 只获取需要的列
|
||||
User.objects.values('id', 'email')
|
||||
User.objects.values_list('email', flat=True)
|
||||
|
||||
# 使用注解代替 Python 循环
|
||||
from django.db.models import Count, Sum
|
||||
Order.objects.annotate(item_count=Count('items'), revenue=Sum('items__price'))
|
||||
|
||||
# 批量操作
|
||||
OrderItem.objects.bulk_create([...])
|
||||
Order.objects.filter(status='pending').update(status='cancelled')
|
||||
|
||||
# 数据库索引
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'status']),
|
||||
models.Index(fields=['-created_at']),
|
||||
models.Index(fields=['email'], condition=Q(is_active=True)),
|
||||
]
|
||||
|
||||
# 分页
|
||||
from rest_framework.pagination import CursorPagination
|
||||
class OrderPagination(CursorPagination):
|
||||
page_size = 20
|
||||
ordering = '-created_at'
|
||||
```
|
||||
|
||||
### 缓存
|
||||
|
||||
```python
|
||||
from django.core.cache import cache
|
||||
|
||||
def get_product(product_id: str):
|
||||
cache_key = f'product:{product_id}'
|
||||
product = cache.get(cache_key)
|
||||
if product is None:
|
||||
product = Product.objects.get(id=product_id)
|
||||
cache.set(cache_key, product, timeout=300)
|
||||
return product
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试(中高优先级)
|
||||
|
||||
### pytest-django + factory_boy
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(api_client, user_factory):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
```
|
||||
|
||||
```python
|
||||
# factories.py
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = User
|
||||
email = factory.Sequence(lambda n: f'user{n}@example.com')
|
||||
username = factory.Sequence(lambda n: f'user{n}')
|
||||
|
||||
class OrderFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = 'orders.Order'
|
||||
user = factory.SubFactory(UserFactory)
|
||||
total = factory.Faker('pydecimal', left_digits=3, right_digits=2, positive=True)
|
||||
```
|
||||
|
||||
```python
|
||||
# test_views.py
|
||||
@pytest.mark.django_db
|
||||
class TestListOrders:
|
||||
def test_returns_user_orders(self, authenticated_client):
|
||||
OrderFactory.create_batch(3, user=authenticated_client.handler._force_user)
|
||||
response = authenticated_client.get('/api/orders/')
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['data']) == 3
|
||||
|
||||
def test_requires_authentication(self, api_client):
|
||||
response = api_client.get('/api/orders/')
|
||||
assert response.status_code == 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 管理后台定制(中等优先级)
|
||||
|
||||
```python
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = OrderItem
|
||||
extra = 0
|
||||
readonly_fields = ['price']
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user', 'status', 'total', 'created_at']
|
||||
list_filter = ['status', 'created_at']
|
||||
search_fields = ['user__email', 'id']
|
||||
readonly_fields = ['id', 'created_at', 'updated_at']
|
||||
inlines = [OrderItemInline]
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('user')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 生产部署(中等优先级)
|
||||
|
||||
### 安全配置
|
||||
|
||||
```python
|
||||
# settings/prod.py
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = ['example.com', 'www.example.com']
|
||||
CSRF_TRUSTED_ORIGINS = ['https://example.com']
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
```
|
||||
|
||||
### 部署架构
|
||||
|
||||
```
|
||||
Nginx → Gunicorn → Django
|
||||
↕
|
||||
PostgreSQL + Redis (cache)
|
||||
↕
|
||||
Celery (background tasks)
|
||||
```
|
||||
|
||||
```bash
|
||||
gunicorn config.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers 4 \
|
||||
--timeout 120 \
|
||||
--access-logfile -
|
||||
```
|
||||
|
||||
### 静态文件——WhiteNoise
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware', # 紧跟在 SecurityMiddleware 之后
|
||||
...
|
||||
]
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
```
|
||||
✅ Gunicorn + Nginx(或 Cloud Run / Railway)
|
||||
✅ PostgreSQL(不要用 SQLite)
|
||||
✅ python manage.py check --deploy
|
||||
✅ 使用 Sentry 进行错误追踪
|
||||
|
||||
❌ 生产环境绝不使用 runserver
|
||||
❌ 生产环境绝不使用 DEBUG=True
|
||||
❌ 生产环境绝不使用 SQLite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| # | ❌ 不要这样做 | ✅ 应该这样做 |
|
||||
|---|---------|--------------|
|
||||
| 1 | 在视图中写业务逻辑 | 服务层(`services.py`) |
|
||||
| 2 | 一个巨型应用 | 按领域划分应用 |
|
||||
| 3 | 使用默认 User 模型 | 首次迁移前配置自定义 User 模型 |
|
||||
| 4 | 不使用 `select_related` | 始终预加载关联对象 |
|
||||
| 5 | 使用 Django fixtures 做测试 | 使用 `factory_boy` 工厂 |
|
||||
| 6 | 单一 `settings.py` 文件 | 拆分:base + dev + prod |
|
||||
| 7 | 生产环境使用 `runserver` | Gunicorn + Nginx |
|
||||
| 8 | 生产环境使用 SQLite | PostgreSQL |
|
||||
| 9 | 使用 `ModelSerializer` 进行写操作 | 显式的输入序列化器 |
|
||||
| 10 | 在视图中写原生 SQL | ORM querysets + `selectors.py` |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:「首次迁移后无法更改 User 模型」
|
||||
|
||||
**解决方案:** 如果从零开始:删除所有迁移文件 + 数据库,设置自定义 User 模型,重新迁移。如果已有数据:需进行复杂迁移(使用 `django-allauth` 或增量字段迁移)。
|
||||
|
||||
### 问题 2:「序列化器在处理大型查询集时速度太慢」
|
||||
|
||||
**解决方案:** 缺少 `select_related` / `prefetch_related` → 导致 N+1 查询。
|
||||
```python
|
||||
queryset = Order.objects.select_related('user').prefetch_related('items')
|
||||
```
|
||||
|
||||
### 问题 3:「应用之间存在循环导入」
|
||||
|
||||
**解决方案:** 使用字符串引用:`models.ForeignKey('orders.Order', ...)` 代替导入模型类。对于服务层,在函数内部进行导入。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 环境与 CORS 管理
|
||||
|
||||
跨前后端技术栈管理环境变量、API URL 和 CORS 配置的模式。
|
||||
|
||||
---
|
||||
|
||||
## 标准环境模式
|
||||
|
||||
```
|
||||
# .env.local(被 gitignore,用于本地开发)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:3001
|
||||
|
||||
# 预发布环境(在 Vercel/CI 中设置)
|
||||
NEXT_PUBLIC_API_URL=https://api-staging.example.com
|
||||
|
||||
# 生产环境(在 Vercel/CI 中设置)
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境变量规则
|
||||
|
||||
```
|
||||
✅ API 基础地址来自环境变量——绝不硬编码
|
||||
✅ 客户端变量前缀使用 NEXT_PUBLIC_(Next.js)或 VITE_(Vite)
|
||||
✅ 后端 URL = 仅服务端使用的环境变量(用于 SSR 调用,不暴露给浏览器)
|
||||
✅ 后端 CORS:按环境明确列出允许的域名列表
|
||||
|
||||
❌ 生产构建中绝不使用 localhost URL
|
||||
❌ 绝不使用 NEXT_PUBLIC_ 前缀暴露仅后端使用的密钥
|
||||
❌ 绝不提交 .env.local(提交含占位符的 .env.example)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CORS 配置
|
||||
|
||||
```typescript
|
||||
// 后端:感知环境的 CORS
|
||||
const ALLOWED_ORIGINS = {
|
||||
development: ['http://localhost:3000', 'http://localhost:5173'],
|
||||
staging: ['https://staging.example.com'],
|
||||
production: ['https://example.com', 'https://www.example.com'],
|
||||
};
|
||||
|
||||
app.use(cors({
|
||||
origin: ALLOWED_ORIGINS[process.env.NODE_ENV || 'development'],
|
||||
credentials: true, // 需要用于 cookie(身份认证)
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:「浏览器报 CORS 错误,但 Postman 能正常访问」
|
||||
|
||||
**原因:** CORS 是浏览器的安全机制。Postman/curl 会跳过它。
|
||||
|
||||
**修复方法:**
|
||||
1. 后端必须返回 `Access-Control-Allow-Origin: https://your-frontend.com`
|
||||
2. 涉及 Cookie/身份认证时,两端都需要设置 `credentials: true`
|
||||
3. 确认预检 `OPTIONS` 请求返回了正确的响应头
|
||||
|
||||
### 问题 2:「浏览器中环境变量为 undefined」
|
||||
|
||||
**原因:** 缺少客户端访问所需的 `NEXT_PUBLIC_` 或 `VITE_` 前缀。
|
||||
|
||||
**修复方法:** 客户端变量必须带有框架前缀。添加新环境变量后需要重新构建(它们在构建时被嵌入)。
|
||||
|
||||
### 问题 3:「本地能正常工作,预发布环境却失败」
|
||||
|
||||
**原因:** 域名不同,缺少预发布域的 CORS 配置。
|
||||
|
||||
**修复方法:** 将预发布域名添加到 `ALLOWED_ORIGINS`,确认部署平台已设置相应的环境变量。
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: release-acceptance-checklist
|
||||
description: 发布与验收检查清单——后端与全栈应用的 6 道关卡发布检查清单
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# 发布与验收检查清单
|
||||
|
||||
适用于后端和全栈应用的 6 道关卡发布检查清单。防止出现「在我机器上能跑」和「我们忘了检查 X」之类的故障。
|
||||
|
||||
**铁律:未通过所有关卡,不得发布。**
|
||||
|
||||
---
|
||||
|
||||
## 发布关卡概览
|
||||
|
||||
```
|
||||
功能完成
|
||||
↓
|
||||
关卡 1:功能验收 → 功能是否达到预期?
|
||||
↓
|
||||
关卡 2:非功能验收 → 是否快速、可靠、可观测?
|
||||
↓
|
||||
关卡 3:安全审查 → 是否安全?
|
||||
↓
|
||||
关卡 4:部署就绪 → 能否安全部署并回滚?
|
||||
↓
|
||||
关卡 5:发布执行 → 采用金丝雀部署 + 监控上线
|
||||
↓
|
||||
关卡 6:发布后验证 → 在生产环境是否真正生效?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关卡 1:功能验收
|
||||
|
||||
**问题:功能是否满足需求文档的要求?**
|
||||
|
||||
- [ ] 所有来自工单/PRD 的验收条件均有通过测试
|
||||
- [ ] 正常路径端到端可用
|
||||
- [ ] 边界情况已测试(空输入、最大长度、Unicode)
|
||||
- [ ] 错误情况已测试(无效输入、未找到、超时)
|
||||
- [ ] 数据完整性已验证(CRUD 周期产生正确状态)
|
||||
- [ ] 向后兼容性已确认(现有客户端未被破坏)
|
||||
- [ ] API 契约与 OpenAPI 规范一致
|
||||
- [ ] 幂等性已验证(重试不会产生重复数据)
|
||||
|
||||
### 证据模板
|
||||
|
||||
| 需求 | 测试 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 用户可创建订单 | `orders.api.test:creates order` | ✅ 通过 | |
|
||||
| 空购物车 → 报错 | `orders.api.test:rejects empty` | ✅ 通过 | |
|
||||
| 支付失败已处理 | `payments.test:handles decline` | ✅ 通过 | |
|
||||
|
||||
---
|
||||
|
||||
## 关卡 2:非功能验收
|
||||
|
||||
**问题:是否快速、可靠、可观测?**
|
||||
|
||||
### 性能
|
||||
|
||||
- [ ] 响应时间在预算范围内(p95 < ___ms)——已实测,非假设
|
||||
- [ ] 无 N+1 查询(通过查询日志检查)
|
||||
- [ ] 新查询使用索引(`EXPLAIN ANALYZE`)
|
||||
- [ ] 大数据集支持分页
|
||||
- [ ] 缓存生效(命中率 > 80%)
|
||||
- [ ] 连接池在负载下运行正常
|
||||
|
||||
### 可靠性
|
||||
|
||||
- [ ] 依赖失败时优雅降级(断路器)
|
||||
- [ ] 瞬态故障的重试逻辑正常工作
|
||||
- [ ] 所有外部调用均设置了超时
|
||||
- [ ] 速率限制正确返回 429
|
||||
- [ ] 健康检查端点已验证(`/health`、`/ready`)
|
||||
|
||||
### 可观测性
|
||||
|
||||
- [ ] 结构化日志包含请求 ID(非 `console.log`)
|
||||
- [ ] 指标已暴露(请求数、延迟、错误率)
|
||||
- [ ] 告警已配置(错误突增、延迟突增)
|
||||
- [ ] 请求追踪端到端可用
|
||||
- [ ] 新功能对应的仪表盘已更新
|
||||
|
||||
### 证据
|
||||
|
||||
| 指标 | 目标 | 实际值 | 状态 |
|
||||
|------|------|--------|------|
|
||||
| p95 响应时间 | < 500ms | ___ms | ✅/❌ |
|
||||
| p99 响应时间 | < 1000ms | ___ms | ✅/❌ |
|
||||
| 负载下错误率 | < 0.1% | ___% | ✅/❌ |
|
||||
| 吞吐量 | > ___ RPS | ___ RPS | ✅/❌ |
|
||||
|
||||
---
|
||||
|
||||
## 关卡 3:安全审查
|
||||
|
||||
**问题:是否引入了安全漏洞?**
|
||||
|
||||
### 输入与输出
|
||||
|
||||
- [ ] 所有输入在服务端验证(永远不要信任客户端)
|
||||
- [ ] 已防止 SQL 注入(仅使用参数化查询)
|
||||
- [ ] 已防止 XSS(输出编码)
|
||||
- [ ] 文件上传已验证(类型、大小、文件名已净化)
|
||||
- [ ] 敏感端点已限流(登录、重置、API)
|
||||
|
||||
### 认证与数据
|
||||
|
||||
- [ ] 受保护端点需要有效凭据
|
||||
- [ ] 用户只能访问自己的资源
|
||||
- [ ] 管理后台路由需要管理员角色
|
||||
- [ ] 令牌有过期机制(短生命周期访问令牌 + 刷新令牌)
|
||||
- [ ] 密码已哈希(bcrypt/argon2,非 MD5/SHA)
|
||||
- [ ] 敏感数据未记入日志(密码、令牌、PII)
|
||||
- [ ] 密钥存储在环境变量中(非硬编码)
|
||||
- [ ] 错误消息不泄露内部信息
|
||||
|
||||
### 依赖
|
||||
|
||||
- [ ] 无已知漏洞(`npm audit` / `pip audit` / `govulncheck`)
|
||||
- [ ] 依赖版本已锁定在 lockfile 中
|
||||
- [ ] 未使用的依赖已移除
|
||||
|
||||
---
|
||||
|
||||
## 关卡 4:部署就绪
|
||||
|
||||
**问题:能否安全部署,并在必要时回滚?**
|
||||
|
||||
### 代码
|
||||
|
||||
- [ ] 所有测试在 CI 中通过(而非「本地通过了」)
|
||||
- [ ] 代码检查通过,构建成功
|
||||
- [ ] 代码已审查并批准
|
||||
- [ ] 无未解决的 TODO/FIXME/HACK
|
||||
|
||||
### 数据库
|
||||
|
||||
- [ ] 迁移在预发布环境使用类生产数据已测试
|
||||
- [ ] 回滚迁移可正常工作(已测试!)
|
||||
- [ ] 迁移不会造成破坏(仅新增)
|
||||
- [ ] 已根据生产数据规模预估迁移耗时
|
||||
- [ ] 数据回填方案已记录(如需)
|
||||
|
||||
### 配置
|
||||
|
||||
- [ ] 新增环境变量已在 `.env.example` 中记录
|
||||
- [ ] 环境变量已在预发布环境设置并验证
|
||||
- [ ] 环境变量已在生产环境设置
|
||||
- [ ] 功能开关已配置(如适用)
|
||||
|
||||
### 回滚计划模板
|
||||
|
||||
```markdown
|
||||
## 回滚计划:[功能]
|
||||
|
||||
### 触发回滚的条件
|
||||
- 错误率 > 1% 持续 5 分钟
|
||||
- p99 延迟 > 3000ms 持续 10 分钟
|
||||
- 关键业务功能被破坏
|
||||
|
||||
### 步骤
|
||||
1. 回退部署:[命令]
|
||||
2. 回滚迁移(如已执行):[命令]
|
||||
3. 清除缓存:[命令]
|
||||
4. 通知团队:#incidents 频道
|
||||
5. 验证回滚:[验证步骤]
|
||||
|
||||
### 预计耗时:[X 分钟]
|
||||
### 数据恢复:[数据被修改时的恢复方案]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关卡 5:发布执行
|
||||
|
||||
### 部署顺序
|
||||
|
||||
```
|
||||
1. 📢 在发布频道中公告
|
||||
|
||||
2. 🗄️ 数据库——执行迁移
|
||||
- 运行迁移
|
||||
- 验证完成
|
||||
- 检查数据完整性
|
||||
|
||||
3. 🚀 部署——发布代码
|
||||
- 先金丝雀发布(10% 流量)
|
||||
- 监控 5 分钟
|
||||
- 如正常 → 50% → 监控 → 100%
|
||||
- 如不正常 → 立即停止
|
||||
|
||||
4. 🔍 冒烟测试
|
||||
- 健康检查 → 200
|
||||
- 登录正常
|
||||
- 核心操作正常
|
||||
- 无错误突增
|
||||
|
||||
5. ✅ 公告「发布完成。持续监控 30 分钟。」
|
||||
```
|
||||
|
||||
### 金丝雀决策表
|
||||
|
||||
| 指标 | 基准值 | 金丝雀正常 | 停止 | 回滚 |
|
||||
|------|--------|-----------|------|------|
|
||||
| 错误率 | 0.05% | < 0.1% | 0.5% | > 1% |
|
||||
| p95 延迟 | 300ms | < 500ms | 700ms | > 1000ms |
|
||||
|
||||
---
|
||||
|
||||
## 关卡 6:发布后验证
|
||||
|
||||
### 即时(0-30 分钟)
|
||||
|
||||
- [ ] 所有实例的健康检查均为绿色
|
||||
- [ ] 错误率在正常范围内
|
||||
- [ ] 延迟正常(p95、p99)
|
||||
- [ ] 核心用户流程已手动测试
|
||||
- [ ] 日志正常——无意外错误
|
||||
- [ ] 告警保持静默
|
||||
|
||||
### 短期(1-24 小时)
|
||||
|
||||
- [ ] 无客户投诉
|
||||
- [ ] 业务指标稳定(转化率、收入、注册数)
|
||||
- [ ] 内存/CPU 稳定(无缓慢增长)
|
||||
- [ ] 队列积压已清除
|
||||
- [ ] 数据库性能稳定
|
||||
|
||||
### 发布后报告模板
|
||||
|
||||
```markdown
|
||||
## 发布报告:[功能]
|
||||
- 部署时间:[时间戳] 由 @[工程师]
|
||||
- 耗时:[分钟]
|
||||
|
||||
| 检查项 | 状态 | 备注 |
|
||||
|--------|------|------|
|
||||
| 健康检查 | ✅ | 全部正常 |
|
||||
| 错误率 | ✅ | 0.03%(基准值:0.05%) |
|
||||
| p95 延迟 | ✅ | 310ms(基准值:300ms) |
|
||||
| 核心流程 | ✅ | 订单创建已验证 |
|
||||
|
||||
发现的问题:无 / [详情]
|
||||
是否回滚:否 / 是:[原因]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 发布就绪评分
|
||||
|
||||
每道关卡评分 **0-2**:(0 = 未检查,1 = 部分检查,2 = 已全面验证并有证据)
|
||||
|
||||
| 关卡 | 评分 |
|
||||
|------|------|
|
||||
| 1. 功能验收 | /2 |
|
||||
| 2. 非功能验收 | /2 |
|
||||
| 3. 安全审查 | /2 |
|
||||
| 4. 部署就绪 | /2 |
|
||||
| 5. 发布执行计划 | /2 |
|
||||
| 6. 发布后验证计划 | /2 |
|
||||
| **总分** | **/12** |
|
||||
|
||||
**决策:**
|
||||
- **12/12** → 上线 ✅
|
||||
- **10-11** → 上线,附带已记录的例外事项 + 指定负责人
|
||||
- **< 10** → 不得发布。先修复差距。
|
||||
|
||||
---
|
||||
|
||||
## 常见借口
|
||||
|
||||
| ❌ 借口 | ✅ 事实 |
|
||||
|--------|--------|
|
||||
| 「这只是一个小改动」 | 小改动每天都会导致故障 |
|
||||
| 「我们在本地测过了」 | 本地 ≠ 生产环境 |
|
||||
| 「出问题了再修」 | 你会在凌晨 3 点修。现在就预防。 |
|
||||
| 「今天就是截止日期」 | 出错的代码比延期交付成本更高 |
|
||||
| 「CI 通过了」 | CI 并不检查所有内容。执行检查清单。 |
|
||||
| 「我们随时可以回滚」 | 只有当你有计划并测试过回滚才行 |
|
||||
| 「上次这么干没问题」 | 幸存者偏差。每次都要走检查清单。 |
|
||||
@@ -0,0 +1,254 @@
|
||||
# 技术选型框架
|
||||
|
||||
后端与全栈技术选型的结构化决策框架。防止分析瘫痪,同时确保严谨评估。
|
||||
|
||||
**铁律:未经明确的取舍分析,不得做出任何技术选择。**
|
||||
|
||||
"我喜欢"和"这很热门"都不是工程论证。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:先定需求,再选技术
|
||||
|
||||
### 非功能性需求(量化!)
|
||||
|
||||
| 维度 | 问题 | 糟糕的回答 | 好的回答 |
|
||||
|-----------|----------|-----------|-------------|
|
||||
| 规模 | 多少并发用户? | "很多" | "1K 并发,500 RPS 峰值" |
|
||||
| 延迟 | 可接受的 p99 响应时间? | "快" | "API < 200ms,报告 < 2s" |
|
||||
| 可用性 | 需要的正常运行时间? | "永远在线" | "99.9%(年停机 8.7 小时)" |
|
||||
| 数据量 | 预计存储增长? | "很多" | "100GB/年,1000 万行" |
|
||||
| 一致性 | 强一致还是最终一致? | "一致" | "支付强一致,信息流最终一致" |
|
||||
| 合规性 | 监管要求? | "有一些" | "GDPR 数据驻留欧盟,SOC 2 Type II" |
|
||||
|
||||
### 团队约束
|
||||
|
||||
- 团队规模与资深程度
|
||||
- 团队已熟练掌握的技术
|
||||
- 能否为此技术栈招到人?(查看招聘市场)
|
||||
- 时间压力(几天还是几个月上线)
|
||||
- 许可、基础设施、培训的预算
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:评估矩阵
|
||||
|
||||
对每个选项按加权标准打分 1-5 分:
|
||||
|
||||
| 评估标准 | 权重 | 选项 A | 选项 B | 选项 C |
|
||||
|-----------|--------|----------|----------|----------|
|
||||
| 满足功能性需求 | 5× | _ | _ | _ |
|
||||
| 满足非功能性需求 | 5× | _ | _ | _ |
|
||||
| 团队经验 / 学习曲线 | 4× | _ | _ | _ |
|
||||
| 生态成熟度(库、工具) | 3× | _ | _ | _ |
|
||||
| 社区与长期 viability | 3× | _ | _ | _ |
|
||||
| 运维复杂度 | 3× | _ | _ | _ |
|
||||
| 招聘人才池 availability | 2× | _ | _ | _ |
|
||||
| 成本(许可 + 基础设施 + 培训) | 2× | _ | _ | _ |
|
||||
| **加权总分** | | _ | _ | _ |
|
||||
|
||||
**规则:**
|
||||
- 任何选项在 **5× 标准上得 1 分** → 自动淘汰
|
||||
- 选项间差距在 **10% 以内** → 选团队最熟悉的
|
||||
- 选项间差距在 **15% 以内** → 进行**有时间限制的概念验证**(最长 2-5 天)
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:决策树
|
||||
|
||||
### 后端语言 / 框架
|
||||
|
||||
```
|
||||
什么类型的项目?
|
||||
│
|
||||
├─ REST/GraphQL API,快速开发
|
||||
│ ├─ 团队熟悉 TypeScript → Node.js
|
||||
│ │ ├─ 功能完备,企业级模式 → NestJS
|
||||
│ │ ├─ 轻量灵活 → Fastify / Hono / Express
|
||||
│ │ └─ 全栈 + React → Next.js API routes
|
||||
│ ├─ 团队熟悉 Python
|
||||
│ │ ├─ 高性能异步 API → FastAPI
|
||||
│ │ ├─ 全栈,后台管理繁重 → Django
|
||||
│ │ └─ 轻量级 → Flask / Litestar
|
||||
│ └─ 团队熟悉 Java/Kotlin
|
||||
│ ├─ 企业级,大型团队 → Spring Boot
|
||||
│ └─ 轻量级,启动快 → Quarkus / Ktor
|
||||
│
|
||||
├─ 高并发,系统级
|
||||
│ ├─ 微服务,网络编程 → Go
|
||||
│ ├─ 极致性能,安全性 → Rust (Axum / Actix)
|
||||
│ └─ 容错性 → Elixir (Phoenix)
|
||||
│
|
||||
├─ 实时(WebSocket、流式)
|
||||
│ ├─ Node.js 生态 → Socket.io / ws
|
||||
│ ├─ 可扩展的发布/订阅 → Elixir Phoenix
|
||||
│ └─ 低延迟 → Go / Rust
|
||||
│
|
||||
└─ 机器学习 / 数据密集
|
||||
└─ Python (FastAPI + ML libs)
|
||||
```
|
||||
|
||||
### 数据库
|
||||
|
||||
```
|
||||
什么数据模型?
|
||||
│
|
||||
├─ 结构化,关系型,ACID
|
||||
│ ├─ 通用 → PostgreSQL ← 默认选择
|
||||
│ ├─ 读密集,MySQL 生态 → MySQL / MariaDB
|
||||
│ └─ 嵌入式 / 无服务器边缘 → SQLite / Turso / D1
|
||||
│
|
||||
├─ 半结构化,灵活 schema
|
||||
│ ├─ 文档型 → MongoDB
|
||||
│ ├─ 无服务器文档 → DynamoDB / Firestore
|
||||
│ └─ 搜索密集 → Elasticsearch / OpenSearch
|
||||
│
|
||||
├─ 键值 / 缓存
|
||||
│ ├─ 内存 + 数据结构 → Redis / Valkey
|
||||
│ └─ 星球级 KV → DynamoDB / Cassandra
|
||||
│
|
||||
├─ 时间序列 → TimescaleDB / ClickHouse / InfluxDB
|
||||
├─ 图 → Neo4j / Apache AGE (Postgres 扩展)
|
||||
└─ 向量(AI 嵌入) → pgvector / Pinecone / Qdrant
|
||||
```
|
||||
|
||||
**默认:** 从 PostgreSQL 开始。它能处理 80% 的使用场景。
|
||||
|
||||
### 缓存策略
|
||||
|
||||
| 模式 | 技术 | 何时使用 |
|
||||
|---------|-----------|------|
|
||||
| 应用缓存 | Redis / Valkey | 会话、频繁读取、限流 |
|
||||
| HTTP 缓存 | CDN (Cloudflare/Vercel) | 静态资源、公开 API 响应 |
|
||||
| 查询缓存 | 物化视图 | 复杂聚合、仪表盘 |
|
||||
| 进程内缓存 | LRU(内存) | 配置、小型查找表 |
|
||||
| 边缘缓存 | Cloudflare KV / Vercel KV | 全球低延迟读取 |
|
||||
|
||||
### 消息队列 / 事件流
|
||||
|
||||
| 模式 | 技术 | 何时使用 |
|
||||
|---------|-----------|------|
|
||||
| 任务队列(后台作业) | BullMQ / Celery / SQS | 邮件、导出、支付 |
|
||||
| 事件流(回放、审计) | Kafka / Redpanda | 事件溯源、实时管道 |
|
||||
| 轻量发布/订阅 | Redis Streams / NATS | 简单通知、广播 |
|
||||
| 请求-回复(异步同步化) | NATS / RabbitMQ RPC | 内部服务调用 |
|
||||
|
||||
### 托管 / 部署
|
||||
|
||||
| 模式 | 技术 | 何时使用 |
|
||||
|-------|-----------|------|
|
||||
| 无服务器(自动扩缩) | Vercel / Cloudflare Workers / Lambda | 流量波动、按量付费 |
|
||||
| 容器(可预测) | Cloud Run / Render / Railway / Fly.io | 流量稳定、运维简单 |
|
||||
| Kubernetes(大规模) | EKS / GKE / AKS | 10+ 服务、团队有 K8s 经验 |
|
||||
| VPS(完全控制) | DigitalOcean / Hetzner / EC2 | 工作负载可预测、对成本敏感 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段四:决策文档
|
||||
|
||||
### ADR(架构决策记录)模板
|
||||
|
||||
```markdown
|
||||
# ADR-{NNN}: {标题}
|
||||
|
||||
## 状态:提议 | 已接受 | 已废弃 | 被 ADR-{NNN} 取代
|
||||
|
||||
## 背景
|
||||
我们在解决什么问题?有哪些因素在起作用?
|
||||
|
||||
## 决策
|
||||
我们选择了什么以及为什么?
|
||||
|
||||
## 评估
|
||||
| 标准 | 权重 | 选中方案 | 备选方案 |
|
||||
|-----------|--------|--------|-----------|
|
||||
|
||||
## 影响
|
||||
- 正面:...
|
||||
- 负面:...
|
||||
- 风险:...
|
||||
|
||||
## 已拒绝的备选方案
|
||||
- 选项 B:因...被拒绝
|
||||
- 选项 C:因...被拒绝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见技术栈模板
|
||||
|
||||
### A:初创 / MVP(速度优先)
|
||||
|
||||
| 层 | 选择 | 理由 |
|
||||
|-------|--------|-----|
|
||||
| 语言 | TypeScript | 前后端统一语言 |
|
||||
| 框架 | Next.js(全栈)或 NestJS(API) | 快速迭代 |
|
||||
| 数据库 | PostgreSQL (Supabase / Neon) | 托管服务,慷慨的免费额度 |
|
||||
| 认证 | Better Auth / Clerk | 无需维护认证代码 |
|
||||
| 缓存 | Redis (Upstash) | 对无服务器友好 |
|
||||
| 托管 | Vercel / Railway | 零配置部署 |
|
||||
|
||||
### B:SaaS / 商业应用(平衡)
|
||||
|
||||
| 层 | 选择 | 理由 |
|
||||
|-------|--------|-----|
|
||||
| 语言 | TypeScript 或 Python | 团队偏好 |
|
||||
| 框架 | NestJS 或 FastAPI | 结构化、可测试 |
|
||||
| 数据库 | PostgreSQL | 可靠、功能丰富 |
|
||||
| 队列 | BullMQ (Redis) | 简单的后台任务 |
|
||||
| 认证 | OAuth 2.0 + JWT | 标准、灵活 |
|
||||
| 托管 | AWS ECS / Cloud Run | 可扩缩容器 |
|
||||
| 监控 | Datadog / Grafana + Prometheus | 完整可观测性 |
|
||||
|
||||
### C:高性能(规模优先)
|
||||
|
||||
| 层 | 选择 | 理由 |
|
||||
|-------|--------|-----|
|
||||
| 语言 | Go 或 Rust | 最大吞吐量、低延迟 |
|
||||
| 数据库 | PostgreSQL + Redis + ClickHouse | OLTP + 缓存 + 分析 |
|
||||
| 队列 | Kafka / Redpanda | 高吞吐量流处理 |
|
||||
| 托管 | Kubernetes (EKS/GKE) | 细粒度扩缩 |
|
||||
| 监控 | Prometheus + Grafana + Jaeger | 指标 + 追踪 |
|
||||
|
||||
### D:AI / 机器学习应用
|
||||
|
||||
| 层 | 选择 | 理由 |
|
||||
|-------|--------|-----|
|
||||
| 语言 | Python(API)+ TypeScript(前端) | 机器学习库 + 现代 UI |
|
||||
| 框架 | FastAPI + Next.js | 异步 + SSR |
|
||||
| 数据库 | PostgreSQL + pgvector | 关系型 + 嵌入 |
|
||||
| 队列 | Celery + Redis | 机器学习任务处理 |
|
||||
| 托管 | Modal / AWS GPU / Replicate | GPU 访问 |
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| # | ❌ 不要做 | ✅ 应该做 |
|
||||
|---|---------|--------------|
|
||||
| 1 | "X 在 HN 上火了" | 对照**你自己的需求**来评估 |
|
||||
| 2 | 简历驱动开发 | 选团队能维护的 |
|
||||
| 3 | "必须支持 100 万用户"(第一天) | 按当前需求的 10 倍构建,而非 1000 倍 |
|
||||
| 4 | 评估好几周 | 限制在 3-5 天,然后做决定 |
|
||||
| 5 | 不做决策文档 | 为每个重大选择写 ADR |
|
||||
| 6 | 忽略运维成本 | 将部署、监控、调试成本纳入考量 |
|
||||
| 7 | "以后重写" | 假设不会重写。慎重选择。 |
|
||||
| 8 | 默认微服务 | 从单体开始,需要时再拆分 |
|
||||
| 9 | 每个服务一个不同数据库(第一天) | 用一个数据库,有充分理由再拆分 |
|
||||
| 10 | "谷歌就是这么做的" | 你不是谷歌。按**你自己的场景**来扩缩。 |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:"团队无法就框架达成一致"
|
||||
|
||||
**解决方法:** 限制在 3 天内。填写评估矩阵。如果分数相差在 10% 以内,选多数人熟悉的。写入 ADR。继续推进。
|
||||
|
||||
### 问题 2:"我们选了 X,但它不合适"
|
||||
|
||||
**解决方法:** 检查沉没成本谬误。如果投入不到 2 周,立即切换。如果已超过 2 周,记录痛点并规划分阶段迁移。
|
||||
|
||||
### 问题 3:"我们需要微服务吗?"
|
||||
|
||||
**解决方法:** 几乎肯定不需要。从一个结构良好的单体开始。仅在以下情况才提取为服务:(a) 不同扩缩需求,(b) 不同团队所有权,(c) 不同部署节奏。
|
||||
@@ -0,0 +1,406 @@
|
||||
```markdown
|
||||
# 后端测试策略
|
||||
|
||||
面向后端与全栈应用的全面测试指南。涵盖完整的测试金字塔,重点深入 API 集成测试、数据库测试、契约测试与性能测试。
|
||||
|
||||
## 快速启动清单
|
||||
|
||||
- [ ] **测试运行器已配置**(Jest/Vitest、Pytest、Go test)
|
||||
- [ ] **测试数据库**就绪(Docker 容器或内存数据库)
|
||||
- [ ] **每次测试的数据库隔离**(事务回滚或截断表)
|
||||
- [ ] **测试工厂**用于常见实体(用户、订单、商品)
|
||||
- [ ] **认证助手**用于生成测试令牌
|
||||
- [ ] **CI 流水线**使用真实数据库服务运行测试
|
||||
- [ ] **覆盖率阈值**已设定(≥ 80%)
|
||||
|
||||
---
|
||||
|
||||
## 测试金字塔
|
||||
|
||||
```
|
||||
╱╲ E2E(少量、慢速)——跨服务的完整流程
|
||||
╱ ╲
|
||||
╱────╲ 集成测试(适中)——API + 数据库 + 外部服务
|
||||
╱ ╲
|
||||
╱────────╲ 单元测试(大量、快速)——纯业务逻辑
|
||||
╱__________╲
|
||||
```
|
||||
|
||||
| 层级 | 测试内容 | 速度 | 数量 |
|
||||
|-------|------|-------|-------|
|
||||
| 单元测试 | 纯函数、业务逻辑、无 I/O | < 10ms | 占总测试的 70% 以上 |
|
||||
| 集成测试 | API 路由 + 真实数据库 + 模拟外部服务 | 50-500ms | 约 20% |
|
||||
| E2E 测试 | 跨已部署服务的完整用户流程 | 1-30s | 约 10% |
|
||||
| 契约测试 | 服务间的 API 兼容性 | < 100ms | 每个 API 边界 |
|
||||
| 性能测试 | 负载、压力、浸泡测试 | 数分钟 | 每个关键路径 |
|
||||
|
||||
---
|
||||
|
||||
## 1. API 集成测试(关键)
|
||||
|
||||
### 每个端点需要测试的内容
|
||||
|
||||
| 方面 | 需编写的测试 |
|
||||
|--------|---------------|
|
||||
| 正常路径 | 正确的输入 → 预期的响应 + 正确的数据库状态 |
|
||||
| 认证 | 无令牌 → 401,错误令牌 → 401,过期令牌 → 401 |
|
||||
| 授权 | 错误角色 → 403,非所有者 → 403 |
|
||||
| 校验 | 缺少字段 → 422,错误类型 → 422,边界值 |
|
||||
| 未找到 | 无效 ID → 404,已删除资源 → 404 |
|
||||
| 冲突 | 重复创建 → 409,过期更新 → 409 |
|
||||
| 幂等性 | 相同请求两次 → 相同结果 |
|
||||
| 副作用 | 数据库状态变更、事件被触发、缓存失效 |
|
||||
| 错误格式 | 所有错误均符合 RFC 9457 信封格式 |
|
||||
|
||||
### TypeScript(Jest + Supertest)
|
||||
|
||||
```typescript
|
||||
describe('POST /api/orders', () => {
|
||||
let token: string;
|
||||
let product: Product;
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetDatabase();
|
||||
const user = await createTestUser({ role: 'customer' });
|
||||
token = await getAuthToken(user);
|
||||
product = await createTestProduct({ price: 29.99, stock: 10 });
|
||||
});
|
||||
|
||||
it('创建订单 → 201 + 正确的数据库状态', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/orders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ items: [{ productId: product.id, quantity: 2 }] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data.total).toBe(59.98);
|
||||
|
||||
const updated = await db.product.findUnique({ where: { id: product.id } });
|
||||
expect(updated!.stock).toBe(8);
|
||||
});
|
||||
|
||||
it('无认证时拒绝 → 401', async () => {
|
||||
const res = await request(app).post('/api/orders').send({ items: [] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('空商品列表时拒绝 → 422', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/orders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ items: [] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.errors[0].field).toBe('items');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Python(Pytest + FastAPI TestClient)
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def client(db_session):
|
||||
def override_get_db():
|
||||
yield db_session
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_create_order_success(client, auth_headers, test_product):
|
||||
response = client.post("/api/orders", json={
|
||||
"items": [{"product_id": test_product.id, "quantity": 2}]
|
||||
}, headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["data"]["total"] == 59.98
|
||||
|
||||
def test_create_order_no_auth(client):
|
||||
response = client.post("/api/orders", json={"items": []})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_create_order_empty_items(client, auth_headers):
|
||||
response = client.post("/api/orders", json={"items": []}, headers=auth_headers)
|
||||
assert response.status_code == 422
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库测试(高优先级)
|
||||
|
||||
### 测试隔离策略
|
||||
|
||||
| 策略 | 速度 | 真实度 | 使用时机 |
|
||||
|----------|-------|---------|------|
|
||||
| **事务回滚** | ⚡ 最快 | 中等 | 单元测试 + 集成测试的默认方式 |
|
||||
| **截断表** | 快 | 高 | 事务回滚不可用时 |
|
||||
| **测试容器** | 启动慢 | 最高 | CI 流水线、完整集成测试 |
|
||||
|
||||
**事务回滚(推荐默认方式):**
|
||||
```typescript
|
||||
let tx: Transaction;
|
||||
beforeEach(async () => { tx = await db.beginTransaction(); });
|
||||
afterEach(async () => { await tx.rollback(); });
|
||||
```
|
||||
|
||||
**Docker 测试容器(CI):**
|
||||
```yaml
|
||||
# docker-compose.test.yml
|
||||
services:
|
||||
test-db:
|
||||
image: postgres:16-alpine
|
||||
tmpfs: /var/lib/postgresql/data # RAM 磁盘以提升速度
|
||||
environment:
|
||||
POSTGRES_DB: myapp_test
|
||||
```
|
||||
|
||||
### 测试工厂(而非原始 SQL)
|
||||
|
||||
```typescript
|
||||
// factories/user.factory.ts
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export function buildUser(overrides: Partial<User> = {}): CreateUserDTO {
|
||||
return {
|
||||
email: faker.internet.email(),
|
||||
firstName: faker.person.firstName(),
|
||||
role: 'customer',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
export async function createUser(overrides = {}) {
|
||||
return db.user.create({ data: buildUser(overrides) });
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# factories/user_factory.py
|
||||
import factory
|
||||
from faker import Faker
|
||||
|
||||
class UserFactory(factory.Factory):
|
||||
class Meta:
|
||||
model = User
|
||||
email = factory.LazyAttribute(lambda _: Faker().email())
|
||||
first_name = factory.LazyAttribute(lambda _: Faker().first_name())
|
||||
role = "customer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 外部服务测试(高优先级)
|
||||
|
||||
### HTTP 级别模拟(而非函数级别模拟)
|
||||
|
||||
**TypeScript(nock):**
|
||||
```typescript
|
||||
import nock from 'nock';
|
||||
|
||||
it('成功处理支付', async () => {
|
||||
nock('https://api.stripe.com')
|
||||
.post('/v1/charges')
|
||||
.reply(200, { id: 'ch_123', status: 'succeeded', amount: 5000 });
|
||||
|
||||
const result = await paymentService.charge({ amount: 50.00, currency: 'usd' });
|
||||
expect(result.status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('处理支付超时', async () => {
|
||||
nock('https://api.stripe.com').post('/v1/charges').delay(10000).reply(200);
|
||||
await expect(paymentService.charge({ amount: 50, currency: 'usd' }))
|
||||
.rejects.toThrow('timeout');
|
||||
});
|
||||
```
|
||||
|
||||
**Python(responses):**
|
||||
```python
|
||||
import responses
|
||||
|
||||
@responses.activate
|
||||
def test_payment_success():
|
||||
responses.post("https://api.stripe.com/v1/charges",
|
||||
json={"id": "ch_123", "status": "succeeded"}, status=200)
|
||||
result = payment_service.charge(amount=50.00, currency="usd")
|
||||
assert result.status == "succeeded"
|
||||
```
|
||||
|
||||
### 用于基础设施的测试容器
|
||||
|
||||
```typescript
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import { RedisContainer } from '@testcontainers/redis';
|
||||
|
||||
beforeAll(async () => {
|
||||
const pg = await new PostgreSqlContainer('postgres:16').start();
|
||||
process.env.DATABASE_URL = pg.getConnectionUri();
|
||||
await runMigrations();
|
||||
}, 60000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 契约测试(中高优先级)
|
||||
|
||||
### 消费者驱动的契约(Pact)
|
||||
|
||||
**消费者(OrderService 调用 UserService):**
|
||||
```typescript
|
||||
it('可以根据 ID 获取用户', async () => {
|
||||
await pact.addInteraction()
|
||||
.given('用户 usr_123 存在')
|
||||
.uponReceiving('GET /users/usr_123')
|
||||
.withRequest('GET', '/api/users/usr_123')
|
||||
.willRespondWith(200, (b) => {
|
||||
b.jsonBody({ data: { id: MatchersV3.string(), email: MatchersV3.email() } });
|
||||
})
|
||||
.executeTest(async (mockserver) => {
|
||||
const user = await new UserClient(mockserver.url).getUser('usr_123');
|
||||
expect(user.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**提供者在 CI 中验证:**
|
||||
```typescript
|
||||
await new Verifier({
|
||||
providerBaseUrl: 'http://localhost:3001',
|
||||
pactBrokerUrl: process.env.PACT_BROKER_URL,
|
||||
provider: 'UserService',
|
||||
}).verifyProvider();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能测试(中优先级)
|
||||
|
||||
### k6 负载测试
|
||||
|
||||
```javascript
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '30s', target: 20 }, // 逐步增加
|
||||
{ duration: '1m', target: 100 }, // 持续维持
|
||||
{ duration: '30s', target: 0 }, // 逐步减少
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||||
http_req_failed: ['rate<0.01'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const res = http.get(`${__ENV.BASE_URL}/api/orders`);
|
||||
check(res, { 'status 200': (r) => r.status === 200 });
|
||||
sleep(1);
|
||||
}
|
||||
```
|
||||
|
||||
### 性能预算
|
||||
|
||||
| 指标 | 目标值 | 超出后措施 |
|
||||
|--------|--------|--------------------|
|
||||
| p95 响应时间 | < 500ms | 优化查询/缓存 |
|
||||
| p99 响应时间 | < 1000ms | 检查异常查询 |
|
||||
| 错误率 | < 0.1% | 排查异常尖峰 |
|
||||
| 数据库查询时间 | 每次 < 100ms | 添加索引 |
|
||||
|
||||
### 何时运行
|
||||
|
||||
| 触发时机 | 测试类型 |
|
||||
|---------|-----------|
|
||||
| 重大版本发布前 | 完整负载测试 |
|
||||
| 新增数据库查询/索引 | 查询基准测试 |
|
||||
| 基础设施变更 | 基线对比 |
|
||||
| 每周(CI) | 冒烟负载测试 |
|
||||
|
||||
---
|
||||
|
||||
## 测试文件组织
|
||||
|
||||
```
|
||||
tests/
|
||||
unit/ # 纯逻辑,模拟依赖
|
||||
order.service.test.ts
|
||||
integration/ # API + 真实数据库
|
||||
orders.api.test.ts
|
||||
auth.api.test.ts
|
||||
contracts/ # 消费者驱动的契约
|
||||
user-service.consumer.pact.ts
|
||||
performance/ # 负载测试
|
||||
load-test.js
|
||||
fixtures/
|
||||
factories/ # 测试数据工厂
|
||||
user.factory.ts
|
||||
seeds/
|
||||
test-data.ts
|
||||
helpers/
|
||||
setup.ts # 全局测试配置
|
||||
auth.helper.ts # 令牌生成
|
||||
db.helper.ts # 数据库清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| # | ❌ 不要这样做 | ✅ 应该这样做 |
|
||||
|---|---------|--------------|
|
||||
| 1 | 仅测试正常路径 | 测试错误、认证、校验、边界情况 |
|
||||
| 2 | 什么都模拟(无真实数据库) | 使用测试容器或测试数据库 |
|
||||
| 3 | 测试依赖执行顺序 | 每个测试自行设置/清理自身状态 |
|
||||
| 4 | 硬编码测试数据 | 使用工厂(faker + 覆盖参数) |
|
||||
| 5 | 测试实现细节 | 测试行为:输入 → 输出 |
|
||||
| 6 | 共享可变状态 | 每次测试隔离(事务回滚) |
|
||||
| 7 | 在 CI 中跳过迁移测试 | 在 CI 中从头运行迁移 |
|
||||
| 8 | 发布前不做性能测试 | 每个重大版本都做负载测试 |
|
||||
| 9 | 对生产数据做测试 | 仅使用生成的测试数据 |
|
||||
| 10 | 测试套件超过 10 分钟 | 并行化、使用 RAM 磁盘、优化设置 |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:「测试单独通过,一起运行时失败」
|
||||
|
||||
**原因:** 测试之间共享了数据库状态。缺少清理操作。
|
||||
|
||||
**修复方法:**
|
||||
```typescript
|
||||
beforeEach(async () => { await db.raw('TRUNCATE orders, users CASCADE'); });
|
||||
// 或者每次测试使用事务回滚
|
||||
```
|
||||
|
||||
### 问题 2:「测试运行结束后 Jest 没有在一秒内退出」
|
||||
|
||||
**原因:** 数据库连接或 HTTP 服务器未关闭。
|
||||
|
||||
**修复方法:**
|
||||
```typescript
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
await server.close();
|
||||
});
|
||||
```
|
||||
|
||||
### 问题 3:「异步回调未在超时时间内被调用」
|
||||
|
||||
**原因:** 缺少 `async/await` 或未处理的 promise。
|
||||
|
||||
**修复方法:**
|
||||
```typescript
|
||||
// ❌ Promise 未被等待
|
||||
it('should work', () => { request(app).get('/users'); });
|
||||
|
||||
// ✅ 正确等待
|
||||
it('should work', async () => { await request(app).get('/users'); });
|
||||
```
|
||||
|
||||
### 问题 4:「集成测试在 CI 中太慢」
|
||||
|
||||
**修复方法:**
|
||||
1. 对 PostgreSQL 数据目录使用 `tmpfs`(RAM 磁盘)
|
||||
2. 在 `beforeAll` 中运行一次迁移,在 `beforeEach` 中截断表
|
||||
3. 使用 `--maxWorkers` 并行化测试套件
|
||||
4. 特性分支跳过性能测试(仅在主分支运行)
|
||||
```
|
||||
Reference in New Issue
Block a user