六维教程

Node.js 博客 API 实战

这篇把前面所有知识串起来,从零搭建一个完整的博客系统后端 API。你会看到真实项目怎么分层组织代码,注册登录、文章、评论、权限控制怎么组合在一起,最后用 Swagger 生成接口文档。

功能规划

先定目标,一个博客后端需要这些接口

模块 功能 接口
用户 注册、登录 POST /api/auth/register、POST /api/auth/login
用户 查看自己的信息 GET /api/users/me
文章 增删改查 POST /api/posts、GET /api/posts、GET /api/posts/:id、PUT /api/posts/:id、DELETE /api/posts/:id
评论 发表、列表 POST /api/posts/:id/comments、GET /api/posts/:id/comments

权限规则,文章和评论只有登录用户能创建,文章只有作者本人能修改和删除。这个项目复用了前面的全部成果,Express 入门见 Node.js Express 入门,数据库和关联查询见 Node.js Prisma ORMNode.js 数据库设计与关联,认证见 Node.js JWT 身份认证

项目结构

分层架构是真实项目的标准做法,每层只干一件事

blog-api/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── app.js             入口,装配中间件和路由
│   ├── server.js          启动服务器
│   ├── routes/            路由定义,只做 URL 分发
│   │   ├── auth.routes.js
│   │   ├── user.routes.js
│   │   ├── post.routes.js
│   │   └── comment.routes.js
│   ├── controllers/       控制器,处理请求和响应
│   │   ├── auth.controller.js
│   │   ├── user.controller.js
│   │   ├── post.controller.js
│   │   └── comment.controller.js
│   ├── services/          业务逻辑,复杂的规则放这里
│   ├── middlewares/       中间件
│   │   ├── auth.middleware.js
│   │   └── error.middleware.js
│   └── utils/
│       └── response.js    统一响应格式
├── .env
└── package.json

依赖清单,npm install 一次装齐

npm install express @prisma/client bcryptjs jsonwebtoken cors
npm install -D prisma

生产依赖和开发依赖的区别见 Node.js npm 入门

数据模型

// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  username  String   @unique
  password  String
  posts     Post[]
  comments  Comment[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String
  author    User      @relation(fields: [authorId], references: [id])
  authorId  Int
  comments  Comment[]
  createdAt DateTime  @default(now())
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  post      Post     @relation(fields: [postId], references: [id])
  postId    Int
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
}

模型字段的含义和关联关系写法,回顾 Node.js 数据库设计与关联。执行迁移建表

npx prisma migrate dev --name init

统一响应与错误处理

所有接口返回固定结构 { code, data, message },客户端拿到 code 就知道成功失败,设计规范见 Node.js RESTful API 设计

// src/utils/response.js
function success(res, data, message = 'ok', code = 0) {
  res.json({ code, data, message })
}

function fail(res, status, message, code = status) {
  res.status(status).json({ code, data: null, message })
}

module.exports = { success, fail }

全局错误处理中间件,在 app.js 里注册到所有路由之后

// src/middlewares/error.middleware.js
function notFound(req, res) {
  res.status(404).json({ code: 404, data: null, message: '接口不存在' })
}

function errorHandler(err, req, res, next) {
  console.error(err)
  res.status(500).json({ code: 500, data: null, message: '服务器内部错误' })
}

module.exports = { notFound, errorHandler }

错误处理中间件的四个参数和注册顺序,回顾 Node.js Express 中间件

认证模块

注册时用 bcryptjs 哈希密码,绝不能存明文。登录成功签发 JWT,有效期 7 天。

// src/controllers/auth.controller.js
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { success, fail } = require('../utils/response')

const prisma = new PrismaClient()
const JWT_SECRET = process.env.JWT_SECRET

async function register(req, res) {
  const { username, password } = req.body
  if (!username || !password) {
    return fail(res, 400, '用户名和密码不能为空')
  }
  const exists = await prisma.user.findUnique({ where: { username } })
  if (exists) {
    return fail(res, 400, '用户名已存在')
  }
  const hash = await bcrypt.hash(password, 10)
  const user = await prisma.user.create({
    data: { username, password: hash }
  })
  success(res, { id: user.id, username: user.username }, '注册成功')
}

async function login(req, res) {
  const { username, password } = req.body
  const user = await prisma.user.findUnique({ where: { username } })
  if (!user) {
    return fail(res, 400, '用户名或密码错误')
  }
  const ok = await bcrypt.compare(password, user.password)
  if (!ok) {
    return fail(res, 400, '用户名或密码错误')
  }
  const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' })
  success(res, { token }, '登录成功')
}

module.exports = { register, login }

密码哈希和 JWT 签发的原理,回顾 Node.js JWT 身份认证

鉴权中间件,拦截所有需要登录的接口

// src/middlewares/auth.middleware.js
const jwt = require('jsonwebtoken')

