CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
后端入门实战/课时/综合练习
RawBlame
课时目录12 节

后端入门实战

  1. 01后端入门概览
  2. 02Git 与托管
  3. 03语言选型
  4. 04PostgreSQL
  5. 05数据库进阶
  6. 06Redis 与缓存
  7. 07REST API
  8. 08认证
  9. 09ORM
  10. 10单元测试
  11. 11集成与功能测试
  12. 12综合练习

综合练习

Todo API 小项目。

40 分钟

综合练习

这一课你将能做到

  1. 从零实现一个带注册登录的笔记服务 NoteAPI:PostgreSQL 持久化 + Prisma ORM + Redis 旁路缓存 + JWT 鉴权 + 集成测试,全部代码可运行
  2. 按路由/中间件/服务分层组织代码,说清每一层的职责边界
  3. 用 curl 完成端到端验收,并用一份检查清单向面试官讲出项目的设计取舍

为什么要学

前 11 课的知识是「零件」,这一课是总装。面试官判断真实水平的标准题就是「介绍一个你完整做过的项目」——不是 CRUD demo 的 API 罗列,而是:数据怎么建模、缓存什么时候失效、鉴权链路怎么走、测试怎么保证改了不出事。亲手把 12 课拧成一个服务,这些答案才会长在你身上。

人话:本课带你做出简历上第一个能讲 15 分钟的后端项目。做完它,你就具备了初级后端岗「独立交付一个小服务」的完整能力。

先建立图像

项目全景与请求流向:

note-api/
├── prisma/schema.prisma        # 数据模型(第 9 课)
├── .env                        # 密钥与连接串(第 2 课:绝不进 Git)
├── src/
│   ├── server.js               # 启动入口
│   ├── app.js                  # Express 应用(导出供测试,第 11 课)
│   ├── config.js               # 环境变量唯一出口
│   ├── middleware/auth.js      # JWT 鉴权中间件(第 8 课)
│   ├── routes/auth.js          # 注册/登录
│   ├── routes/notes.js         # 笔记 CRUD
│   └── services/cache.js       # Redis 封装(第 6 课)
└── tests/api.test.js           # 集成测试(supertest)

请求流向:
curl ─▶ auth 中间件验签 ─▶ 路由匹配 ─▶ 业务校验 ─▶ 缓存?(命中即返回)
                                              └▶ Prisma ─▶ PostgreSQL

跟着做

步骤 1:初始化项目与基础设施

mkdir note-api && cd note-api && npm init -y
npm install express dotenv bcryptjs jsonwebtoken @prisma/client redis
npm install -D prisma vitest supertest

# 起依赖的容器(第 4、6 课的方式)
docker run --name pg-practice -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
docker run --name redis-practice -p 6379:6379 -d redis:7

.env(第 8 课纪律:密钥走环境变量):

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/practicedb"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="local-dev-secret-please-change"
CACHE_TTL=60
PORT=3000

.gitignore:node_modules/、.env。

步骤 2:数据模型与迁移

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id           Int      @id @default(autoincrement())
  username     String   @unique
  passwordHash String   @map("password_hash")
  notes        Note[]
  createdAt    DateTime @default(now()) @map("created_at")

  @@map("users")
}

model Note {
  id        Int      @id @default(autoincrement())
  title     String
  content   String   @default("")
  pinned    Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  Int      @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@index([authorId, updatedAt])   // 列表页的过滤+排序路径
  @@map("notes")
}

建库并迁移:

docker exec pg-practice psql -U postgres -c "CREATE DATABASE practicedb"
npx prisma migrate dev --name init

步骤 3:配置、缓存服务、鉴权中间件

// src/config.js —— 环境变量唯一出口,其他文件不直接碰 process.env
require('dotenv').config();

module.exports = {
  port: Number(process.env.PORT || 3000),
  jwtSecret: process.env.JWT_SECRET,
  redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
  cacheTtl: Number(process.env.CACHE_TTL || 60),
};
// src/services/cache.js —— Redis 薄封装:业务层不感知 redis 客户端细节
const { createClient } = require('redis');
const { redisUrl, cacheTtl } = require('../config');

let client;
async function getRedis() {
  if (!client) {
    client = createClient({ url: redisUrl });
    client.on('error', (e) => console.error('[redis]', e.message));
    await client.connect();
  }
  return client;
}

