From 2e61c0357cf75e41cc7aafdb12bccc91a301a421 Mon Sep 17 00:00:00 2001 From: wehub-skill-sync Date: Mon, 13 Jul 2026 21:35:57 +0800 Subject: [PATCH] chore: import zh skill fullstack-dev --- README.wehub.md | 9 + SKILL.md | 1038 ++++++++++++++++++++++++++ references/api-design.md | 444 +++++++++++ references/auth-flow.md | 172 +++++ references/db-schema.md | 705 +++++++++++++++++ references/django-best-practices.md | 466 ++++++++++++ references/environment-management.md | 78 ++ references/release-checklist.md | 285 +++++++ references/technology-selection.md | 254 +++++++ references/testing-strategy.md | 406 ++++++++++ 10 files changed, 3857 insertions(+) create mode 100644 README.wehub.md create mode 100644 SKILL.md create mode 100644 references/api-design.md create mode 100644 references/auth-flow.md create mode 100644 references/db-schema.md create mode 100644 references/django-best-practices.md create mode 100644 references/environment-management.md create mode 100644 references/release-checklist.md create mode 100644 references/technology-selection.md create mode 100644 references/testing-strategy.md diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..3cdb4e4 --- /dev/null +++ b/README.wehub.md @@ -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 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..25eeb05 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1038 @@ +--- + +name: fullstack-dev +description: | + 全栈后端架构与前后端集成指南。 + 触发条件:构建全栈应用、创建带前端的 REST API、搭建后端服务、 + 构建 todo 应用、构建 CRUD 应用、构建实时应用、构建聊天应用、 + Express + React、Next.js API、Node.js 后端、Python 后端、Go 后端、 + 设计服务层、实现错误处理、管理配置/认证、 + 设置 API 客户端、实现认证流程、处理文件上传、 + 添加实时功能(SSE/WebSocket)、为生产环境加固。 + 不触发条件:纯前端 UI 工作、纯 CSS/样式、仅数据库模式。 +license: MIT +metadata: + category: full-stack + version: "1.0.0" + sources: + - The Twelve-Factor App (12factor.net) + - Clean Architecture (Robert C. Martin) + - Domain-Driven Design (Eric Evans) + - Patterns of Enterprise Application Architecture (Martin Fowler) + - Martin Fowler (Testing Pyramid, Contract Tests) + - Google SRE Handbook (Release Engineering) + - ThoughtWorks Technology Radar +--- + +# 全栈开发实践 + +## 强制工作流程——请按顺序执行以下步骤 + +**当此技能被触发时,在编写任何代码之前,你必须遵循此工作流程。** + +### 第 0 步:收集需求 + +在搭建任何内容之前,请让用户澄清(或从上下文中推断): + +1. **技术栈**:后端和前端的语言/框架(例如 Express + React、Django + Vue、Go + HTMX) +2. **服务类型**:仅 API、全栈单体应用还是微服务? +3. **数据库**:SQL(PostgreSQL、SQLite、MySQL)还是 NoSQL(MongoDB、Redis)? +4. **集成方式**:REST、GraphQL、tRPC 还是 gRPC? +5. **实时性**:是否需要?如果需要——SSE、WebSocket 还是轮询? +6. **认证**:是否需要?如果需要——JWT、Session、OAuth 还是第三方(Clerk、Auth.js)? + +如果用户已在请求中指定了以上内容,则跳过询问,直接继续。 + +### 第 1 步:架构决策 + +基于需求,在编码之前做出并声明以下决策: + +| 决策项 | 可选方案 | 参考章节 | +|--------|---------|---------| +| 项目结构 | 按功能优先(推荐)vs 按层优先 | [第 1 节](#1-项目结构与分层关键) | +| API 客户端方式 | 类型化 fetch / React Query / tRPC / OpenAPI 代码生成 | [第 5 节](#5-api-客户端模式中等) | +| 认证策略 | JWT + 刷新 / Session / 第三方 | [第 6 节](#6-认证与中间件高) | +| 实时方案 | 轮询 / SSE / WebSocket | [第 11 节](#11-实时模式中等) | +| 错误处理 | 类型化错误层级 + 全局处理器 | [第 3 节](#3-错误处理与弹性高) | + +简要说明每个选择的原因(每项一句话)。 + +### 第 2 步:使用检查清单搭建 + +使用下方相应的检查清单。确保所有已勾选的项目均已实现——不得跳过任何项。 + +### 第 3 步:按照模式实现 + +按照本文档中的模式编写代码。在实现每个部分时,引用相应的具体章节。 + +### 第 4 步:测试与验证 + +实现完成后,在声称完成之前运行以下检查: + +1. **构建检查**:确保后端和前端均编译无误 + ```bash + # 后端 + cd server && npm run build + # 前端 + cd client && npm run build + ``` +2. **启动与冒烟测试**:启动服务器,验证关键端点返回预期的响应 + ```bash + # 启动服务器,然后测试 + curl http://localhost:3000/health + curl http://localhost:3000/api/ + ``` +3. **集成检查**:验证前端能够连接到后端(CORS、API 基础 URL、认证流程) +4. **实时性检查**(如适用):打开两个浏览器标签页,验证变更能够同步 + +如果任何检查失败,请在继续之前修复问题。 + +### 第 5 步:交接总结 + +向用户提供一个简要总结: + +- **已构建的内容**:已实现的功能和端点列表 +- **如何运行**:启动后端和前端的准确命令 +- **缺失项/后续步骤**:任何延期事项、已知限制或建议的改进 +- **关键文件**:列出用户应该了解的最重要文件 + +--- + +## 适用范围 + +**在以下情况使用此技能:** +- 构建全栈应用(后端 + 前端) +- 搭建新的后端服务或 API +- 设计服务层和模块边界 +- 实现数据库访问、缓存或后台任务 +- 编写错误处理、日志记录或配置管理 +- 审查后端代码的架构问题 +- 为生产环境加固 +- 设置 API 客户端、认证流程、文件上传或实时功能 + +**不适用于:** +- 纯前端/UI 相关事项(请查阅你的前端框架文档) +- 脱离后端上下文的纯数据库模式设计 + +--- + +## 快速入门——新后端服务检查清单 + +- [ ] 使用**按功能优先**结构搭建项目 +- [ ] 配置**集中化**,环境变量在启动时**验证**(快速失败) +- [ ] 定义**类型化错误层级**(而非通用 `Error`) +- [ ] **全局错误处理器**中间件 +- [ ] **结构化 JSON 日志**,带请求 ID 传递 +- [ ] 数据库:设置**迁移**,配置**连接池** +- [ ] 所有端点的**输入验证**(Zod / Pydantic / Go 验证器) +- [ ] **认证中间件**就位 +- [ ] **健康检查**端点(`/health`、`/ready`) +- [ ] **优雅关闭**处理(SIGTERM) +- [ ] **CORS** 已配置(显式来源,而非 `*`) +- [ ] **安全头部**(helmet 或等效方案) +- [ ] 提交 `.env.example`(不含真实密钥) + +## 快速入门——前后端集成检查清单 + +- [ ] **API 客户端**已配置(类型化 fetch 封装、React Query、tRPC 或 OpenAPI 生成) +- [ ] **基础 URL** 来自环境变量(而非硬编码) +- [ ] **认证令牌**自动附加到请求中(拦截器/中间件) +- [ ] **错误处理**——API 错误映射为用户可读的消息 +- [ ] **加载状态**已处理(骨架屏/旋转图标,而非空白屏幕) +- [ ] **跨边界类型安全**(共享类型、OpenAPI 或 tRPC) +- [ ] **CORS** 已配置,使用显式来源(生产环境不使用 `*`) +- [ ] **刷新令牌**流程已实现(httpOnly cookie + 401 时透明重试) + +--- + +## 快速导航 + +| 需要…… | 跳转到 | +|--------|--------| +| 组织项目文件夹 | [1. 项目结构](#1-项目结构与分层关键) | +| 管理配置 + 密钥 | [2. 配置](#2-配置与环境关键) | +| 正确处理错误 | [3. 错误处理](#3-错误处理与弹性高) | +| 编写数据库代码 | [4. 数据库访问模式](#4-数据库访问模式高) | +| 从前端设置 API 客户端 | [5. API 客户端模式](#5-api-客户端模式中等) | +| 添加认证中间件 | [6. 认证与中间件](#6-认证与中间件高) | +| 设置日志记录 | [7. 日志记录与可观测性](#7-日志记录与可观测性中高) | +| 添加后台任务 | [8. 后台任务](#8-后台任务与异步中等) | +| 实现缓存 | [9. 缓存模式](#9-缓存模式中等) | +| 上传文件(预签名 URL、multipart) | [10. 文件上传模式](#10-文件上传模式中等) | +| 添加实时功能(SSE、WebSocket) | [11. 实时模式](#11-实时模式中等) | +| 在前端 UI 中处理 API 错误 | [12. 跨边界错误处理](#12-跨边界错误处理中等) | +| 为生产环境加固 | [13. 生产环境加固](#13-生产环境加固中等) | +| 设计 API 端点 | [API 设计](references/api-design.md) | +| 设计数据库模式 | [数据库模式](references/db-schema.md) | +| 认证流程(JWT、刷新、Next.js SSR、RBAC) | [references/auth-flow.md](references/auth-flow.md) | +| CORS、环境变量、环境管理 | [references/environment-management.md](references/environment-management.md) | + +--- + +## 核心原则(7 条铁律) + +``` +1. ✅ 按功能组织,而非按技术层 +2. ✅ 控制器绝不包含业务逻辑 +3. ✅ 服务绝不导入 HTTP 请求/响应类型 +4. ✅ 所有配置来自环境变量,启动时验证,快速失败 +5. ✅ 每个错误都有类型、被记录,并返回一致的格式 +6. ✅ 所有输入在边界处验证——不信任来自客户端的任何内容 +7. ✅ 使用带请求 ID 的结构化 JSON 日志——而非 console.log +``` + +--- + +## 1. 项目结构与分层(关键) + +### 按功能优先的组织方式 + +``` +✅ 按功能优先 ❌ 按层优先 +src/ src/ + orders/ controllers/ + order.controller.ts order.controller.ts + order.service.ts user.controller.ts + order.repository.ts services/ + order.dto.ts order.service.ts + order.test.ts user.service.ts + users/ repositories/ + user.controller.ts ... + user.service.ts + shared/ + database/ + middleware/ +``` + +### 三层架构 + +``` +控制器(HTTP)→ 服务(业务逻辑)→ 仓库(数据访问) +``` + +| 层 | 职责 | ❌ 绝不 | +|------|--------|--------| +| 控制器 | 解析请求、验证、调用服务、格式化响应 | 业务逻辑、数据库查询 | +| 服务 | 业务规则、编排、事务管理 | HTTP 类型(req/res)、直接访问数据库 | +| 仓库 | 数据库查询、外部 API 调用 | 业务逻辑、HTTP 类型 | + +### 依赖注入(所有语言) + +**TypeScript:** +```typescript +class OrderService { + constructor( + private readonly orderRepo: OrderRepository, // ✅ 注入接口 + private readonly emailService: EmailService, + ) {} +} +``` + +**Python:** +```python +class OrderService: + def __init__(self, order_repo: OrderRepository, email_service: EmailService): + self.order_repo = order_repo # ✅ 注入 + self.email_service = email_service +``` + +**Go:** +```go +type OrderService struct { + orderRepo OrderRepository // ✅ 接口 + emailService EmailService +} + +func NewOrderService(repo OrderRepository, email EmailService) *OrderService { + return &OrderService{orderRepo: repo, emailService: email} +} +``` + +--- + +## 2. 配置与环境(关键) + +### 集中化、类型化、快速失败 + +**TypeScript:** +```typescript +const config = { + port: parseInt(process.env.PORT || '3000', 10), + database: { url: requiredEnv('DATABASE_URL'), poolSize: intEnv('DB_POOL_SIZE', 10) }, + auth: { jwtSecret: requiredEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN || '1h' }, +} as const; + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); // 快速失败 + return value; +} +``` + +**Python:** +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + database_url: str # 必填——没有它应用不会启动 + jwt_secret: str # 必填 + port: int = 3000 # 可选,有默认值 + db_pool_size: int = 10 + class Config: + env_file = ".env" + +settings = Settings() # 如果缺少 DATABASE_URL 则快速失败 +``` + +### 规则 + +``` +✅ 所有配置通过环境变量(十二因素应用) +✅ 在启动时验证必填变量——快速失败 +✅ 在配置层进行类型转换,而非在使用处 +✅ 提交 .env.example,使用虚拟值 + +❌ 绝不硬编码密钥、URL 或凭据 +❌ 绝不提交 .env 文件 +❌ 绝不在代码中散落 process.env / os.environ +``` + +--- + +## 3. 错误处理与弹性(高) + +### 类型化错误层级 + +```typescript +// 基类(TypeScript) +class AppError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode: number, + public readonly isOperational: boolean = true, + ) { super(message); } +} +class NotFoundError extends AppError { + constructor(resource: string, id: string) { + super(`${resource} not found: ${id}`, 'NOT_FOUND', 404); + } +} +class ValidationError extends AppError { + constructor(public readonly errors: FieldError[]) { + super('Validation failed', 'VALIDATION_ERROR', 422); + } +} +``` + +```python +# 基类(Python) +class AppError(Exception): + def __init__(self, message: str, code: str, status_code: int): + self.message, self.code, self.status_code = message, code, status_code + +class NotFoundError(AppError): + def __init__(self, resource: str, id: str): + super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404) +``` + +### 全局错误处理器 + +```typescript +// TypeScript(Express) +app.use((err, req, res, next) => { + if (err instanceof AppError && err.isOperational) { + return res.status(err.statusCode).json({ + title: err.code, status: err.statusCode, + detail: err.message, request_id: req.id, + }); + } + logger.error('Unexpected error', { error: err.message, stack: err.stack, request_id: req.id }); + res.status(500).json({ title: 'Internal Error', status: 500, request_id: req.id }); +}); +``` + +### 规则 + +``` +✅ 类型化、领域特定的错误类 +✅ 全局错误处理器捕获所有异常 +✅ 操作性错误→结构化响应 +✅ 编程错误→记录日志 + 通用 500 +✅ 对临时故障使用指数退避重试 + +❌ 绝不捕获并静默忽略错误 +❌ 绝不向客户端返回堆栈跟踪 +❌ 绝不抛出通用的 Error('something') +``` + +--- + +## 4. 数据库访问模式(高) + +### 始终使用迁移 + +```bash +# TypeScript(Prisma) # Python(Alembic) # Go(golang-migrate) +npx prisma migrate dev alembic revision --autogenerate migrate -source file://migrations +npx prisma migrate deploy alembic upgrade head migrate -database $DB up +``` + +``` +✅ 通过迁移进行模式变更,绝不手动执行 SQL +✅ 迁移必须是可逆的 +✅ 在应用到生产环境前审查迁移 SQL +❌ 绝不手动修改生产环境模式 +``` + +### N+1 预防 + +```typescript +// ❌ N+1:1 次查询 + N 次查询 +const orders = await db.order.findMany(); +for (const o of orders) { o.items = await db.item.findMany({ where: { orderId: o.id } }); } + +// ✅ 单次 JOIN 查询 +const orders = await db.order.findMany({ include: { items: true } }); +``` + +### 多步写入使用事务 + +```typescript +await db.$transaction(async (tx) => { + const order = await tx.order.create({ data: orderData }); + await tx.inventory.decrement({ productId, quantity }); + await tx.payment.create({ orderId: order.id, amount }); +}); +``` + +### 连接池 + +连接池大小 = `(CPU 核心数 × 2) + 磁盘轴数`(从 10-20 开始)。始终设置连接超时。对于无服务器场景使用 PgBouncer。 + +--- + +## 5. API 客户端模式(中等) + +连接前后端的"胶水层"。选择适合你的团队和技术栈的方式。 + +### 选项 A:类型化 Fetch 封装(简单,无依赖) + +```typescript +// lib/api-client.ts +const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +class ApiError extends Error { + constructor(public status: number, public body: any) { + super(body?.detail || body?.message || `API error ${status}`); + } +} + +async function api(path: string, options: RequestInit = {}): Promise { + const token = getAuthToken(); // 来自 cookie / 内存 / context + + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new ApiError(res.status, body); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const apiClient = { + get: (path: string) => api(path), + post: (path: string, data: unknown) => api(path, { method: 'POST', body: JSON.stringify(data) }), + put: (path: string, data: unknown) => api(path, { method: 'PUT', body: JSON.stringify(data) }), + patch: (path: string, data: unknown) => api(path, { method: 'PATCH', body: JSON.stringify(data) }), + delete: (path: string) => api(path, { method: 'DELETE' }), +}; +``` + +### 选项 B:React Query + 类型化客户端(React 推荐) + +```typescript +// hooks/use-orders.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; + +interface Order { id: string; total: number; status: string; } +interface CreateOrderInput { items: { productId: string; quantity: number }[] } + +export function useOrders() { + return useQuery({ + queryKey: ['orders'], + queryFn: () => apiClient.get<{ data: Order[] }>('/api/orders'), + staleTime: 1000 * 60, // 1 分钟 + }); +} + +export function useCreateOrder() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateOrderInput) => + apiClient.post<{ data: Order }>('/api/orders', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orders'] }); + }, + }); +} + +// 在组件中使用: +function OrdersPage() { + const { data, isLoading, error } = useOrders(); + const createOrder = useCreateOrder(); + if (isLoading) return ; + if (error) return ; + // ... +} +``` + +### 选项 C:tRPC(同一团队同时维护两端) + +```typescript +// server: trpc/router.ts +export const appRouter = router({ + orders: router({ + list: publicProcedure.query(async () => { + return db.order.findMany({ include: { items: true } }); + }), + create: protectedProcedure + .input(z.object({ items: z.array(orderItemSchema) })) + .mutation(async ({ input, ctx }) => { + return orderService.create(ctx.user.id, input); + }), + }), +}); +export type AppRouter = typeof appRouter; + +// client:自动类型安全,无需代码生成 +const { data } = trpc.orders.list.useQuery(); +const createOrder = trpc.orders.create.useMutation(); +``` + +### 选项 D:OpenAPI 生成客户端(公共/多消费者 API) + +```bash +npx openapi-typescript-codegen \ + --input http://localhost:3001/api/openapi.json \ + --output src/generated/api \ + --client axios +``` + +### 决策:选择哪个 API 客户端? + +| 方式 | 适用场景 | 类型安全 | 工作量 | +|--------|--------|----------|--------| +| 类型化 fetch 封装 | 简单应用、小团队 | 手动类型 | 低 | +| React Query + fetch | React 应用、服务端状态 | 手动类型 | 中等 | +| tRPC | 同一团队,两端均为 TypeScript | 自动 | 低 | +| OpenAPI 生成 | 公共 API、多消费者 | 自动 | 中等 | +| GraphQL codegen | GraphQL API | 自动 | 中等 | + +--- + +## 6. 认证与中间件(高) + +> **完整参考:** [references/auth-flow.md](references/auth-flow.md)——JWT Bearer 流程、自动令牌刷新、Next.js 服务端认证、RBAC 模式、后端中间件顺序。 + +### 标准中间件顺序 + +``` +请求 → 1.请求ID → 2.日志 → 3.CORS → 4.限流 → 5.请求体解析 + → 6.认证 → 7.鉴权 → 8.验证 → 9.处理器 → 10.错误处理器 → 响应 +``` + +### JWT 规则 + +``` +✅ 短期访问令牌(15 分钟)+ 刷新令牌(服务端存储) +✅ 最小化声明: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); +``` + +### 认证令牌自动刷新 + +```typescript +// lib/api-client.ts——在 401 时透明刷新 +async function apiWithRefresh(path: string, options: RequestInit = {}): Promise { + try { + return await api(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(path, options); // 重试 + } + throw err; + } +} +``` + +--- + +## 7. 日志记录与可观测性(中高) + +### 结构化 JSON 日志 + +```typescript +// ✅ 结构化——可解析、可过滤、可告警 +logger.info('Order created', { + orderId: order.id, userId: user.id, total: order.total, + items: order.items.length, duration_ms: Date.now() - startTime, +}); +// 输出:{"level":"info","msg":"Order created","orderId":"ord_123",...} + +// ❌ 非结构化——在大规模下毫无用处 +console.log(`Order created for user ${user.id} with total ${order.total}`); +``` + +### 日志级别 + +| 级别 | 适用场景 | 生产环境? | +|------|---------|-----------| +| error | 需要立即关注 | ✅ 始终 | +| warn | 意外但已处理 | ✅ 始终 | +| info | 正常操作、审计追踪 | ✅ 始终 | +| debug | 开发调试 | ❌ 仅开发环境 | + +### 规则 + +``` +✅ 每条日志条目包含请求 ID(通过中间件传播) +✅ 在层边界处记录日志(请求进入、响应返回、外部调用) +❌ 绝不记录密码、令牌、PII 或密钥 +❌ 绝不在生产代码中使用 console.log +``` + +--- + +## 8. 后台任务与异步(中等) + +### 规则 + +``` +✅ 所有任务必须是幂等的(同一任务执行两次 = 相同结果) +✅ 失败任务→重试(最多 3 次)→死信队列→告警 +✅ 工作进程作为独立进程运行(而非 API 服务器中的线程) + +❌ 绝不在请求处理器中放置长时间运行的任务 +❌ 绝不假设任务只执行一次 +``` + +### 幂等任务模式 + +```typescript +async function processPayment(data: { orderId: string }) { + const order = await orderRepo.findById(data.orderId); + if (order.paymentStatus === 'completed') return; // 已处理 + await paymentGateway.charge(order); + await orderRepo.updatePaymentStatus(order.id, 'completed'); +} +``` + +--- + +## 9. 缓存模式(中等) + +### 旁路缓存(懒加载) + +```typescript +async function getUser(id: string): Promise { + const cached = await redis.get(`user:${id}`); + if (cached) return JSON.parse(cached); + + const user = await userRepo.findById(id); + if (!user) throw new NotFoundError('User', id); + + await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900); // 15 分钟 TTL + return user; +} +``` + +### 规则 + +``` +✅ 始终设置 TTL——绝不做无过期时间的缓存 +✅ 在写入时失效(更新后删除缓存键) +✅ 缓存用于读取,绝不用于权威状态 + +❌ 绝不缓存而不设 TTL(过期数据比慢数据更糟糕) +``` + +| 数据类型 | 建议 TTL | +|-----------|--------| +| 用户资料 | 5-15 分钟 | +| 产品目录 | 1-5 分钟 | +| 配置/功能开关 | 30-60 秒 | +| 会话 | 与会话时长一致 | + +--- + +## 10. 文件上传模式(中等) + +### 选项 A:预签名 URL(大文件推荐) + +``` +客户端 → GET /api/uploads/presign?filename=photo.jpg&type=image/jpeg +服务端 → { uploadUrl: "https://s3.../presigned", fileKey: "uploads/abc123.jpg" } +客户端 → PUT uploadUrl(直接上传至 S3,绕过你的服务器) +客户端 → POST /api/photos { fileKey: "uploads/abc123.jpg" }(保存引用) +``` + +**后端:** +```typescript +app.get('/api/uploads/presign', authenticate, async (req, res) => { + const { filename, type } = req.query; + const key = `uploads/${crypto.randomUUID()}-${filename}`; + const url = await s3.getSignedUrl('putObject', { + Bucket: process.env.S3_BUCKET, Key: key, + ContentType: type, Expires: 300, // 5 分钟 + }); + res.json({ uploadUrl: url, fileKey: key }); +}); +``` + +**前端:** +```typescript +async function uploadFile(file: File) { + const { uploadUrl, fileKey } = await apiClient.get( + `/api/uploads/presign?filename=${file.name}&type=${file.type}` + ); + await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } }); + return apiClient.post('/api/photos', { fileKey }); +} +``` + +### 选项 B:Multipart(小于 10MB 的小文件) + +```typescript +// 前端 +const formData = new FormData(); +formData.append('file', file); +formData.append('description', 'Profile photo'); +const res = await fetch('/api/upload', { method: 'POST', body: formData }); +// 注意:不要设置 Content-Type 头部——浏览器会自动设置 boundary +``` + +### 决策 + +| 方式 | 文件大小 | 服务器负载 | 复杂度 | +|--------|---------|-----------|--------| +| 预签名 URL | 任意(推荐 > 5MB) | 无(直接上传至存储) | 中等 | +| Multipart | < 10MB | 高(流经服务器) | 低 | +| 分块/断点续传 | > 100MB | 中等 | 高 | + +--- + +## 11. 实时模式(中等) + +### 选项 A:服务器推送事件(SSE)——单向服务端 → 客户端 + +最适合:通知、实时推送、AI 响应流式输出。 + +**后端(Express):** +```typescript +app.get('/api/events', authenticate, (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + const send = (event: string, data: unknown) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + const unsubscribe = eventBus.subscribe(req.user.id, (event) => { + send(event.type, event.payload); + }); + req.on('close', () => unsubscribe()); +}); +``` + +**前端:** +```typescript +function useServerEvents(userId: string) { + useEffect(() => { + const source = new EventSource(`/api/events?userId=${userId}`); + source.addEventListener('notification', (e) => { + showToast(JSON.parse(e.data).message); + }); + source.onerror = () => { source.close(); setTimeout(() => /* 重连 */, 3000); }; + return () => source.close(); + }, [userId]); +} +``` + +### 选项 B:WebSocket——双向 + +最适合:聊天、协同编辑、游戏。 + +**后端(ws 库):** +```typescript +import { WebSocketServer } from 'ws'; +const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); +wss.on('connection', (ws, req) => { + const userId = authenticateWs(req); + if (!userId) { ws.close(4001, 'Unauthorized'); return; } + ws.on('message', (raw) => handleMessage(userId, JSON.parse(raw.toString()))); + ws.on('close', () => cleanupUser(userId)); + const interval = setInterval(() => ws.ping(), 30000); + ws.on('pong', () => { /* 活跃 */ }); + ws.on('close', () => clearInterval(interval)); +}); +``` + +**前端:** +```typescript +function useWebSocket(url: string) { + const [ws, setWs] = useState(null); + useEffect(() => { + const socket = new WebSocket(url); + socket.onopen = () => setWs(socket); + socket.onclose = () => setTimeout(() => /* 重连 */, 3000); + return () => socket.close(); + }, [url]); + const send = useCallback((data: unknown) => ws?.send(JSON.stringify(data)), [ws]); + return { ws, send }; +} +``` + +### 选项 C:轮询(最简单,无需基础设施) + +```typescript +function useOrderStatus(orderId: string) { + return useQuery({ + queryKey: ['order-status', orderId], + queryFn: () => apiClient.get(`/api/orders/${orderId}`), + refetchInterval: (query) => { + if (query.state.data?.status === 'completed') return false; + return 5000; + }, + }); +} +``` + +### 决策 + +| 方式 | 方向 | 复杂度 | 适用场景 | +|--------|---------|--------|---------| +| 轮询 | 客户端 → 服务端 | 低 | 简单的状态检查,少于 10 个客户端 | +| SSE | 服务端 → 客户端 | 中等 | 通知、推送、AI 流式输出 | +| WebSocket | 双向 | 高 | 聊天、协作、游戏 | + +--- + +## 12. 跨边界错误处理(中等) + +### API 错误 → 面向用户的消息 + +```typescript +// lib/error-handler.ts +export function getErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + switch (error.status) { + case 401: return '请登录后继续。'; + case 403: return '你没有执行此操作的权限。'; + case 404: return '你查找的项目不存在。'; + case 409: return '与已有项目冲突。'; + case 422: + const fields = error.body?.errors; + if (fields?.length) return fields.map((f: any) => f.message).join('。'); + return '请检查你的输入。'; + case 429: return '请求过于频繁,请稍后再试。'; + default: return '出错了,请重试。'; + } + } + if (error instanceof TypeError && error.message === 'Failed to fetch') { + return '无法连接到服务器,请检查你的网络连接。'; + } + return '发生了意外错误。'; +} +``` + +### React Query 全局错误处理器 + +```typescript +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { onError: (error) => toast.error(getErrorMessage(error)) }, + queries: { + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status < 500) return false; + return failureCount < 3; + }, + }, + }, +}); +``` + +### 规则 + +``` +✅ 将每个 API 错误代码映射为人类可读的消息 +✅ 在表单输入旁显示字段级别的验证错误 +✅ 5xx 自动重试(最多 3 次,带退避),4xx 绝不重试 +✅ 401 时重定向到登录页面(刷新尝试失败后) +✅ 当 fetch 因 TypeError 失败时显示"离线"横幅 + +❌ 绝不向用户显示原始 API 错误消息("NullPointerException") +❌ 绝不静默吞噬错误(显示 toast 或记录日志) +❌ 绝不重试 4xx 错误(客户端有误,重试无济于事) +``` + +### 集成决策树 + +``` +同一团队维护前端 + 后端? +│ +├─ 是,且两端均为 TypeScript +│ └─ tRPC(端到端类型安全,零代码生成) +│ +├─ 是,但语言不同 +│ └─ OpenAPI 规范 → 生成客户端(通过代码生成实现类型安全) +│ +├─ 否,公共 API +│ └─ REST + OpenAPI → 为消费者生成 SDK +│ +└─ 复杂数据需求,多个前端 + └─ GraphQL + codegen(每个客户端的灵活查询) + +需要实时功能? +│ +├─ 仅服务端 → 客户端(通知、推送、AI 流式输出) +│ └─ SSE(最简单,自动重连,可穿透代理) +│ +├─ 双向(聊天、协作) +│ └─ WebSocket(需要心跳 + 重连逻辑) +│ +└─ 简单的状态轮询(少于 10 个客户端) + └─ React Query refetchInterval(无需基础设施) +``` + +--- + +## 13. 生产环境加固(中等) + +### 健康检查 + +```typescript +app.get('/health', (req, res) => res.json({ status: 'ok' })); // 存活检查 +app.get('/ready', async (req, res) => { // 就绪检查 + const checks = { + database: await checkDb(), redis: await checkRedis(), + }; + const ok = Object.values(checks).every(c => c.status === 'ok'); + res.status(ok ? 200 : 503).json({ status: ok ? 'ok' : 'degraded', checks }); +}); +``` + +### 优雅关闭 + +```typescript +process.on('SIGTERM', async () => { + logger.info('SIGTERM received'); + server.close(); // 停止新连接 + await drainConnections(); // 完成正在处理的请求 + await closeDatabase(); + process.exit(0); +}); +``` + +### 安全检查清单 + +``` +✅ CORS:显式来源(生产环境绝不用 '*') +✅ 安全头部(helmet / 等效方案) +✅ 对公共端点进行限流 +✅ 所有端点进行输入验证(不信任任何内容) +✅ 强制使用 HTTPS +❌ 绝不向客户端暴露内部错误 +``` + +--- + +## 反模式 + +| # | ❌ 不要做 | ✅ 应该做 | +|---|---------|---------| +| 1 | 在路由/控制器中编写业务逻辑 | 移到服务层 | +| 2 | `process.env` 散落在各处 | 集中化的类型化配置 | +| 3 | 使用 `console.log` 记录日志 | 结构化 JSON 日志记录器 | +| 4 | 通用的 `Error('oops')` | 类型化错误层级 | +| 5 | 在控制器中直接调用数据库 | 仓库模式 | +| 6 | 没有输入验证 | 在边界处验证(Zod/Pydantic) | +| 7 | 静默捕获错误 | 记录日志 + 重新抛出或返回错误 | +| 8 | 没有健康检查端点 | `/health` + `/ready` | +| 9 | 硬编码配置/密钥 | 环境变量 | +| 10 | 没有优雅关闭 | 正确处理 SIGTERM | +| 11 | 在前端硬编码 API URL | 环境变量(`NEXT_PUBLIC_API_URL`) | +| 12 | 将 JWT 存储在 localStorage 中 | 内存 + httpOnly 刷新 cookie | +| 13 | 向用户显示原始 API 错误 | 映射为人类可读的消息 | +| 14 | 重试 4xx 错误 | 仅重试 5xx(服务端故障) | +| 15 | 跳过加载状态 | 获取数据时使用骨架屏/旋转图标 | +| 16 | 通过 API 服务器上传大文件 | 预签名 URL → 直接上传至 S3 | +| 17 | 轮询获取实时数据 | SSE 或 WebSocket | +| 18 | 前端 + 后端重复类型 | 共享类型、tRPC 或 OpenAPI codegen | + +--- + +## 常见问题 + +### 问题 1:"这条业务规则应该放哪里?" + +**规则:** 如果涉及 HTTP(请求解析、状态码、头部)→ 放控制器。如果涉及业务决策(定价、权限、规则)→ 放服务。如果涉及数据库 → 放仓库。 + +### 问题 2:"服务层变得太大了" + +**症状:** 一个服务文件超过 500 行,包含 20 多个方法。 + +**修复:** 按子领域拆分。`OrderService` → `OrderCreationService` + `OrderFulfillmentService` + `OrderQueryService`。每个专注于一个工作流。 + +### 问题 3:"测试很慢,因为它们要访问数据库" + +**修复:** 单元测试模拟仓库层(速度快)。集成测试使用测试容器或事务回滚(真实数据库,仍然快速)。绝不在集成测试中模拟服务层。 + +--- + +## 参考文档 + +此技能包含针对专门主题的深入参考文档。当你需要详细指导时,请阅读相应的参考文档。 + +| 需要…… | 参考文档 | +|---------|---------| +| 编写后端测试(单元、集成、端到端、契约、性能) | [references/testing-strategy.md](references/testing-strategy.md) | +| 在部署前验证发布(6 道关卡检查清单) | [references/release-checklist.md](references/release-checklist.md) | +| 选择技术栈(语言、框架、数据库、基础设施) | [references/technology-selection.md](references/technology-selection.md) | +| 使用 Django / DRF 构建(模型、视图、序列化器、管理后台) | [references/django-best-practices.md](references/django-best-practices.md) | +| 设计 REST/GraphQL/gRPC 端点(URL、状态码、分页) | [references/api-design.md](references/api-design.md) | +| 设计数据库模式、索引、迁移、多租户 | [references/db-schema.md](references/db-schema.md) | +| 认证流程(JWT Bearer、令牌刷新、Next.js SSR、RBAC、中间件顺序) | [references/auth-flow.md](references/auth-flow.md) | +| CORS 配置、各环境的环境变量、常见 CORS 问题 | [references/environment-management.md](references/environment-management.md) | diff --git a/references/api-design.md b/references/api-design.md new file mode 100644 index 0000000..4e48d42 --- /dev/null +++ b/references/api-design.md @@ -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: ; 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、负数数量) diff --git a/references/auth-flow.md b/references/auth-flow.md new file mode 100644 index 0000000..1bf0b7c --- /dev/null +++ b/references/auth-flow.md @@ -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 + 服务端 → 验证 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(path: string, options: RequestInit = {}): Promise { + try { + return await api(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(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 ; +} +``` + +--- + +## 后端:标准中间件顺序 + +``` +请求 → 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(不能使用 `*`) diff --git a/references/db-schema.md b/references/db-schema.md new file mode 100644 index 0000000..71034b3 --- /dev/null +++ b/references/db-schema.md @@ -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; diff --git a/references/django-best-practices.md b/references/django-best-practices.md new file mode 100644 index 0000000..1fafd34 --- /dev/null +++ b/references/django-best-practices.md @@ -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', ...)` 代替导入模型类。对于服务层,在函数内部进行导入。 diff --git a/references/environment-management.md b/references/environment-management.md new file mode 100644 index 0000000..bcdef2c --- /dev/null +++ b/references/environment-management.md @@ -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`,确认部署平台已设置相应的环境变量。 diff --git a/references/release-checklist.md b/references/release-checklist.md new file mode 100644 index 0000000..d5f8ecf --- /dev/null +++ b/references/release-checklist.md @@ -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 并不检查所有内容。执行检查清单。 | +| 「我们随时可以回滚」 | 只有当你有计划并测试过回滚才行 | +| 「上次这么干没问题」 | 幸存者偏差。每次都要走检查清单。 | diff --git a/references/technology-selection.md b/references/technology-selection.md new file mode 100644 index 0000000..c3e1027 --- /dev/null +++ b/references/technology-selection.md @@ -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) 不同部署节奏。 diff --git a/references/testing-strategy.md b/references/testing-strategy.md new file mode 100644 index 0000000..3b594f3 --- /dev/null +++ b/references/testing-strategy.md @@ -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 = {}): 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. 特性分支跳过性能测试(仅在主分支运行) +```