综合练习
这一课你将能做到
- 从零实现一个带注册登录的笔记服务 NoteAPI:PostgreSQL 持久化 + Prisma ORM + Redis 旁路缓存 + JWT 鉴权 + 集成测试,全部代码可运行
- 按路由/中间件/服务分层组织代码,说清每一层的职责边界
- 用 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 全流程)
# ① 健康检查
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);
});
});
步骤 9:向面试官讲这个项目(设计取舍清单)
| 设计点 | 我的做法 | 为什么 |
|---|
| 分层 | 路由收参数 → 服务/Prisma 干活 → 缓存独立封装 | 路由薄、可测、缓存替换不动业务 |
| 缓存一致性 | 先更库再删缓存 + TTL 随机抖动 | 最终一致,代码最简,雪崩风险可控 |
| 数据隔离 | 所有查询带 authorId 条件 | 授权与查询合一,杜绝越权漏网 |
| 密码与密钥 | bcrypt 存哈希;secret 走环境变量 | 拖库不泄密,仓库不含凭据 |
| 测试 | 集成测试覆盖注册→登录→CRUD→越权→缓存 | 改任何一层,npm test 先报警 |
| 错误处理 | 全局错误中间件统一 500 | 内部信息不外泄,前端拿到稳定结构 |
讲清楚要点
- 先跑通再优化:本项目的正确建造顺序是 数据库 → 认证 → CRUD → 缓存 → 测试,每步都可验证。一次性把五样东西全写完再调试,是初学者最常掉进去的坑。
- 授权要长在查询里:
WHERE authorId = 当前用户 而不是「查出来再判断是不是你的」。后者在并发和分页下一定漏(越权访问是 OWASP 高频项)。
- 缓存键设计即缓存设计:
notes:{username} 粒度对应「该用户的列表页」;键里带用户身份天然隔离;写操作删除列表键而不是逐条维护,简单可靠。多一个写路径就多一处删键,收口到统一的 service 层,别散落在路由里。
- 配置集中一个出口:
config.js 是唯一读 process.env 的地方——好处是测试换环境只改一处、新成员一眼看全所有可调项、漏配变量启动即报而不是运行到一半才炸。
- 错误处理三层:可预期的业务错(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 | 接口挂起或返回 200 | next(e) 交给全局错误中间件 |
| 一次写完全部代码再调 | 错误定位困难 | 按步骤推进,每步 curl 验收 |
卡点急救
prisma migrate dev 连不上库:确认两个容器 docker ps 在跑、.env 连接串与容器端口一致;首次要先 CREATE DATABASE practicedb(步骤 2 已给命令)。
- Redis 连接报错但接口全通:注意本项目的缓存失败不应阻断业务——若报错来自测试环境连了被占用的 6379,换成独立端口 6380 的测试实例(第 11 课方案)。
- 测试里 token 是 undefined:登录响应结构变了(比如错误返回 401 时取 body.token 为空)。先单独跑注册/登录用例定位,再看是否密码不满足 8 位校验。
fromCache 永远是 false:写操作后忘了 delCache 之外的可能——redis 连的不是同一个实例(Docker 端口映射 vs 本机),或 TTL 恰好过期;redis-cli MONITOR 实时看命令流最快定位。
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/登出(安全补全)→ 限流(保护)→ 分页与游标(容量准备)。展示的是「优先级思维」。
自测三问
- 画出「POST /api/notes」从 curl 到 PostgreSQL 的完整数据流,标出缓存命中与失效发生的位置。
- 把笔记列表接口改成分页(?page&size),缓存键和失效逻辑需要怎么调整?说出你方案的一致性弱点。
- 不看代码复述:注册接口处理了哪几种错误分支?各自的状态码?
小结一句话
一个合格的后端服务 = 分层清晰的代码 + 查询里长出来的权限 + 有失效策略的缓存 + 钉死行为的测试;把这四件事讲成取舍而不是步骤,你就从「跟着教程做完了」升级成「理解了为什么这么做」。
延伸
- 本项目完整源码建议推上 GitHub 作为简历项目(commit 按第 2 课规范分步提交,README 写明启动方式)
- 继续进阶:Docker 容器化 → GitHub Actions CI → 结构化日志(pino)→ 消息队列(第 13 步的方向)
- 课程完结:回到 /roadmaps/backend-beginner 逐节点勾选掌握状态,查漏补缺。