async function getCache(key) {
  const raw = await (await getRedis()).get(key);
  return raw === null ? null : JSON.parse(raw);
}

async function setCache(key, data) {
  // TTL 必设 + 随机抖动,防雪崩(第 6 课)
  const ttl = cacheTtl + Math.floor(Math.random() * 15);
  await (await getRedis()).set(key, JSON.stringify(data), { EX: ttl });
}

async function delCache(key) {
  await (await getRedis()).del(key);
}

module.exports = { getCache, setCache, delCache };
// src/middleware/auth.js —— 认证(401)只做一件事,权限(403)另起一层
const jwt = require('jsonwebtoken');
const { jwtSecret } = require('../config');

module.exports = function auth(req, res, next) {
  const header = req.headers.authorization || '';
  if (!header.startsWith('Bearer ')) return res.status(401).json({ error: '未登录' });
  try {
    req.user = jwt.verify(header.slice(7), jwtSecret);   // { sub: username }
    next();
  } catch {
    res.status(401).json({ error: 'token 无效或已过期' });
  }
};

步骤 4:认证路由

// src/routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { PrismaClient } = require('@prisma/client');
const { jwtSecret } = require('../config');

const prisma = new PrismaClient();
const router = express.Router();

router.post('/register', async (req, res, next) => {
  try {
    const { username, password } = req.body || {};
    if (!username?.trim() || !password || password.length < 8) {
      return res.status(400).json({ error: '用户名必填,密码至少 8 位' });
    }
    const passwordHash = await bcrypt.hash(password, 10);
    await prisma.user.create({ data: { username: username.trim(), passwordHash } });
    res.status(201).json({ message: '注册成功' });
  } catch (e) {
    if (e.code === 'P2002') return res.status(409).json({ error: '用户名已存在' });
    next(e);   // 其他错误交给全局兜底
  }
});

router.post('/login', async (req, res, next) => {
  try {
    const { username, password } = req.body || {};
    const user = await prisma.user.findUnique({ where: { username: username || '' } });
    const ok = user && (await bcrypt.compare(password || '', user.passwordHash));
    if (!ok) return res.status(401).json({ error: '用户名或密码错误' });   // 不透露是哪个错
    const token = jwt.sign({ sub: user.username }, jwtSecret, { expiresIn: '2h' });
    res.json({ token });
  } catch (e) { next(e); }
});

module.exports = router;

步骤 5:笔记路由(缓存与数据在这里汇合)

// src/routes/notes.js
const express = require('express');
const auth = require('../middleware/auth');
const { PrismaClient } = require('@prisma/client');
const { getCache, setCache, delCache } = require('../services/cache');

const prisma = new PrismaClient();
const router = express.Router();
router.use(auth);                       // 本路由下所有接口都要登录

const listKey = (username) => `notes:${username}`;

// 列表:旁路缓存读路径
router.get('/', async (req, res, next) => {
  try {
    const key = listKey(req.user.sub);
    const cached = await getCache(key);
    if (cached) return res.json({ ...cached, fromCache: true });

    const notes = await prisma.note.findMany({
      where: { author: { username: req.user.sub } },
      orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
    });
    const payload = { data: notes, total: notes.length };
    await setCache(key, payload);
    res.json(payload);
  } catch (e) { next(e); }
});

router.post('/', async (req, res, next) => {
  try {
    const { title, content, pinned } = req.body || {};
    if (!title?.trim()) return res.status(400).json({ error: 'title 必填' });
    const author = await prisma.user.findUnique({ where: { username: req.user.sub } });
    const note = await prisma.note.create({
      data: { title: title.trim(), content: content || '', pinned: Boolean(pinned), authorId: author.id },
    });
    await delCache(listKey(req.user.sub));   // 写后删缓存(第 6 课顺序:先更库再删缓存)
    res.status(201).json(note);
  } catch (e) { next(e); }
});

router.patch('/:id', async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    const { title, content, pinned } = req.body || {};
    if (title !== undefined && !title.trim()) return res.status(400).json({ error: 'title 不能为空' });

    // 授权长在查询里:WHERE id AND authorId,不存在或不是你的统一 404(不透露存在性)
    const author = await prisma.user.findUnique({ where: { username: req.user.sub } });
    const updated = await prisma.note.updateMany({
      where: { id, authorId: author.id },
      data: {
        ...(title !== undefined && { title: title.trim() }),
        ...(content !== undefined && { content }),
        ...(pinned !== undefined && { pinned }),
      },
    });
    if (updated.count === 0) return res.status(404).json({ error: '笔记不存在' });

    const note = await prisma.note.findUnique({ where: { id } });   // updateMany 不返回行,回查一次
    await delCache(listKey(req.user.sub));
    res.json(note);
  } catch (e) { next(e); }
});

