Skip to content
Published at:

第 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 会先输出类似这样的分析:

markdown
## 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 分钟审视:

  1. 枚举值是否够用? 优先级只有 4 档够不够?要不要加 ARCHIVED 表示归档任务?
  2. 级联删除是否安全? User 删除级联删除所有数据——产品那边确认了这个行为吗?
  3. 索引是否过度? 开发阶段加太多索引反而拖慢写入——可以先只加最关键的,上线后根据慢查询再补。

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
// 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 会执行:

bash
npx prisma migrate dev --name init

这一步会生成 prisma/migrations/ 目录下的迁移 SQL 文件,并创建 SQLite 数据库文件。Claude Code 会自动执行命令并输出结果,你在终端中就能看到迁移是否成功。

验证方式:

bash
npx prisma studio

Prisma Studio 会在浏览器中打开一个可视化的数据库管理界面。你可以直观地看到所有表的结构和关系——比在终端里敲 SQL 快得多。

38.1.5 编写种子数据

空数据库对开发测试不友好。让 Claude Code 生成种子数据脚本:

创建 prisma/seed.ts,插入测试数据:
- 2 个测试用户(test@example.com / demo@example.com)
- 每个用户 5 个任务,覆盖不同的优先级和状态
- 每个用户 3 个分类(工作、个人、学习)
- 每个用户 4 个标签(紧急、重要、待讨论、低优先级)
- 任务和标签的关联(随机分配)

密码用 bcrypt 哈希,一个固定的测试密码。

Claude Code 会生成类似这样的 seed 脚本:

typescript
// 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:

json
{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  }
}

执行种子数据:

bash
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 输出的路由表:

markdown
## 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

有了目录结构和路由表,接下来按依赖关系逐模块推进。正确的顺序是:

  1. lib/prisma.ts — Prisma client 单例(所有模块的依赖)
  2. utils/jwt.ts + utils/password.ts — 工具函数
  3. middleware/auth.ts + middleware/errorHandler.ts — 中间件
  4. validators/* — 参数校验 schema
  5. controllers/auth.tsroutes/auth.ts
  6. controllers/task.tsroutes/task.ts
  7. controllers/category.tsroutes/category.ts
  8. controllers/tag.tsroutes/tag.ts
  9. routes/index.ts — 汇总挂载
  10. app.tsindex.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 token

Claude Code 会逐文件创建。其中 validators/auth.ts 的 Zod schema 是输入安全的第一道防线——你必须仔细审查:

typescript
// 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 时,你会看到类似这样的代码:

typescript
// 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 代码时的重点

  1. where 条件中必须包含 userId:这是多租户隔离的核心。如果漏了,用户就能看到其他人的数据。你应该在审查时主动检查每个查询方法的 where 条件。

  2. include 的字段选择include 默认返回关联对象的所有字段。对于 Tag 的嵌套查询,使用了 include: { tag: { select: {...} } } 只取需要的字段——这是好习惯。

  3. 分页参数的安全边界:limit 是否有上限(max 100)?如果没有限制,攻击者可以传 ?limit=9999999 拖慢数据库。

  4. 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 逐条验证:

bash
# 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 在上一节创建):

typescript
// 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 会给出分析:

markdown
### 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 中,我们已经做了所有权检查:

typescript
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 会逐项分析并指出漏洞——比如创建任务时附带的 categoryIdtagIds 应该验证它们属于当前用户:

typescript
// 创建任务时验证 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 是最关键的文件——它负责在每个测试套件运行前准备一个干净的数据库:

typescript
// 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 → 401

Claude Code 会生成约 200-300 行的测试文件。一个典型的测试用例看起来像这样:

typescript
// 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('邮箱或密码错误');
  });
});

审查测试代码时关注:

  1. 测试隔离:一个测试的副作用是否会影响其他测试?beforeAll 中的注册是否会在每个 describe 块之前重置?
  2. 断言精度expect(res.body.error).toBe('邮箱或密码错误') 这样的精确断言比 expect(res.status).toBe(401) 更有价值——它验证了安全策略(不泄露用户是否存在)。
  3. 不测什么:不要在测试中验证 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):

typescript
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 运行测试、修复失败

写完测试后,执行:

bash
npx vitest run

Claude Code 会自动运行测试并在终端显示结果。不可能所有测试一次通过——总有那么几个测试会因为细微的逻辑差异而失败。

典型的失败场景及处理方式:

  1. 测试数据冲突:多个测试共用同一个邮箱导致注册重复。修复:每个测试用唯一邮箱(加时间戳或 UUID)。

  2. token 未等待createUserAndLogin 返回 token 但异步未完成。修复:确保 helper 函数 await 所有操作。

  3. 错误信息不匹配:后端返回的 error 字段名和测试期望的 message 不一致。修复:统一错误响应格式。

  4. 状态码争议:创建资源返回 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 协作的模式:

  1. 先分析再写代码:数据库设计从需求分析开始,Claude Code 先输出分析再生成 schema——你审查的是"设计决策"而非"代码语法"。

  2. 按依赖关系推进lib/prisma.ts → utils → middleware → validators → controllers → routes → app.ts → index.ts——每个文件创建时,它的依赖已经就绪,Claude Code 读取上下文后生成的代码风格一致。

  3. 逐文件审查:不批量 Accept。每个文件创建后在 Diff 视图中审查:数据库查询的 where 条件是否包含 userId、sortBy 是否做了白名单校验、错误信息是否泄露了敏感信息。

  4. 即时验证:API 写好立刻用 curl 测试;测试写好立刻用 vitest run 验证——错误发现越早,修复成本越低。

  5. 安全第一:Claude Code 写业务逻辑很快,但它不天然理解你的安全要求。安全性审查是你的责任——认证、授权、输入校验、密码安全,每一样都不能跳过。

下一章预告

后端 API 已经就绪,接下来是第 39 章——前端开发。我们将用 Claude Code 搭建 SmartTodo 的 React 前端:组件架构设计、页面实现、状态管理、以及与后端 API 的联调。有了后端 API 的坚实基础,前端的开发会更加顺畅——你知道每个端点返回什么数据,分页和筛选的参数已经定义好了,剩下的"只是"把界面搭建起来。

提示:在进入第 39 章之前,确保你已经:

  • 运行 npx vitest run 确认所有测试通过
  • 用 curl 或 Postman 手动测试了所有 API 端点
  • 确认 Prisma Studio 中能看到种子数据
  • 把当前进度提交为 git commit,推荐信息:"feat: SmartTodo 后端开发完成(数据模型、API 路由、认证授权、测试)"