十:综合实战——做一个留言板后端

一、回顾与本篇目标

前面九篇,我们一步步学习了后端开发的所有核心知识:

  • 用 Node.js 和 Express 启动 HTTP 服务
  • 定义路由处理不同的 URL 和请求方法
  • 用 MySQL 数据库持久化存储数据
  • 用 bcrypt 哈希加密保护用户密码
  • 用 JWT 实现登录认证

这些知识点就像一堆零件。这一篇,我们要把它们组装成一个完整的项目——一个留言板后端。用户可以注册、登录、发表留言、查看留言列表、删除自己的留言。

这不是一个教学玩具。这是一个结构完整、可以直接部署的小型后端服务。跟下来,你就能说自己“独立完成过一个完整的后端项目”。

二、项目需求分析

动手写代码之前,先把需求列清楚。一个好的习惯是:先想清楚做什么,再动手。

功能列表:

  1. 用户注册:填写用户名、邮箱、密码,注册成功后自动登录。
  2. 用户登录:填写邮箱和密码,登录成功返回 Token。
  3. 获取当前用户信息:通过 Token 识别用户,返回其基本信息。
  4. 发表留言:登录用户可以发表留言,内容不能为空。
  5. 查看留言列表:任何人都可以查看所有留言,显示留言内容、作者、时间。
  6. 删除留言:只有留言的作者本人可以删除自己的留言。

技术选型:

  • 运行环境:Node.js
  • 框架:Express
  • 数据库:MySQL
  • 密码加密:bcrypt
  • 用户认证:JWT

三、数据库设计

留言板需要两张表:用户表留言表

用户表 users

字段名 类型 说明
id INT AUTO_INCREMENT PRIMARY KEY 用户 ID,主键,自增
name VARCHAR(100) NOT NULL 用户名
email VARCHAR(200) NOT NULL UNIQUE 邮箱,唯一
password VARCHAR(255) NOT NULL 密码的 bcrypt 哈希值
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 注册时间

留言表 messages

字段名 类型 说明
id INT AUTO_INCREMENT PRIMARY KEY 留言 ID,主键,自增
content TEXT NOT NULL 留言内容
user_id INT NOT NULL 留言作者的 ID,关联 users 表
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 留言时间

在 MySQL 命令行中执行以下 SQL:

CREATE DATABASE IF NOT EXISTS message_board;
USE message_board;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(200) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE messages (
  id INT AUTO_INCREMENT PRIMARY KEY,
  content TEXT NOT NULL,
  user_id INT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

新知识点:外键。最后一行 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 的意思是:user_id 的值必须在 users 表的 id 列中存在。如果某个用户被删除了,他所有的留言也会自动删除(ON DELETE CASCADE)。这保证了数据的一致性——不会出现一条留言的作者是一个已删除的用户。

四、项目结构

message-board/
├── .env                ← 环境变量(数据库密码、JWT 密钥等)
├── package.json        ← 项目配置
├── app.js              ← 主入口文件
├── db.js               ← 数据库连接
├── authMiddleware.js   ← JWT 认证中间件
└── routes/
    ├── users.js        ← 用户路由(注册、登录、获取当前用户)
    └── messages.js     ← 留言路由(发表、查看、删除)

五、环境准备

第 1 步:初始化项目并安装依赖

mkdir message-board
cd message-board
npm init -y
npm install express mysql2 bcrypt jsonwebtoken dotenv

第 2 步:创建 .env 文件

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=你的数据库密码
DB_NAME=message_board
JWT_SECRET=change-this-to-a-random-string-in-production
PORT=3000

第 3 步:创建数据库连接模块 db.js

// db.js

const mysql = require('mysql2');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
});

module.exports = pool.promise();

六、编写用户路由 routes/users.js

// routes/users.js

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const db = require('../db.js');
const router = express.Router();