router.delete('/:id', async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    const author = await prisma.user.findUnique({ where: { username: req.user.sub } });
    const deleted = await prisma.note.deleteMany({ where: { id, authorId: author.id } });
    if (deleted.count === 0) return res.status(404).json({ error: '笔记不存在' });
    await delCache(listKey(req.user.sub));
    res.status(204).end();
  } catch (e) { next(e); }
});

module.exports = router;

步骤 6:组装 app 与启动入口

// src/app.js —— 路由 + 全局错误兜底(导出 app 供测试)
const express = require('express');
const authRouter = require('./routes/auth');
const notesRouter = require('./routes/notes');

function createApp() {
  const app = express();
  app.use(express.json());

  app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
  app.use('/api/auth', authRouter);
  app.use('/api/notes', notesRouter);

  app.use((req, res) => res.status(404).json({ error: '接口不存在' }));
  // 统一错误处理:4 个参数的签名是 Express 识别错误中间件的依据
  app.use((err, req, res, next) => {
    console.error('[error]', err.message);
    res.status(500).json({ error: '服务器内部错误' });   // 内部细节不外泄
  });
  return app;
}

module.exports = createApp;
// src/server.js
const createApp = require('./app');
const { port } = require('./config');

createApp().listen(port, () => console.log(`NoteAPI: http://localhost:${port}`));

步骤 7:端到端验收(curl 全流程)

node src/server.js
# ① 健康检查
curl -s http://localhost:3000/api/health

# ② 注册 → 登录
curl -s -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"password123"}'
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"password123"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).token")

# ③ 创建两条笔记 → 列表(第一次 miss,第二次命中缓存)
curl -s -X POST http://localhost:3000/api/notes -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"title":"项目验收","content":"跑通了","pinned":true}'
curl -s http://localhost:3000/api/notes -H "Authorization: Bearer $TOKEN" | node -pe "JSON.parse(require('fs').readFileSync(0)).fromCache"
# false(第一次)
curl -s http://localhost:3000/api/notes -H "Authorization: Bearer $TOKEN" > /dev/null
curl -s http://localhost:3000/api/notes -H "Authorization: Bearer $TOKEN" | node -pe "JSON.parse(require('fs').readFileSync(0)).fromCache"
# true(命中 Redis)

# ④ 安全验证:无 token 401、改别人的笔记 404
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes        # 401

步骤 8:集成测试(第 11 课的成果落地)

// tests/api.test.js
const { describe, it, expect, beforeAll, afterAll } = require('vitest');
const request = require('supertest');
const { execSync } = require('child_process');
const createApp = require('../src/app');

process.env.JWT_SECRET = 'test-secret';
process.env.REDIS_URL = 'redis://localhost:6379';       // 用独立测试实例更佳:redis://localhost:6380
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/practicedb_test';

const app = createApp();
let tokenA, tokenB;

beforeAll(() => {
  // 测试库准备:结构由迁移产生(第 11 课纪律)
  execSync('docker exec pg-practice psql -U postgres -c "DROP DATABASE IF EXISTS practicedb_test" || true');
  execSync('docker exec pg-practice psql -U postgres -c "CREATE DATABASE practicedb_test"');
  execSync('npx prisma migrate deploy');
});

