第 38 章:后端开发 —— SmartTodo API
第 37 章中,我们用 Claude Code 完成了 SmartTodo 的项目初始化——技术选型、脚手架搭建、CLAUDE.md 编写、开发工作流的建立。项目骨架已经就位,接下来要填充血肉了。
本章正式进入 SmartTodo 的后端开发。我们将从数据库设计开始,逐步构建 API 路由、认证授权系统,最后编写完整的测试套件。整章的核心不是某一个框架或 ORM 的用法,而是如何在实际后端开发中与 Claude Code 协作——怎么提问、怎么审查、怎么迭代、怎么验证。
本章目标:用 Claude Code 从零开发 SmartTodo 的完整后端——Prisma 数据建模、RESTful API 设计与实现(含 Zod 参数校验 + 分页)、JWT 认证与授权、Vitest + Supertest 测试驱动。掌握"层层递进、即时审查、逐级验证"的后端开发协作模式。
SmartTodo 的技术栈(已在第 37 章确定):
- 运行时:Node.js + Express
- 数据库:SQLite(开发)/ PostgreSQL(生产),通过 Prisma ORM 抽象
- 认证:JWT(access token + refresh token)
- 校验:Zod
- 测试:Vitest + Supertest
38.1 数据库设计
后端开发的第一站不是写路由,而是设计数据模型。数据库 schema 是整个后端的"地基"——模型关系定义得好,后续的路由和业务逻辑自然顺滑;模型关系有缺陷,后面的代码都是在"修补"。
传统做法是自己画 ER 图、手写 CREATE TABLE、逐一推敲字段类型和关系。用 Claude Code 做数据库设计,核心思路是让 AI 先出方案,你来审查和迭代——它可以把你对模型关系的"意图"快速转化成可运行的 schema。
38.1.1 初始需求输入:描述业务模型
在向 Claude Code 描述数据模型需求时,不要只说"设计一个 Todo 应用的数据库"。要给出完整的业务上下文和明确的约束条件:
帮我设计 SmartTodo 的数据库模型。SmartTodo 是一个多用户的任务管理应用,支持:
### 核心实体
- **用户(User)**:邮箱注册,支持 JWT 认证
- **任务(Task)**:用户创建的任务,包含标题、描述、优先级、截止日期、完成状态
- **标签(Tag)**:用户自定义标签,一个任务可以有多个标签
- **分类(Category)**:用户自定义分类,一个任务属于一个分类
### 业务规则
1. 一个用户有多个任务(1:N)
2. 一个用户有多个分类(1:N)
3. 一个用户有多个标签(1:N)
4. 任务和标签是多对多关系(M:N)
5. 一个任务属于一个分类(N:1),分类可选
6. 用户只能看到和操作自己的数据(多租户隔离)
### 技术约束
- 使用 Prisma ORM
- 数据库开发用 SQLite,生产用 PostgreSQL
- 所有表需要有 createdAt 和 updatedAt
- 优先级的取值范围:LOW / MEDIUM / HIGH / URGENT
请基于以上信息,生成 Prisma schema。先不要写代码,先列出数据模型设计的分析过程。为什么要让 Claude Code "先分析再写代码":如果你直接让它生成 schema,它会跳过分析直接产出结果——你只知道"它给了什么",但不知道"它为什么这么设计"。让 Claude Code 先分析,你可以在它动手之前审查决策的合理性。
38.1.2 Claude 的分析输出
Claude Code 会先输出类似这样的分析:
## SmartTodo 数据模型分析
### 实体识别与关系梳理
从需求中提取出 4 个核心实体,关系如下:User ──1:N──> Task ──N:1──> Category │ │ │ └──M:N──> Tag │ └──1:N──> Category │ └──1:N──> Tag
### 各实体字段设计
#### User
- id: Int @id @default(autoincrement()) — 自增主键
- email: String @unique — 登录凭证,需唯一
- passwordHash: String — bcrypt 哈希后的密码,不可明文
- name: String? — 可选的显示名称
- refreshToken: String? — refresh token,用于 JWT 续期
- createdAt: DateTime
- updatedAt: DateTime
#### Task
- id: Int @id @default(autoincrement())
- title: String — 任务标题
- description: String? — 可选详细描述
- priority: Priority 枚举(LOW / MEDIUM / HIGH / URGENT),默认 MEDIUM
- dueDate: DateTime? — 可选截止日期
- completed: Boolean @default(false)
- userId: Int — 外键 → User
- categoryId: Int? — 外键 → Category,可选
- createdAt / updatedAt
#### Category
- id: Int @id @default(autoincrement())
- name: String
- color: String? — 可选颜色标识
- userId: Int — 外键 → User
- createdAt / updatedAt
- @@unique([name, userId]) — 同一用户下分类名唯一
#### Tag
- id: Int @id @default(autoincrement())
- name: String
- userId: Int — 外键 → User
- createdAt / updatedAt
- @@unique([name, userId]) — 同一用户下标签名唯一
#### TaskTag(关联表)
- taskId: Int
- tagId: Int
- @@id([taskId, tagId]) — 复合主键
### 索引策略
- User.email — 登录查询高频
- Task.userId — 获取用户的所有任务(高频)
- Task.categoryId — 按分类筛选
- Category.userId — 获取用户的所有分类
- Tag.userId — 获取用户的所有标签
### 级联删除
- User 删除 → 级联删除关联的 Task、Category、Tag
- Category 删除 → 关联 Task 的 categoryId 设为 null(SET NULL)这个分析给出了完整的推理过程——为什么每个字段在、为什么关系这样设置、索引加的有没有道理。你拿到后不要直接说"OK 开始写",先花 3 分钟审视:
- 枚举值是否够用? 优先级只有 4 档够不够?要不要加 ARCHIVED 表示归档任务?
- 级联删除是否安全? User 删除级联删除所有数据——产品那边确认了这个行为吗?
- 索引是否过度? 开发阶段加太多索引反而拖慢写入——可以先只加最关键的,上线后根据慢查询再补。
38.1.3 审查后调整
审视后,你可能有一些调整意见。直接在对话中提出来:
分析基本 OK。做以下调整:
1. 任务加一个 status 字段,用枚举 TODO / IN_PROGRESS / DONE / ARCHIVED,替换单纯的 completed boolean——以后可能需要看板视图
2. Tag 和 Category 的 @@unique([name, userId]) 保留,但去掉 createdAt/updatedAt 上的索引(写入密集型表,暂不需要)
3. User 删除时 Task 的处理:不要级联删除,改为 SET NULL 或标记为匿名——先确认产品需求再定
确认后,生成完整 Prisma schema。Claude Code 会更新分析,然后生成最终的 schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
ARCHIVED
}
model User {
id Int @id @default(autoincrement())
email String @unique
passwordHash String
name String?
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
categories Category[]
tags Tag[]
}
model Task {
id Int @id @default(autoincrement())
title String
description String?
priority Priority @default(MEDIUM)
status TaskStatus @default(TODO)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
categoryId Int?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
tags TaskTag[]
@@index([userId])
@@index([categoryId])
}
model Category {
id Int @id @default(autoincrement())
name String
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([name, userId])
@@index([userId])
}
model Tag {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks TaskTag[]
@@unique([name, userId])
@@index([userId])
}
model TaskTag {
taskId Int
tagId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
}38.1.4 运行数据库迁移
Schema 就绪后,让 Claude Code 帮你执行迁移:
Schema 看起来没问题。运行 prisma migrate dev,迁移名称用 init。Claude Code 会执行:
npx prisma migrate dev --name init这一步会生成 prisma/migrations/ 目录下的迁移 SQL 文件,并创建 SQLite 数据库文件。Claude Code 会自动执行命令并输出结果,你在终端中就能看到迁移是否成功。
验证方式:
npx prisma studioPrisma Studio 会在浏览器中打开一个可视化的数据库管理界面。你可以直观地看到所有表的结构和关系——比在终端里敲 SQL 快得多。
38.1.5 编写种子数据
空数据库对开发测试不友好。让 Claude Code 生成种子数据脚本:
创建 prisma/seed.ts,插入测试数据:
- 2 个测试用户(test@example.com / demo@example.com)
- 每个用户 5 个任务,覆盖不同的优先级和状态
- 每个用户 3 个分类(工作、个人、学习)
- 每个用户 4 个标签(紧急、重要、待讨论、低优先级)
- 任务和标签的关联(随机分配)
密码用 bcrypt 哈希,一个固定的测试密码。Claude Code 会生成类似这样的 seed 脚本:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// 清理已有数据
await prisma.taskTag.deleteMany();
await prisma.task.deleteMany();
await prisma.tag.deleteMany();
await prisma.category.deleteMany();
await prisma.user.deleteMany();
const passwordHash = await bcrypt.hash('Test1234!', 10);
// 创建用户
const alice = await prisma.user.create({
data: {
email: 'test@example.com',
passwordHash,
name: 'Alice',
},
});
const bob = await prisma.user.create({
data: {
email: 'demo@example.com',
passwordHash,
name: 'Bob',
},
});
// 创建分类
const categories = await Promise.all(
['工作', '个人', '学习'].map((name) =>
prisma.category.create({
data: { name, userId: alice.id },
})
)
);
// 创建标签
const tags = await Promise.all(
['紧急', '重要', '待讨论', '低优先级'].map((name) =>
prisma.tag.create({
data: { name, userId: alice.id },
})
)
);
// 创建任务
const tasks = await Promise.all([
prisma.task.create({
data: {
title: '完成 SmartTodo 后端 API',
description: '实现认证、任务 CRUD、分类和标签管理',
priority: 'HIGH',
status: 'IN_PROGRESS',
dueDate: new Date('2026-06-15'),
userId: alice.id,
categoryId: categories[0].id,
},
}),
prisma.task.create({
data: {
title: '编写单元测试',
description: '覆盖认证和任务模块',
priority: 'MEDIUM',
status: 'TODO',
userId: alice.id,
categoryId: categories[0].id,
},
}),
prisma.task.create({
data: {
title: '健身 30 分钟',
priority: 'LOW',
status: 'TODO',
userId: alice.id,
categoryId: categories[1].id,
},
}),
prisma.task.create({
data: {
title: '阅读《深入理解计算机系统》',
priority: 'MEDIUM',
status: 'TODO',
userId: alice.id,
categoryId: categories[2].id,
},
}),
prisma.task.create({
data: {
title: '修复登录页面样式bug',
priority: 'URGENT',
status: 'DONE',
userId: alice.id,
categoryId: categories[0].id,
},
}),
]);
// 关联任务和标签
await prisma.taskTag.createMany({
data: [
{ taskId: tasks[0].id, tagId: tags[0].id }, // 紧急
{ taskId: tasks[0].id, tagId: tags[2].id }, // 待讨论
{ taskId: tasks[1].id, tagId: tags[1].id }, // 重要
{ taskId: tasks[4].id, tagId: tags[0].id }, // 紧急
],
});
// 为 Bob 创建种子数据(简化版)
await prisma.task.createMany({
data: [
{ title: 'Bob 的测试任务 1', priority: 'MEDIUM', status: 'TODO', userId: bob.id },
{ title: 'Bob 的测试任务 2', priority: 'HIGH', status: 'TODO', userId: bob.id },
],
});
console.log('✅ Seed data inserted successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});在 package.json 中配置 prisma seed:
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}执行种子数据:
npx prisma db seed验证:打开 Prisma Studio,确认数据已正确插入。
38.1.6 数据库设计阶段小结
到这一步,我们完成了:
- 从业务需求出发,与 Claude Code 协同设计了 4 个实体 + 1 个关联表的数据模型
- 通过"先分析再写代码"的模式,确保设计决策的合理性
- 运行 Prisma 迁移生成 SQLite 数据库
- 编写种子数据脚本,为后续开发测试提供便利
关键习惯:数据库设计是后续所有工作的基础。不要在这个阶段追求"一遍完美"——先出一个可工作的方案,在开发过程中随着业务理解的加深逐步调整。Prisma 的迁移机制让这种"渐进式精化"变得安全可控。
38.2 API 路由与控制器
数据库就绪后,下一步是搭建 API 层。SmartTodo 的 API 按资源组织,每个资源有对应的路由文件、控制器逻辑和参数校验。
38.2.1 路由结构设计
在动手写代码之前,先让 Claude Code 规划路由结构:
基于 Prisma schema,帮我设计完整的 RESTful API 路由结构。
要求:
- 资源命名使用复数名词
- 每个路由标注 HTTP 方法和路径
- 标注哪些路由需要认证
- 标注每个路由的请求体和查询参数
请先输出路由表,不要写代码。Claude Code 输出的路由表:
## SmartTodo API 路由表
### 认证 (Auth) — 无需认证
| 方法 | 路径 | 说明 | 请求体 |
|--------|----------------------|--------------|-------------------------------|
| POST | /api/auth/register | 注册 | { email, password, name? } |
| POST | /api/auth/login | 登录 | { email, password } |
| POST | /api/auth/refresh | 刷新 token | { refreshToken } |
| POST | /api/auth/logout | 登出 | — (需携带 access token) |
### 任务 (Tasks) — 需认证
| 方法 | 路径 | 说明 | 参数 |
|--------|----------------------|----------------|-------------------------------|
| GET | /api/tasks | 获取任务列表 | ?page=1&limit=20&status=&categoryId=&priority=&sortBy= |
| POST | /api/tasks | 创建任务 | { title, description?, priority?, status?, dueDate?, categoryId?, tagIds? } |
| GET | /api/tasks/:id | 获取单个任务 | — |
| PUT | /api/tasks/:id | 更新任务 | { title?, description?, ... } |
| DELETE | /api/tasks/:id | 删除任务 | — |
| PATCH | /api/tasks/:id/status| 更新任务状态 | { status } |
### 分类 (Categories) — 需认证
| 方法 | 路径 | 说明 |
|--------|-------------------------|----------------|
| GET | /api/categories | 获取分类列表 |
| POST | /api/categories | 创建分类 |
| PUT | /api/categories/:id | 更新分类 |
| DELETE | /api/categories/:id | 删除分类 |
### 标签 (Tags) — 需认证
| 方法 | 路径 | 说明 |
|--------|-------------------|----------------|
| GET | /api/tags | 获取标签列表 |
| POST | /api/tags | 创建标签 |
| PUT | /api/tags/:id | 更新标签 |
| DELETE | /api/tags/:id | 删除标签 |审查路由表时注意几点:
/api/tasks的 GET 请求带了分页和筛选参数——这是实际产品中必不可少但容易在设计阶段遗漏的- PATCH
/api/tasks/:id/status单独抽出来,因为更新状态是高频操作,独立端点比通过 PUT 更新整个任务体更轻量 - 所有资源路由(tasks/categories/tags)都需要认证——符合"多租户隔离"的需求
- 标签和分类不提供分页参数——用户的数据量通常不大,全量返回即可
38.2.2 搭建项目结构
在创建具体路由文件之前,需要先搭建后端的目录骨架。让 Claude Code 一次性规划:
基于上面的路由表,帮我设计方案的后端目录结构。
目录结构要清晰,遵循关注点分离原则:
- 路由 (routes) — 定义端点和 HTTP 方法
- 控制器 (controllers) — 处理请求逻辑
- 中间件 (middleware) — 认证、校验、错误处理
- 校验 (validators) — Zod schema
- 工具函数 (utils) — JWT、密码哈希等
请先列出目录结构,我来确认后再创建文件。Claude Code 输出的结构:
server/
├── src/
│ ├── index.ts # Express 入口
│ ├── app.ts # Express app 配置(中间件挂载)
│ ├── config.ts # 环境变量管理
│ ├── lib/
│ │ └── prisma.ts # Prisma client 单例
│ ├── middleware/
│ │ ├── auth.ts # JWT 认证中间件
│ │ ├── errorHandler.ts # 全局错误处理
│ │ └── rateLimiter.ts # 限流中间件
│ ├── validators/
│ │ ├── auth.ts # 认证相关 Zod schema
│ │ ├── task.ts # 任务相关 Zod schema
│ │ ├── category.ts # 分类相关 Zod schema
│ │ └── tag.ts # 标签相关 Zod schema
│ ├── controllers/
│ │ ├── auth.ts
│ │ ├── task.ts
│ │ ├── category.ts
│ │ └── tag.ts
│ ├── routes/
│ │ ├── auth.ts
│ │ ├── task.ts
│ │ ├── category.ts
│ │ ├── tag.ts
│ │ └── index.ts # 路由汇总
│ └── utils/
│ ├── jwt.ts # JWT sign/verify
│ └── password.ts # bcrypt hash/compare
├── tests/
│ ├── setup.ts # 测试环境配置
│ ├── auth.test.ts
│ ├── tasks.test.ts
│ ├── categories.test.ts
│ └── tags.test.ts
└── prisma/
├── schema.prisma
├── migrations/
└── seed.ts这是一个标准的分层结构:routes → controllers → Prisma,中间夹着 middleware(横切关注点)和 validators(输入校验)。确认结构后,让 Claude Code 批量创建空文件或脚手架。
38.2.3 逐模块实现 API
有了目录结构和路由表,接下来按依赖关系逐模块推进。正确的顺序是:
lib/prisma.ts— Prisma client 单例(所有模块的依赖)utils/jwt.ts+utils/password.ts— 工具函数middleware/auth.ts+middleware/errorHandler.ts— 中间件validators/*— 参数校验 schemacontrollers/auth.ts→routes/auth.tscontrollers/task.ts→routes/task.tscontrollers/category.ts→routes/category.tscontrollers/tag.ts→routes/tag.tsroutes/index.ts— 汇总挂载app.ts→index.ts— 应用启动
先创建 Prisma client 单例和工具函数:
创建 server/src/lib/prisma.ts,Prisma client 单例。
同时创建 server/src/utils/jwt.ts(JWT sign/verify)和 server/src/utils/password.ts(bcrypt hash/compare)。
jwt.ts 需要:
- generateAccessToken(userId): 生成 access token,过期时间 15 分钟
- generateRefreshToken(userId): 生成 refresh token,过期时间 7 天
- verifyAccessToken(token): 验证并解码 access token
password.ts 需要:
- hashPassword(password): bcrypt 哈希
- comparePassword(password, hash): 验证密码
这些是基础工具,后面所有模块都依赖它们。Claude Code 会创建这三个文件。lib/prisma.ts 比较简单,关键是要确保它是单例——整个应用只有一个 PrismaClient 实例,避免开发时热重载导致连接数爆炸。
jwt.ts 你重点审查:
- secret 是否从环境变量读取(绝不能硬编码)
- access token 过期时间是否合理(15 分钟是比较公认的实践)
- refresh token 用的是不同的 secret(JWT_REFRESH_SECRET),还是同一个 secret?——建议分开,增加安全性
password.ts 重点审查:bcrypt 的 salt rounds。默认 10 是合理的平衡点(安全性 vs 性能),但如果你对安全要求更高可以调至 12。
然后是认证模块:
现在实现认证模块。按顺序创建:
1. server/src/validators/auth.ts — Zod schema,校验 register/login 的输入
2. server/src/middleware/auth.ts — JWT 认证中间件
3. server/src/controllers/auth.ts — 注册、登录、刷新、登出
4. server/src/routes/auth.ts — 挂载路由
注意:
- 注册时密码至少 8 位,需包含大小写字母和数字
- 登录失败返回统一错误信息 "邮箱或密码错误"(不要区分用户不存在 vs 密码错误)
- refresh token 需要在数据库中存储和验证
- 登出时清除 refresh tokenClaude Code 会逐文件创建。其中 validators/auth.ts 的 Zod schema 是输入安全的第一道防线——你必须仔细审查:
// server/src/validators/auth.ts
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z
.string()
.min(8, '密码至少 8 位')
.regex(/[a-z]/, '密码需包含小写字母')
.regex(/[A-Z]/, '密码需包含大写字母')
.regex(/[0-9]/, '密码需包含数字'),
name: z.string().min(1).max(50).optional(),
});
export const loginSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(1, '密码不能为空'),
});
export const refreshSchema = z.object({
refreshToken: z.string().min(1, 'refresh token 不能为空'),
});审查要点:
- email 格式校验是否足够严格?(
z.string().email()只是基础校验,要不要加域名白名单?要不要限制长度?) - 密码强度规则是否合理?(8 位 + 大小写 + 数字是最低标准,产品是否要求特殊字符?)
- 错误信息是否会泄露过多信息?(Zod 默认的错误信息可以直接返回给前端,但注意不要在后端的 try-catch 中追加敏感信息)
接下来让 Claude Code 继续创建 tasks 模块:
实现 Tasks API。
先创建 server/src/validators/task.ts:
- createTaskSchema: title 必填(1-200字符),description 可选,priority 枚举,dueDate ISO 日期可选,categoryId 可选,tagIds 数组可选
- updateTaskSchema: 所有字段可选(partial update)
- queryTasksSchema: page (默认1), limit (默认20, 最大100), status, priority, categoryId, sortBy (createdAt/priority/dueDate), order (asc/desc)
然后创建 server/src/controllers/task.ts,实现:
- getTasks: 分页查询,支持按 status/priority/categoryId 筛选,支持排序
- getTaskById: 获取单个任务,验证所有权
- createTask: 创建任务,支持同时关联标签(taskTag createMany)
- updateTask: 部分更新,验证所有权
- deleteTask: 删除任务,验证所有权
- updateTaskStatus: 仅更新 status 字段
最关键的是:所有操作必须验证任务属于当前登录用户。用户不能看到或操作其他人的任务。
Controller 中使用 Prisma 的 include 来返回关联的 category 和 tags。Claude Code 创建 task controller 时,你会看到类似这样的代码:
// server/src/controllers/task.ts(核心片段)
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../lib/prisma';
export const getTasks = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user.id; // 由 auth middleware 注入
const { page = 1, limit = 20, status, priority, categoryId, sortBy = 'createdAt', order = 'desc' } = req.query;
const where: any = { userId };
if (status) where.status = status;
if (priority) where.priority = priority;
if (categoryId) where.categoryId = Number(categoryId);
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
include: {
category: { select: { id: true, name: true, color: true } },
tags: { include: { tag: { select: { id: true, name: true } } } },
},
orderBy: { [sortBy as string]: order },
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
}),
prisma.task.count({ where }),
]);
res.json({
data: tasks,
pagination: {
page: Number(page),
limit: Number(limit),
total,
totalPages: Math.ceil(total / Number(limit)),
},
});
} catch (error) {
next(error);
}
};
export const getTaskById = async (req: Request, res: Response, next: NextFunction) => {
try {
const task = await prisma.task.findUnique({
where: { id: Number(req.params.id) },
include: {
category: { select: { id: true, name: true, color: true } },
tags: { include: { tag: { select: { id: true, name: true } } } },
},
});
if (!task) {
return res.status(404).json({ error: '任务不存在' });
}
if (task.userId !== req.user.id) {
return res.status(403).json({ error: '无权访问此任务' });
}
res.json({ data: task });
} catch (error) {
next(error);
}
};审查 controller 代码时的重点:
where 条件中必须包含
userId:这是多租户隔离的核心。如果漏了,用户就能看到其他人的数据。你应该在审查时主动检查每个查询方法的 where 条件。include 的字段选择:
include默认返回关联对象的所有字段。对于 Tag 的嵌套查询,使用了include: { tag: { select: {...} } }只取需要的字段——这是好习惯。分页参数的安全边界:limit 是否有上限(max 100)?如果没有限制,攻击者可以传
?limit=9999999拖慢数据库。sortBy 的动态字段名:
orderBy: { [sortBy as string]: order }有风险——用户可以传任意字段名。应该在 Zod 中把sortBy限制为枚举值。
审查后如果有问题,直接在对话中纠正:
getTasks 的 sortBy 需要加校验——只允许 createdAt/priority/dueDate/updatedAt 四个值,在 Zod schema 中加 .refine() 限制。
分页 total 为 0 时 totalPages 应该是 0 而不是 0(你的 Math.ceil(0 / 20) = 0,没问题,但检查一下边界)38.2.4 Categories 和 Tags 模块
这两个模块结构相似,可以让 Claude Code 一起生成:
接下来同时实现 Categories 和 Tags 的 API。
它们的结构类似:
- GET /api/categories 和 GET /api/tags:返回当前用户的所有分类/标签(不需要分页)
- POST:创建
- PUT /:id:更新(验证所有权)
- DELETE /:id:删除(验证所有权)
标签的创建可以直接在 task create 时通过 tagIds 关联,所以 Tags API 相对简单。
分类删除时需要把关联任务的 categoryId 设为 null(Prisma schema 中已配置 onDelete: SetNull)。
请同时创建两个模块的 validators、controllers、routes。38.2.5 路由汇总与应用启动
所有模块创建完毕后,汇总路由并启动应用:
所有模块的 routes 都已创建。现在:
1. 创建 server/src/routes/index.ts,汇总所有路由
2. 创建 server/src/middleware/errorHandler.ts,全局错误处理(Zod 校验错误返回 400,Prisma 已知错误返回适当状态码,未知错误返回 500)
3. 创建 server/src/app.ts,配置 CORS、JSON parser、路由挂载、错误处理
4. 创建 server/src/index.ts,启动 HTTP 服务器
app.ts 中先挂载全局中间件(cors, express.json),再挂载 /api/auth 路由,然后挂载 auth middleware,最后挂载需要认证的路由(/api/tasks, /api/categories, /api/tags)。错误处理中间件放在最后。关键点:app.ts 中的中间件顺序必须是:
1. cors()
2. express.json()
3. rate limiter(可选)
4. /api/auth/* 路由(无需认证)
5. auth middleware ← 从这里开始,后续所有路由都需要认证
6. /api/tasks/*, /api/categories/*, /api/tags/* 路由
7. error handler(最后)auth middleware 放在 auth routes 之后、资源 routes 之前——这样注册和登录不需要认证,但任务/分类/标签的操作都需要。
38.2.6 手工验证 API
启动服务器后,用 curl 逐条验证:
# 1. 注册
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"newuser@test.com","password":"Test1234!"}'
# 2. 登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}'
# 3. 获取任务列表(用上一步返回的 accessToken)
curl http://localhost:3000/api/tasks \
-H "Authorization: Bearer <accessToken>"
# 4. 创建任务
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <accessToken>" \
-d '{"title":"测试任务","priority":"HIGH","status":"TODO"}'
# 5. 分页测试
curl "http://localhost:3000/api/tasks?page=1&limit=2" \
-H "Authorization: Bearer <accessToken>"
# 6. 测试授权隔离:用 Bob 的 token 访问 Alice 创建的任务
# 预期返回 403
curl http://localhost:3000/api/tasks/1 \
-H "Authorization: Bearer <bobAccessToken>"手工验证不是为了"证明代码没问题",而是为了在写测试之前先确认核心流程能跑通。如果连 curl 都跑不过,写再多的测试也是白费。
38.3 认证与授权
第 38.2 节中我们已经实现了基本的注册/登录和 JWT 中间件。这一节深入认证与授权的细节——不只是"能登录",更要"登录得安全、授权得严谨"。
38.3.1 JWT 认证中间件深度分析
先回顾一下 auth.ts 中间件的核心逻辑(由 Claude Code 在上一节创建):
// server/src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
import { prisma } from '../lib/prisma';
declare global {
namespace Express {
interface Request {
user: {
id: number;
email: string;
};
}
}
}
export const auth = async (req: Request, res: Response, next: NextFunction) => {
try {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: '未提供认证令牌' });
}
const token = header.split(' ')[1];
const payload = verifyAccessToken(token);
// 验证用户是否仍存在(防止已删除用户的 token 继续使用)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true },
});
if (!user) {
return res.status(401).json({ error: '用户不存在' });
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({ error: '认证令牌无效或已过期' });
}
};这段代码看起来没问题,但有几个细节值得追问。在对话中向 Claude Code 提问:
审查 auth middleware。有几个问题:
1. 每次请求都查数据库验证用户是否存在——这是否有性能问题?应该缓存吗?
2. token 过期后返回 401,前端怎么做无感刷新?
3. 如果用户修改了密码,旧的 token 是否应该失效?
分析这些问题的解决方案,并给出改进建议。Claude Code 会给出分析:
### 1. 每次查询数据库的性能影响
问题:每次请求都 `findUnique` 查询用户表,在 QPS 较高时会增加数据库负载。
方案:
- **短期**:保持现状。User 表的 `findUnique by id` 是主键查询,性能开销极小。token 本身的过期时间只有 15 分钟,数据库查询并非瓶颈。
- **中期(如果需要优化)**:使用内存缓存(如 node-cache)缓存已验证的 userId,TTL 设为 access token 的过期时间(15分钟)。
- **不推荐**:在 JWT payload 中放入用户的完整信息——JWT 是无状态设计,payload 中的数据在签发后无法被撤销,如果用户被删除或权限变更,payload 中的信息就过期了。
### 2. 前端无感刷新 Token
问题:access token 15 分钟过期,用户不可能每 15 分钟重新登录。
方案:
- 登录时同时返回 accessToken (15min) 和 refreshToken (7d)
- 前端在请求拦截器中检测 401 响应,自动调用 /api/auth/refresh 获取新的 accessToken
- refresh 接口需要传入 refreshToken(存储在 httpOnly cookie 或 localStorage)
- 如果 refresh 也失败(refresh token 过期或被撤销),才跳转到登录页
### 3. 密码修改后旧 Token 失效
问题:用户修改密码后,旧的 token 仍然可以正常使用(因为 token 是无状态的,只要没过期就能通过验证)。
方案:
- **最简单**:在 User 表中维护一个 tokenVersion 字段,签发 token 时包含 tokenVersion。修改密码时递增 tokenVersion,旧 token 因为版本号不匹配而失效。
- **更安全**:使用 refresh token 的轮换机制——每次使用 refresh token 都生成新的 refresh token,旧的作废。如果旧 refresh token 被盗用,因为它已经被轮换掉了,攻击者持有的旧 token 也会失效。基于这些分析,优化 auth 模块:
根据你的分析,做以下改进:
1. 在 User 模型中添加 tokenVersion 字段,修改密码时递增
2. JWT payload 中增加 tokenVersion
3. auth middleware 验证 tokenVersion 是否匹配
4. 实现 refresh token 轮换——使用旧的 refresh token 时同时发新的
请更新 schema、jwt.ts、auth middleware 和 auth controller。38.3.2 授权:任务所有权验证
认证(Authentication)回答的是"你是谁";授权(Authorization)回答的是"你能做什么"。在 SmartTodo 中,最关键的授权规则是:用户只能操作自己的资源。
在第 38.2 节的 Task Controller 中,我们已经做了所有权检查:
if (task.userId !== req.user.id) {
return res.status(403).json({ error: '无权访问此任务' });
}但这还不够。让 Claude Code 分析完整的授权策略:
审查当前的授权实现。当前只在 getTaskById、updateTask、deleteTask 中做了所有权检查。
分析以下场景是否存在授权漏洞:
1. 用户 A 创建任务时,在请求体中传入 categoryId 指向用户 B 的分类——会怎样?
2. 用户 A 更新任务时,在 tagIds 中传入用户 B 的标签 ID——会怎样?
3. 用户通过 PUT /api/categories/:id 修改他人分类的名称——有没有做所有权检查?
4. 有没有办法绕过 userId 检查?(比如通过关联查询的 include 参数)
针对每个场景,给出当前代码是否有漏洞的结论,以及修复方案。Claude Code 会逐项分析并指出漏洞——比如创建任务时附带的 categoryId 和 tagIds 应该验证它们属于当前用户:
// 创建任务时验证 category 和 tags 的所有权
if (categoryId) {
const category = await prisma.category.findUnique({ where: { id: categoryId } });
if (!category || category.userId !== userId) {
return res.status(400).json({ error: '分类不存在' });
}
}
if (tagIds && tagIds.length > 0) {
const tags = await prisma.tag.findMany({
where: { id: { in: tagIds }, userId },
});
if (tags.length !== tagIds.length) {
return res.status(400).json({ error: '标签不存在' });
}
}这是一个很多开发者会忽略的安全细节。Claude Code 在审查时会发现这类跨资源引用问题——这也是为什么"让 AI 审查 AI 写的代码"是有价值的:它能从不同的角度检查。
38.3.3 限流保护
认证接口是暴力破解的高风险目标。为 /api/auth/login 和 /api/auth/register 添加限流:
创建 server/src/middleware/rateLimiter.ts。
使用 express-rate-limit 库。
规则:
- 登录接口:每个 IP 15 分钟内最多 10 次尝试
- 注册接口:每个 IP 1 小时内最多 5 次
- 密码重置相关接口:每个 IP 1 小时内最多 3 次
- 通用 API:每个 IP 1 分钟内最多 100 次
同时在 auth routes 中应用对应的限流中间件。38.3.4 密码安全
密码安全有三个层面,逐一让 Claude Code 实现和审查:
1. 存储安全(bcrypt)——已在 utils/password.ts 中实现。审查 salt rounds:
bcrypt 的 salt rounds 当前设为 10。评估:10 是否足够?什么时候该用 12?Claude Code 的评估:10 轮在 2026 年的硬件上单次哈希约 50-100ms。对于登录这种非高频操作(不像 API 调用),这个延迟完全可以接受。12 轮增加了约 4 倍的哈希时间,安全性更高但对用户体验影响很小。建议保持 10,除非你的应用处理的是金融或医疗数据。
2. 传输安全——密码在 HTTP 请求体中明文传输。如果不用 HTTPS,密码会在网络中明文暴露。确保生产环境使用 HTTPS。
3. 密码策略——在 Zod schema 中已定义(最小 8 位、大小写字母 + 数字)。审查是否需要更强:
当前密码策略:8 位以上,包含大小写字母和数字。
产品需求是否要求特殊字符?是否要检查常见弱密码(如 12345678、password)?
如果要加弱密码检查,怎么实现?Claude Code 可能建议使用 zxcvbn 库来评估密码强度,或者维护一个弱密码黑名单。但要注意——过于严格的密码策略会增加用户注册的摩擦感,在产品阶段需要平衡安全性和用户体验。
38.4 测试编写
API 能跑通了,但"能跑通"离"可靠"还有距离。手工 curl 测试是一次性的、不可重复的。你需要自动化测试来保证:今天写的新功能不会破坏昨天的旧功能;你的同事(或三个月后的你自己)在重构时能快速验证行为是否退化。
38.4.1 测试环境搭建
SmartTodo 使用 Vitest + Supertest 进行 API 测试。首先让 Claude Code 搭建测试基础环境:
配置 Vitest 测试环境。需要:
1. 安装 vitest, supertest, @types/supertest
2. 创建 vitest.config.ts,配置测试环境为 node
3. 创建 tests/setup.ts,处理:
- 测试前:创建独立的测试数据库(SQLite 文件,测试结束后删除)
- 每个 test suite 前:运行 prisma migrate + seed
- 每个 test 后:cleanup(可选,如果测试之间需要隔离)
4. package.json 添加 test 脚本:"vitest run"
关键要求:测试不能影响开发数据库。使用独立的 SQLite 文件。Claude Code 会创建测试配置文件。其中 tests/setup.ts 是最关键的文件——它负责在每个测试套件运行前准备一个干净的数据库:
// tests/setup.ts
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
const TEST_DB_PATH = path.join(__dirname, 'test.db');
beforeAll(() => {
// 设置测试数据库路径
process.env.DATABASE_URL = `file:${TEST_DB_PATH}`;
// 推送 schema 到测试数据库(比 migrate 更快,适合测试环境)
execSync('npx prisma db push --force-reset --skip-generate', {
env: { ...process.env, DATABASE_URL: `file:${TEST_DB_PATH}` },
stdio: 'ignore',
});
});
afterAll(() => {
// 清理测试数据库
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
});审查要点:
- 测试数据库路径是否独立?会不会误删开发数据库?
db push --force-reset会重置数据库——测试套件之间是否隔离?如果多个测试文件并行运行会不会冲突?- 测试环境变量的设置是否在测试结束后恢复?(用
beforeAll/afterAll能确保每个文件的测试环境干净)
38.4.2 认证模块测试
测试从认证模块开始——因为它是其他一切的基础。让 Claude Code 为认证 API 编写完整测试:
为认证模块编写测试文件 tests/auth.test.ts。
使用 Vitest + Supertest。测试用例:
### 注册 (POST /api/auth/register)
- ✅ 成功注册:有效邮箱 + 符合策略的密码 → 201 + 返回用户信息(不含密码)
- ❌ 重复邮箱注册 → 409
- ❌ 邮箱格式不正确 → 400
- ❌ 密码不足 8 位 → 400
- ❌ 密码缺少大写字母 → 400
- ❌ 密码缺少数字 → 400
### 登录 (POST /api/auth/login)
- ✅ 正确邮箱 + 密码 → 200 + 返回 accessToken 和 refreshToken
- ❌ 错误密码 → 401 + "邮箱或密码错误"
- ❌ 不存在的邮箱 → 401 + "邮箱或密码错误"(不区分)
- ❌ 缺少必填字段 → 400
### 刷新 Token (POST /api/auth/refresh)
- ✅ 有效 refreshToken → 200 + 新的 accessToken 和 refreshToken(轮换)
- ❌ 过期 refreshToken → 401
- ❌ 无效 refreshToken → 401
### 认证中间件
- ✅ 有效 token → 通过,访问受保护路由成功
- ❌ 无 token → 401
- ❌ 过期 token → 401
- ❌ 伪造 token → 401Claude Code 会生成约 200-300 行的测试文件。一个典型的测试用例看起来像这样:
// tests/auth.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../src/app';
describe('POST /api/auth/register', () => {
it('should register a new user successfully', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'newuser@test.com',
password: 'Test1234!',
});
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty('id');
expect(res.body.data).toHaveProperty('email', 'newuser@test.com');
expect(res.body.data).not.toHaveProperty('passwordHash');
});
it('should reject duplicate email', async () => {
// 先注册
await request(app)
.post('/api/auth/register')
.send({ email: 'duplicate@test.com', password: 'Test1234!' });
// 再次注册
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'duplicate@test.com', password: 'Test1234!' });
expect(res.status).toBe(409);
});
it('should reject weak password (too short)', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@test.com', password: 'Ab1' });
expect(res.status).toBe(400);
});
it('should reject password without uppercase', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@test.com', password: 'test1234!' });
expect(res.status).toBe(400);
});
it('should reject password without digit', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@test.com', password: 'Test!!!!' });
expect(res.status).toBe(400);
});
});
describe('POST /api/auth/login', () => {
beforeAll(async () => {
// 确保测试用户存在
await request(app)
.post('/api/auth/register')
.send({ email: 'login@test.com', password: 'Test1234!' });
});
it('should login with correct credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'login@test.com', password: 'Test1234!' });
expect(res.status).toBe(200);
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
});
it('should reject wrong password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'login@test.com', password: 'WrongPass1!' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('邮箱或密码错误');
});
it('should return same error for non-existent email', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'noexist@test.com', password: 'Test1234!' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('邮箱或密码错误');
});
});审查测试代码时关注:
- 测试隔离:一个测试的副作用是否会影响其他测试?
beforeAll中的注册是否会在每个describe块之前重置? - 断言精度:
expect(res.body.error).toBe('邮箱或密码错误')这样的精确断言比expect(res.status).toBe(401)更有价值——它验证了安全策略(不泄露用户是否存在)。 - 不测什么:不要在测试中验证 Prisma 是否正常工作(那是 ORM 的责任),也不要测试 Express 的 JSON parser 是否正常(那是框架的责任)。你的测试应该聚焦于你的业务逻辑。
38.4.3 任务模块测试
任务 API 的测试更复杂——涉及 CRUD、分页、筛选、授权。让 Claude Code 写测试:
为任务模块编写测试 tests/tasks.test.ts。
测试前需要先注册 + 登录获取 token。可以抽取一个 helper:
- createUserAndLogin(): 注册用户并返回 { userId, accessToken }
测试用例:
### GET /api/tasks
- ✅ 获取任务列表(空列表)→ 200 + 空数组
- ✅ 获取任务列表(有数据)→ 200 + 数组 + 分页信息
- ✅ 分页:page=2&limit=2 → 返回第二页的数据
- ✅ 筛选:?status=TODO → 只返回 TODO 状态的任务
- ✅ 筛选:?priority=HIGH → 只返回 HIGH 优先级的任务
- ✅ 排序:?sortBy=dueDate&order=asc → 按截止日期升序
### POST /api/tasks
- ✅ 创建任务(最小字段:title)→ 201
- ✅ 创建任务(完整字段:title + description + priority + dueDate)→ 201
- ❌ 缺少 title → 400
- ❌ title 超过 200 字符 → 400
- ❌ 未认证 → 401
- ✅ 创建任务并关联 category 和 tags → 返回嵌套数据
### GET /api/tasks/:id
- ✅ 获取自己的任务 → 200 + 任务详情
- ❌ 获取不存在的任务 → 404
- ❌ 获取他人的任务 → 403
### PUT /api/tasks/:id
- ✅ 更新自己的任务 → 200
- ❌ 更新他人的任务 → 403
- ❌ 更新不存在的任务 → 404
### DELETE /api/tasks/:id
- ✅ 删除自己的任务 → 200
- ❌ 删除他人的任务 → 403
### PATCH /api/tasks/:id/status
- ✅ 更新任务状态 → 200
- ❌ 无效的状态值 → 400
测试组织方式:按 HTTP 方法分 describe 块,每个 describe 内用嵌套 describe 分"正常场景"和"异常场景"。38.4.4 边界条件与错误处理测试
正常的 CRUD 测试覆盖了"Happy Path"。但真正的 Bug 往往藏在边界条件和错误处理中。让 Claude Code 补充:
补充边界条件测试和错误处理测试:
### 边界条件
1. 分页的极端值:page=0, page=-1, limit=0, limit=1000 → 应该被校验拦截或容错处理
2. title 长度边界:正好 1 字符(合法),正好 200 字符(合法),201 字符(非法)
3. 空请求体:POST /api/tasks 发送 {} → title 缺失错误
4. 超长 description:10000 字符的 description → 性能是否能承受?
### 错误处理
1. 数据库连接断开时 → 500 错误,不要暴露内部信息
2. 无效的 JSON body → 400
3. 错误的 Content-Type → 请求体被忽略或报错
### 并发
1. 两个请求同时删除同一个任务 → 第一个 200,第二个 404
2. 两个请求同时更新同一任务 → 无数据竞争,最后写入者胜
### 安全性
1. SQL 注入尝试:title 中包含 '; DROP TABLE tasks; --
2. XSS 尝试:title 中包含 <script>alert('xss')</script>其中 SQL 注入测试特别重要(虽然 Prisma 默认用参数化查询,但你可能有手写 SQL 的 $queryRaw):
it('should sanitize SQL injection attempt in title', async () => {
const res = await request(app)
.post('/api/tasks')
.set('Authorization', `Bearer ${token}`)
.send({
title: "'; DROP TABLE tasks; --",
});
expect(res.status).toBe(201); // 正常创建,SQL 被转义
// 验证数据仍然存在(表没有被删除)
const tasksRes = await request(app)
.get('/api/tasks')
.set('Authorization', `Bearer ${token}`);
expect(tasksRes.status).toBe(200);
});38.4.5 运行测试、修复失败
写完测试后,执行:
npx vitest runClaude Code 会自动运行测试并在终端显示结果。不可能所有测试一次通过——总有那么几个测试会因为细微的逻辑差异而失败。
典型的失败场景及处理方式:
测试数据冲突:多个测试共用同一个邮箱导致注册重复。修复:每个测试用唯一邮箱(加时间戳或 UUID)。
token 未等待:
createUserAndLogin返回 token 但异步未完成。修复:确保 helper 函数await所有操作。错误信息不匹配:后端返回的
error字段名和测试期望的message不一致。修复:统一错误响应格式。状态码争议:创建资源返回 201 还是 200?注册返回 201 是标准 REST 实践;但如果你用 200,测试会失败。
遇到测试失败,不要手动逐个修复。把失败的输出直接喂给 Claude Code:
运行 vitest run,有 4 个测试失败。看输出:
[粘贴测试失败输出]
分析失败原因并修复代码(改应用代码还是改测试代码,你自己判断)。Claude Code 会分析失败输出,定位问题并修复。如果是应用代码的 bug,修改 controller/middleware;如果是测试代码的假设不对,修改测试断言。这种"失败驱动修复"正是 Agentic Coding 的核心优势——AI 能看到测试输出,理解失败原因,自动修复。
38.4.6 覆盖率和持续改进
测试全部通过后,检查覆盖率:
运行 vitest run --coverage。目标:80%+ 行覆盖率。
如果不够,分析未覆盖的代码路径,补充测试。--coverage 需要先安装 @vitest/coverage-v8(或 @vitest/coverage-istanbul)。
覆盖率报告会告诉你哪些行没被测试覆盖。未覆盖的路径通常是:
- 错误处理分支(try-catch 的 catch 块):手动模拟数据库错误有点麻烦,但值得做
- 工具函数的边界条件:JWT 验证中"签名错误"的分支是否测了?
- 中间件的异常路径:auth middleware 中的 catch 块——如果
verifyAccessToken抛异常,返回 401 的逻辑是否测了?
不需要追求 100% 覆盖率——那往往意味着你在测试"不值得测试的代码"。80%+ 是一个合理的工业级标准。
38.5 本章小结
SmartTodo 的后端开发到此完成。回顾你在本章中完成的工作:
产出清单
- 数据模型:4 个实体(User / Task / Category / Tag)+ 1 个关联表(TaskTag),通过 Prisma ORM 管理
- API 路由:17 个端点,覆盖认证、任务 CRUD、分类管理、标签管理
- 参数校验:所有端点均有 Zod schema 校验,拒绝无效输入
- 分页与筛选:Task 列表支持分页(page/limit)、多条件筛选(status/priority/categoryId)和排序
- 认证系统:JWT access token + refresh token 双 token 机制,token 版本号用于失效管理
- 授权系统:多租户数据隔离,所有资源操作验证所有权,跨资源引用安全检查
- 安全防护:bcrypt 密码哈希、限流保护、统一错误信息防用户枚举、SQL 注入防护
- 测试套件:60+ 测试用例,覆盖正常场景、异常场景、边界条件、安全性,行覆盖率 80%+
协作模式复盘
本章展示了后端开发中与 Claude Code 协作的模式:
先分析再写代码:数据库设计从需求分析开始,Claude Code 先输出分析再生成 schema——你审查的是"设计决策"而非"代码语法"。
按依赖关系推进:
lib/prisma.ts → utils → middleware → validators → controllers → routes → app.ts → index.ts——每个文件创建时,它的依赖已经就绪,Claude Code 读取上下文后生成的代码风格一致。逐文件审查:不批量 Accept。每个文件创建后在 Diff 视图中审查:数据库查询的 where 条件是否包含 userId、sortBy 是否做了白名单校验、错误信息是否泄露了敏感信息。
即时验证:API 写好立刻用 curl 测试;测试写好立刻用 vitest run 验证——错误发现越早,修复成本越低。
安全第一:Claude Code 写业务逻辑很快,但它不天然理解你的安全要求。安全性审查是你的责任——认证、授权、输入校验、密码安全,每一样都不能跳过。
下一章预告
后端 API 已经就绪,接下来是第 39 章——前端开发。我们将用 Claude Code 搭建 SmartTodo 的 React 前端:组件架构设计、页面实现、状态管理、以及与后端 API 的联调。有了后端 API 的坚实基础,前端的开发会更加顺畅——你知道每个端点返回什么数据,分页和筛选的参数已经定义好了,剩下的"只是"把界面搭建起来。
提示:在进入第 39 章之前,确保你已经:
- 运行
npx vitest run确认所有测试通过- 用 curl 或 Postman 手动测试了所有 API 端点
- 确认 Prisma Studio 中能看到种子数据
- 把当前进度提交为 git commit,推荐信息:"feat: SmartTodo 后端开发完成(数据模型、API 路由、认证授权、测试)"