// 注册
router.post('/register', async function (request, response) {
  try {
    let { name, email, password } = request.body;

    if (!name || !email || !password) {
      return response.status(400).json({ error: '缺少必填字段:name、email、password' });
    }

    // 检查邮箱是否已注册
    let checkResult = await db.query('SELECT id FROM users WHERE email = ?', [email]);
    if (checkResult[0].length > 0) {
      return response.status(409).json({ error: '该邮箱已被注册' });
    }

    // 哈希加密密码
    let hashedPassword = await bcrypt.hash(password, 10);

    // 存入数据库
    let result = await db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',

[name, email, hashedPassword]

); let userId = result[0].insertId; // 注册成功后直接生成 Token(即“注册即登录”) let token = jwt.sign( { userId: userId, email: email }, process.env.JWT_SECRET, { expiresIn: ‘7d’ } ); response.status(201).json({ message: ‘注册成功’, token: token, user: { id: userId, name: name, email: email } }); } catch (error) { console.error(‘注册失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } }); // 登录 router.post(‘/login’, async function (request, response) { try { let { email, password } = request.body; if (!email || !password) { return response.status(400).json({ error: ‘缺少邮箱或密码’ }); } let result = await db.query(‘SELECT * FROM users WHERE email = ?’, [email]); if (result[0].length === 0) { return response.status(401).json({ error: ‘邮箱或密码错误’ }); } let user = result[0][0]; let isPasswordCorrect = await bcrypt.compare(password, user.password); if (!isPasswordCorrect) { return response.status(401).json({ error: ‘邮箱或密码错误’ }); } let token = jwt.sign( { userId: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: ‘7d’ } ); response.json({ message: ‘登录成功’, token: token, user: { id: user.id, name: user.name, email: user.email } }); } catch (error) { console.error(‘登录失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } }); // 获取当前用户信息(需要登录) router.get(‘/me’, require(‘../authMiddleware.js’), async function (request, response) { try { let result = await db.query( ‘SELECT id, name, email, created_at FROM users WHERE id = ?’,

[request.user.userId]

); if (result[0].length === 0) { return response.status(404).json({ error: ‘用户不存在’ }); } response.json(result[0][0]); } catch (error) { console.error(‘获取用户信息失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } }); module.exports = router;

七、编写认证中间件 authMiddleware.js

// authMiddleware.js

const jwt = require('jsonwebtoken');

function authMiddleware(request, response, next) {
  let authHeader = request.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return response.status(401).json({ error: '未登录,请先登录' });
  }

  let token = authHeader.split(' ')[1];

  try {
    let decoded = jwt.verify(token, process.env.JWT_SECRET);
    request.user = decoded;
    next();
  } catch (error) {
    return response.status(401).json({ error: 'Token 无效或已过期,请重新登录' });
  }
}

module.exports = authMiddleware;

八、编写留言路由 routes/messages.js

// routes/messages.js

const express = require('express');
const db = require('../db.js');
const authMiddleware = require('../authMiddleware.js');
const router = express.Router();

// 获取所有留言(不需要登录)
router.get('/', async function (request, response) {
  try {
    // JOIN 查询:把留言表和用户表关联,获取作者名字
    let result = await db.query(
      `SELECT messages.id, messages.content, messages.created_at,
              users.id AS user_id, users.name AS user_name
       FROM messages
       JOIN users ON messages.user_id = users.id
       ORDER BY messages.created_at DESC`
    );
    response.json(result[0]);
  } catch (error) {
    console.error('获取留言失败:', error.message);
    response.status(500).json({ error: '服务器内部错误' });
  }
});