describe('NoteAPI 端到端', () => {
  it('注册 → 登录拿到 token', async () => {
    await request(app).post('/api/auth/register')
      .send({ username: 'alice', password: 'password123' }).expect(201);
    await request(app).post('/api/auth/register')
      .send({ username: 'alice', password: 'password123' }).expect(409);   // 重复注册
    const login = await request(app).post('/api/auth/login')
      .send({ username: 'alice', password: 'password123' }).expect(200);
    tokenA = login.body.token;
  });

  it('创建笔记 → 列表可见 → 第二次走缓存', async () => {
    await request(app).post('/api/notes')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ title: '测试笔记' }).expect(201);

    const list1 = await request(app).get('/api/notes')
      .set('Authorization', `Bearer ${tokenA}`).expect(200);
    expect(list1.body.total).toBe(1);

    const list2 = await request(app).get('/api/notes')
      .set('Authorization', `Bearer ${tokenA}`).expect(200);
    expect(list2.body.fromCache).toBe(true);
  });

  it('Bob 看不到 Alice 的笔记,也删不掉', async () => {
    await request(app).post('/api/auth/register')
      .send({ username: 'bob', password: 'password123' });
    const login = await request(app).post('/api/auth/login')
      .send({ username: 'bob', password: 'password123' });
    tokenB = login.body.token;

    const bobList = await request(app).get('/api/notes')
      .set('Authorization', `Bearer ${tokenB}`).expect(200);
    expect(bobList.body.total).toBe(0);                       // 数据隔离

    await request(app).delete('/api/notes/1')
      .set('Authorization', `Bearer ${tokenB}`).expect(404);  // 越权被拦
  });

  it('未登录访问笔记接口返回 401', async () => {
    await request(app).get('/api/notes').expect(401);
  });
});
npm test   # 4 passed

步骤 9:向面试官讲这个项目(设计取舍清单)

设计点我的做法为什么
分层路由收参数 → 服务/Prisma 干活 → 缓存独立封装路由薄、可测、缓存替换不动业务
缓存一致性先更库再删缓存 + TTL 随机抖动最终一致,代码最简,雪崩风险可控
数据隔离所有查询带 authorId 条件授权与查询合一,杜绝越权漏网
密码与密钥bcrypt 存哈希;secret 走环境变量拖库不泄密,仓库不含凭据
测试集成测试覆盖注册→登录→CRUD→越权→缓存改任何一层,npm test 先报警
错误处理全局错误中间件统一 500内部信息不外泄,前端拿到稳定结构

讲清楚要点

  1. 先跑通再优化:本项目的正确建造顺序是 数据库 → 认证 → CRUD → 缓存 → 测试,每步都可验证。一次性把五样东西全写完再调试,是初学者最常掉进去的坑。
  2. 授权要长在查询里:WHERE authorId = 当前用户 而不是「查出来再判断是不是你的」。后者在并发和分页下一定漏(越权访问是 OWASP 高频项)。
  3. 缓存键设计即缓存设计:notes:{username} 粒度对应「该用户的列表页」;键里带用户身份天然隔离;写操作删除列表键而不是逐条维护,简单可靠。多一个写路径就多一处删键,收口到统一的 service 层,别散落在路由里。
  4. 配置集中一个出口:config.js 是唯一读 process.env 的地方——好处是测试换环境只改一处、新成员一眼看全所有可调项、漏配变量启动即报而不是运行到一半才炸。
  5. 错误处理三层:可预期的业务错(409 用户名重复)当场返回;「没找到」统一 404 不透露存在性;未知错误进全局中间件记日志返回 500。异常永远 next(e) 交给兜底,禁止路由里吞异常。

你可能会懵的地方

  • 为什么缓存只缓存列表,不缓存单条? 缓存粒度跟着「读模式」走:本服务的高频读是「我的列表页」;单条详情读频低、缓存命中收益小还多一份失效负担。换一个产品(详情页为主,如商品详情),答案就反过来——先看访问模式再定缓存什么。

  • PATCH 用 updateMany 判 count,为什么不让 update 直接抛 P2025? update({ where: { id } }) 只按主键定位,授权就得「先查后改」,多一步且存在「先更新了别人的、再返回 404」的越权窗口;updateMany({ where: { id, authorId } }) 把授权压进同一条 UPDATE,count 为 0 统一 404,既安全又不透露存在性。代价是不返回更新后的行,需要回查一次。

  • JWT_SECRET 测试里覆盖了,生产哪里来? 部署平台注入(环境变量/密钥管理),容器编排里用 secret 对象。原则:代码只声明「我需要这个配置」,值永远来自运行环境。

  • 为什么测试用 prisma migrate deploy 而不是 migrate dev? deploy 只应用已有迁移、不交互、不改 schema 文件——CI 与测试库的正确姿势;dev 会生成/合并迁移,属于开发期命令。

  • 这个项目离「可上生产」还差什么? 差的是运维面:容器化(Dockerfile + compose)、CI 流水线、结构化日志、限流、HTTPS/反向代理、refresh token 与登出。这些都是「加了不改变架构」的外围件——先有本课的内核,再逐个补。