function authMiddleware(req, res, next) {
  const header = req.headers.authorization
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ code: 401, data: null, message: '请先登录' })
  }
  const token = header.slice(7)
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET)
    req.userId = payload.userId
    next()
  } catch (err) {
    res.status(401).json({ code: 401, data: null, message: '登录已过期' })
  }
}

module.exports = authMiddleware

客户端请求时在请求头带上 Authorization: Bearer <token>,后端就能识别是谁。

文章模块

文章列表要带出作者用户名,用 include 预加载关联数据,同时实现分页。

// src/controllers/post.controller.js
const { PrismaClient } = require('@prisma/client')
const { success, fail } = require('../utils/response')

const prisma = new PrismaClient()

async function list(req, res) {
  const page = Number(req.query.page) || 1
  const pageSize = Number(req.query.pageSize) || 10
  const posts = await prisma.post.findMany({
    skip: (page - 1) * pageSize,
    take: pageSize,
    orderBy: { createdAt: 'desc' },
    include: { author: { select: { id: true, username: true } } }
  })
  const total = await prisma.post.count()
  success(res, { list: posts, total })
}

async function create(req, res) {
  const { title, content } = req.body
  if (!title || !content) {
    return fail(res, 400, '标题和内容不能为空')
  }
  const post = await prisma.post.create({
    data: { title, content, authorId: req.userId }
  })
  success(res, post, '发布成功')
}

async function update(req, res) {
  const id = Number(req.params.id)
  const post = await prisma.post.findUnique({ where: { id } })
  if (!post) {
    return fail(res, 404, '文章不存在')
  }
  if (post.authorId !== req.userId) {
    return fail(res, 403, '只能修改自己的文章')
  }
  const updated = await prisma.post.update({
    where: { id },
    data: { title: req.body.title, content: req.body.content }
  })
  success(res, updated, '更新成功')
}

async function remove(req, res) {
  const id = Number(req.params.id)
  const post = await prisma.post.findUnique({ where: { id } })
  if (!post) {
    return fail(res, 404, '文章不存在')
  }
  if (post.authorId !== req.userId) {
    return fail(res, 403, '只能删除自己的文章')
  }
  await prisma.post.delete({ where: { id } })
  success(res, null, '删除成功')
}

module.exports = { list, create, update, remove }

权限判断的逻辑在 update 和 remove 里,比较 post.authorIdreq.userId,不相等返回 403,这是接口级权限控制的常见写法。

路由与入口

路由文件只做分发,业务全部在控制器里

// src/routes/post.routes.js
const express = require('express')
const authMiddleware = require('../middlewares/auth.middleware')
const postController = require('../controllers/post.controller')

const router = express.Router()

router.get('/', postController.list)
router.post('/', authMiddleware, postController.create)
router.put('/:id', authMiddleware, postController.update)
router.delete('/:id', authMiddleware, postController.remove)

module.exports = router

评论模块结构相同,创建评论时从 URL 取 postId,从中间件取 userId,这里不再重复贴代码。app.js 把所有模块装到一起

// src/app.js
const express = require('express')
const cors = require('cors')
const authRoutes = require('./routes/auth.routes')
const userRoutes = require('./routes/user.routes')
const postRoutes = require('./routes/post.routes')
const commentRoutes = require('./routes/comment.routes')
const { notFound, errorHandler } = require('./middlewares/error.middleware')

const app = express()

app.use(cors())
app.use(express.json())

app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
app.use('/api/posts', postRoutes)
app.use('/api/posts/:id/comments', commentRoutes)

app.use(notFound)
app.use(errorHandler)

module.exports = app
// src/server.js
const app = require('./app')

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`博客 API 已启动,端口 ${PORT}`)
})

API 文档

Swagger 能把接口文档挂在网页上,前端照着文档对接。安装后写一份 OpenAPI 描述文件

npm install swagger-jsdoc swagger-ui-express
// src/swagger.js
const swaggerJsdoc = require('swagger-jsdoc')
const swaggerUi = require('swagger-ui-express')

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: '博客 API',
      version: '1.0.0'
    }
  },
  apis: ['./src/routes/*.js']
}

const specs = swaggerJsdoc(options)

module.exports = { swaggerUi, specs }

在 app.js 里挂载,启动后访问 http://localhost:3000/api-docs 就能看到接口文档

const { swaggerUi, specs } = require('./swagger')
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs))

实践任务

按上面的结构从零搭建完整博客 API,验收标准

  1. 注册两个用户,都能正常登录拿到 token
  2. 用户 A 发布文章,用户 B 尝试修改 A 的文章,接口返回 403
  3. 文章列表接口返回作者用户名,评论接口正常创建和查询
  4. 打开 /api-docs 能看到全部接口文档
  5. 未登录访问发布接口返回 401

写完检查一遍路由顺序和鉴权中间件有没有遗漏,这些是最容易出错的地方。下一篇把项目部署到服务器,让它在公网跑起来,见 Node.js 部署与日志

上一篇
Node.js Web 安全
下一篇
Node.js 部署与日志