// 发表留言(需要登录)
router.post('/', authMiddleware, async function (request, response) {
  try {
    let { content } = request.body;

    if (!content || content.trim() === '') {
      return response.status(400).json({ error: '留言内容不能为空' });
    }

    let result = await db.query(
      'INSERT INTO messages (content, user_id) VALUES (?, ?)',

[content.trim(), request.user.userId]

); response.status(201).json({ message: ‘留言发表成功’, messageId: result[0].insertId }); } catch (error) { console.error(‘发表留言失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } }); // 删除留言(只能删除自己的) router.delete(‘/:id’, authMiddleware, async function (request, response) { try { let messageId = Number(request.params.id); // 先查出这条留言的作者是谁 let checkResult = await db.query( ‘SELECT user_id FROM messages WHERE id = ?’, [messageId] ); if (checkResult[0].length === 0) { return response.status(404).json({ error: ‘留言不存在’ }); } let message = checkResult[0][0]; // 检查是不是留言作者本人 if (message.user_id !== request.user.userId) { return response.status(403).json({ error: ‘无权删除他人的留言’ }); } await db.query(‘DELETE FROM messages WHERE id = ?’, [messageId]); response.json({ message: ‘留言已删除’ }); } catch (error) { console.error(‘删除留言失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } }); module.exports = router;

这段代码中有两个新知识点:

  • JOIN 查询JOIN users ON messages.user_id = users.id 把留言表和用户表通过 user_id 关联起来。这样一条查询就能同时拿到留言内容和作者的用户名,不需要先查留言再逐个查作者。这是关系型数据库的核心能力。
  • 权限检查:删除留言时,先查出留言的 user_id,然后和当前登录用户的 ID 对比。如果不一样,返回 403 Forbidden(有登录身份但没有操作权限)。这是后端安全的另一个重要概念——认证是知道你是谁,授权是确定你能做什么。

九、编写主入口 app.js

// app.js

require('dotenv').config();
const express = require('express');
const app = express();

// 中间件
app.use(express.json());

// 路由
app.use('/api/users', require('./routes/users.js'));
app.use('/api/messages', require('./routes/messages.js'));

// 404 兜底
app.use(function (request, response) {
  response.status(404).json({ error: '接口不存在' });
});

// 启动服务
let port = process.env.PORT || 3000;
app.listen(port, function () {
  console.log('留言板后端已启动,端口:' + port);
  console.log('');
  console.log('接口列表:');
  console.log('  POST   /api/users/register   注册');
  console.log('  POST   /api/users/login      登录');
  console.log('  GET    /api/users/me         获取当前用户');
  console.log('  GET    /api/messages         获取留言列表');
  console.log('  POST   /api/messages         发表留言(需登录)');
  console.log('  DELETE /api/messages/:id     删除留言(需本人)');
});

十、完整测试流程

用 Postman 按以下顺序测试全部六个接口:

1. 注册用户 A

POST http://localhost:3000/api/users/register

{
  "name": "张三",
  "email": "zhangsan@example.com",
  "password": "123456"
}

复制返回的 Token,记为 TokenA

2. 注册用户 B

{
  "name": "李四",
  "email": "lisi@example.com",
  "password": "123456"
}

复制返回的 Token,记为 TokenB

3. 用户 A 发表留言

POST http://localhost:3000/api/messages

Headers: Authorization: Bearer TokenA

{
  "content": "大家好,我是张三,这是我第一条留言!"
}

再发一条:

{
  "content": "今天天气真好,适合写代码。"
}

4. 用户 B 发表留言

POST http://localhost:3000/api/messages

Headers: Authorization: Bearer TokenB

{
  "content": "我是李四,很高兴认识大家。"
}

5. 查看留言列表(无需登录)

GET http://localhost:3000/api/messages

返回三条留言,每条都包含作者名字、内容和时间,按时间倒序排列。

6. 用户 A 尝试删除用户 B 的留言(应该失败)

DELETE http://localhost:3000/api/messages/3(假设李四的留言 ID 是 3)

Headers: Authorization: Bearer TokenA

返回 403:“无权删除他人的留言”。

7. 用户 B 删除自己的留言(应该成功)

DELETE http://localhost:3000/api/messages/3

Headers: Authorization: Bearer TokenB

返回“留言已删除”。

8. 不带 Token 发表留言(应该失败)

POST http://localhost:3000/api/messages(不设置 Authorization 头)

返回 401:“未登录,请先登录”。

十一、本篇动手练习

练习 1:增加“编辑留言”功能

新增 PUT /api/messages/:id 接口,允许用户修改自己的留言内容。要求验证:只有作者本人才能编辑。修改后更新 created_at 为当前时间,或者新增一个 updated_at 字段。

练习 2:增加分页功能

修改 GET /api/messages,支持 ?page=1&limit=10 查询参数。使用 SQL 的 LIMITOFFSET 实现分页。返回数据的同时,也返回总页数或总留言数。

练习 3:增加“点赞”功能

新增一张 likes 表(字段:iduser_idmessage_id),实现点赞和取消点赞。一个用户对同一条留言只能点赞一次。获取留言列表时同时返回每条留言的点赞数。

十二、本篇小结

这一篇我们完成了一个完整的留言板后端项目,综合运用了前面九篇学到的知识:

  • 项目初始化:npm init、安装依赖、环境变量配置。
  • 数据库设计:两张表 + 外键约束。
  • 用户系统:注册(bcrypt 加密)、登录(JWT 签发)、Token 认证中间件。
  • CRUD 操作:发表留言、查看留言列表(JOIN 查询)、删除留言。
  • 权限控制:认证(是否登录)和授权(是否有权操作)的区别。403 表示已登录但无权。
  • RESTful 设计:GET 获取、POST 创建、DELETE 删除,资源路径清晰。

这个留言板虽然功能不复杂,但它是你第一个从零搭建的完整后端项目。它具备了生产级后端服务的基本骨架——数据库、用户认证、权限控制、RESTful API。把这个项目吃透,你就可以自信地说自己能用 Node.js 做后端开发了。

下一篇预告

下一篇——《部署上线——让你的后端真正跑在云端》:代码写好了,但只有你电脑上能访问。我们会把它部署到云服务器上,让任何人都能通过公网访问。涉及:云服务器选购、环境搭建、代码上传、进程管理(PM2)、域名配置。这是从“学习阶段”到“上线交付”的最后一步。

后端零基础入门,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容