错法对照

错法问题正解
所有逻辑写在路由回调里无法复用无法测试路由只做参数校验与编排
缓存键不带用户身份 notes:all用户间数据串台键含身份:notes:{username}
查出全部笔记再在代码里过滤自己的数据量大即拖垮,且越权风险where 条件下推到数据库
测试直连开发库清空数据开发数据被测试洗掉独立测试库 + migrate deploy
异常在路由里 try-catch 后只 console.log接口挂起或返回 200next(e) 交给全局错误中间件
一次写完全部代码再调错误定位困难按步骤推进,每步 curl 验收

卡点急救

  1. prisma migrate dev 连不上库:确认两个容器 docker ps 在跑、.env 连接串与容器端口一致;首次要先 CREATE DATABASE practicedb(步骤 2 已给命令)。
  2. Redis 连接报错但接口全通:注意本项目的缓存失败不应阻断业务——若报错来自测试环境连了被占用的 6379,换成独立端口 6380 的测试实例(第 11 课方案)。
  3. 测试里 token 是 undefined:登录响应结构变了(比如错误返回 401 时取 body.token 为空)。先单独跑注册/登录用例定位,再看是否密码不满足 8 位校验。
  4. fromCache 永远是 false:写操作后忘了 delCache 之外的可能——redis 连的不是同一个实例(Docker 端口映射 vs 本机),或 TTL 恰好过期;redis-cli MONITOR 实时看命令流最快定位。
  5. execSync 报 docker 命令找不到(CI 环境):CI 里不用 execSync 起库,改用平台 services(GitHub Actions 的 services: postgres:),setup 只保留 migrate deploy。

面试常问

  • 介绍一下这个项目,最难的点是什么? 套路:一句话定位(多用户笔记服务:JWT 认证 + PG 持久化 + Redis 缓存 + 集成测试)→ 请求链路 → 1-2 个深挖点(缓存一致性的「先更库再删缓存」及残余窗口;越权防护的查询条件化)。难点讲「取舍过程」而不是堆名词。

  • 为什么先更新数据库再删缓存?反过来会怎样? 反过来「先删缓存后更库」:删除后到提交前的间隙,并发读会把旧值回填,之后长期脏读。正序仍有极小窗口(读旧库值后写入缓存晚于删键),靠 TTL 兜底;要更严可延迟双删或 binlog 订阅刷新。

  • 如果两个用户并发改同一篇笔记,你的服务会发生什么? 当前实现后写覆盖(lost update)。改进:乐观锁——加 version 字段,UPDATE ... WHERE version=? 影响行数为 0 即冲突返回 409 前端重试;或 PG 行锁串行化关键更新。

  • 你的接口怎么防越权?怎么证明? 查询条件强制携带作者 ID(授权融进数据访问),测试里专门有「Bob 删 Alice 的笔记返回 404」用例钉死这个行为——安全逻辑必须有回归测试。

  • 下一步你会怎么演进这个服务? 按价值排序:容器化 + CI(保证每次合并可部署)→ 结构化日志 + 健康探针(可观测)→ refresh token/登出(安全补全)→ 限流(保护)→ 分页与游标(容量准备)。展示的是「优先级思维」。

自测三问

  1. 画出「POST /api/notes」从 curl 到 PostgreSQL 的完整数据流,标出缓存命中与失效发生的位置。
  2. 把笔记列表接口改成分页(?page&size),缓存键和失效逻辑需要怎么调整?说出你方案的一致性弱点。
  3. 不看代码复述:注册接口处理了哪几种错误分支?各自的状态码?

小结一句话

一个合格的后端服务 = 分层清晰的代码 + 查询里长出来的权限 + 有失效策略的缓存 + 钉死行为的测试;把这四件事讲成取舍而不是步骤,你就从「跟着教程做完了」升级成「理解了为什么这么做」。

延伸

  • 本项目完整源码建议推上 GitHub 作为简历项目(commit 按第 2 课规范分步提交,README 写明启动方式)
  • 继续进阶:Docker 容器化 → GitHub Actions CI → 结构化日志(pino)→ 消息队列(第 13 步的方向)
  • 课程完结:回到 /roadmaps/backend-beginner 逐节点勾选掌握状态,查漏补缺。
加载本节练习…
← 上一课集成与功能测试