简介:这是基于Node.js、Express与MySQL构建的前后端分离宠物用品购物网站毕业设计源码,面向计算机专业毕业生、课程设计学生以及全栈开发入门者,可完整学习后端服务搭建、数据库设计、接口开发及前端交互的电商项目实践。压缩包共669个文件,总大小12.02MB,包含193个js逻辑文件(路由、中间件、业务处理)、39个vue页面组件、51个css样式、28个html页面、1个sql数据库脚本以及5个bat辅助启动脚本,并配有svg、gif、jpg、png等图片素材、字体文件及Markdown说明文档,目录结构完整,解压后可直接部署运行。目前已有95人学习下载,适合作为毕业设计答辩、课程作业或商城项目原型参考。项目覆盖商品浏览、购物车、订单管理、用户注册登录等核心业务模块,同时涉及Node.js异步编程、Express路由与中间件、MySQL数据持久化、RESTful API设计、JWT用户认证、模板引擎渲染、JSON数据交互、支付接口预留以及测试部署思路等关键技术点,并附带启动脚本和数据库初始化文件,帮助读者系统掌握全栈开发流程,便于快速上手和二次扩展。
1. 毕业设计选「宠物用品购物网站」,最怕的不是做不完,而是做完讲不清
毕业设计选「宠物用品购物网站」这个题目,最怕的不是功能做不完,而是做完之后讲不清楚里面的技术点。基于 Node.js + Express + MySQL 的前后端分离实现,恰好压在一个很舒服的位置:功能上有商品浏览、搜索、购物车、下单、订单管理,足够支撑起一份完整的毕业设计或课程设计;技术上没有引入 Redis、消息队列这类难讲清楚的东西,前后端之间的每一次数据交换都能画出明确的流程图。这套源码不追求炫技,它的价值在于让你在两周内复现出一个能跑、能截图、能在答辩时讲明白的电商闭环。正在找设计题目的同学,或者想用 Node.js 栈做项目实践的开发者,都可以用它作为起点。
2. 动手之前:Node.js 版本、MySQL 8.0 初始化和建库的完整细节
2.1 为什么是 Node.js + Express + MySQL,而不是 Spring Boot 或 PHP
选择技术栈的底层逻辑不是「哪个火选哪个」,而是「答辩被追问时你有多大的把握解释清楚」。Java + Spring Boot 在毕业设计里确实主流,但很多同学在答辩时会被问到「Spring 的 Bean 生命周期」「自动配置原理」,这些问题对于只做了 CRUD 的人来说很难答好。PHP 写起来快,但简历和项目呈现上都显得不够新。Node.js + Express 的优势是语言单一:前端 Vue 用的是 JavaScript,后端 Express 也是 JavaScript,你只需要熟练一种语法就能打通前后端。
从架构上讲,这套组合的请求链路非常板书友好:Vue 页面通过 axios 发起 HTTP 请求 → Express 路由层接收 → Controller 处理业务逻辑 → 通过 mysql2 查询数据库 → 返回 JSON 给前端渲染。每一步都有一个明确的「层」,每一层都有对应的目录和文件,这在答辩画架构图时是天然分层的。而且 Express 的中间件机制是必考点——app.use(cors())、app.use(bodyParser.json())、app.use('/api/user', require('./routes/user'))这三行代码摆在黑板上,就能把「洋葱模型」「中间件执行顺序」这些概念串起来,老师在这一点上通常愿意给高分。
2.2 环境搭建:Node.js 版本选择,以及 MySQL 免安装版的血泪经历
先说版本结论。Node.js 装 16 或 18 的 LTS 版本,不要追新装 20 以上的奇数版本。原因很现实:源码包里用到的某个依赖可能在最新版 Node 上有弃用警告,虽然不影响运行,但答辩演示时控制台飘一堆警告也不太好看。MySQL 装 8.0 系列,别下载 5.7,因为 8.0 对 utf8mb4 字符集、JSON 函数、窗口函数的支持更完整,而且现在的教程、踩坑记录基本都是针对 8.0 写的,遇到问题更容易搜到答案。
Windows 上安装 Node.js 没什么好说的,官网下载 LTS 版本一路 Next 即可。MySQL 才是重头戏。如果你下载的是 zip 免安装版,会踩到整个项目里最麻烦的一个坑——没有data目录。第一次启动net start mysql会直接失败,错误日志里写Data Dictionary initialization failed。原因是免安装版不会自动初始化数据目录。我一般这样处理:
# 以管理员身份打开命令行,进入解压后的 MySQL 目录 # 初始化数据目录,这一步会在输出日志里生成一个临时 root 密码 mysqld --initialize --console # 初始化完成后,把 MySQL 注册成 Windows 服务 mysqld --install MySQL # 启动服务 net start MySQLmysqld --initialize --console这条命令会把初始化过程打印到控制台,其中有一行temporary password is generated后面跟着临时密码,第一次登录必须用它,登录后马上改密码。注意,如果你执行了mysqld --initialize却没记下临时密码,那只能删掉 data 目录重新初始化,没有后悔药。我从那以后每次初始化都把控制台输出截图保存。
验证环境是否就绪:
node -v npm -v mysql -u root -p输入密码后能进入mysql>提示符,说明 Node 和 MySQL 两个基础环境都通了。这里有一个容易被忽略的细节:mysql -u root -p后面千万不要直接跟密码(比如-p123456),命令行会提示 warning,说密码暴露在命令行里不安全。
2.3 建库建表:字符集选 utf8mb4 而不是 utf8,以及商品表结构设计
登录 MySQL 后建库。建库时字符集的选择是第一个会实际踩到的坑:
CREATE DATABASE pet_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pet_shop; -- 用户表 CREATE TABLE `user` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(50) UNIQUE NOT NULL, `password` VARCHAR(255) NOT NULL COMMENT '存的是 bcrypt 哈希,不是明文', `phone` VARCHAR(20), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 CREATE TABLE `product` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(100) NOT NULL, `category` VARCHAR(50) NOT NULL COMMENT '猫粮、狗粮、玩具、日用品', `price` DECIMAL(10,2) NOT NULL, `stock` INT DEFAULT 0, `image` VARCHAR(255) COMMENT '商品主图路径', `description` TEXT COMMENT '详情描述,支持中文和表情符号' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么用 utf8mb4 而不是 utf8?因为 MySQL 的 utf8 实际最多存 3 字节字符,而宠物用品详情的文案里经常有 🐱🐶 这类 emoji,它们是 4 字节的。用 utf8 建表插入 emoji 会直接报错Incorrect string value。这个坑早期我栽过一次,后来建库第一条命令就定死 utf8mb4。
表结构设计上有一个值得在答辩时讲的点:price为什么用DECIMAL(10,2)而不是FLOAT。因为浮点数在 MySQL 里存储会丢失精度,比如 19.99 在 FLOAT 存储后查出来可能是 19.989999。DECIMAL 是定点数,按字符串存储,不会丢精度。购物网站的金额计算涉及钱,精度问题在答辩时是必考加分项。
提示:源码包自带的
pet_shop.sql里已经建好了全部表,包括购物车表cart、订单表orders、订单明细表order_items。用mysql -u root -p pet_shop < pet_shop.sql导入之前,先确认数据库字符集是 utf8mb4,否则导入后表的中文会变成乱码。
3. Express 后端实现:路由分层、JWT 登录和商品分页的边界坑
3.1 入口文件与目录分层:中间件注册顺序为什么不能乱
拿到源码包后,先把后端目录结构看清楚。这个项目的后端分层思路是标准的 Express 工程结构:
pet-shop-server/ ├─ app.js # Express 入口 ├─ config/ │ └─ db.js # MySQL 连接配置(mysql2) ├─ routes/ │ ├─ user.js # 注册、登录路由 │ ├─ product.js # 商品列表、搜索、详情路由 │ └─ order.js # 下单、订单列表路由 ├─ controllers/ │ ├─ userController.js # 业务逻辑:查库、加密、签发 token │ ├─ productController.js │ └─ orderController.js ├─ middleware/ │ └─ auth.js # JWT 鉴权中间件 └─ package.jsonroutes 只负责定义 URL 路径,真正的逻辑在 controllers 里。这个分层习惯极其重要——如果所有代码都写在 app.js 里,前期开发快但后期调试会非常痛苦。入口文件app.js的中间件注册顺序有一点讲究:
const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const app = express(); // 中间件注册顺序:CORS → bodyParser → 路由 → 404 兜底 app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use('/api/user', require('./routes/user')); app.use('/api/product', require('./routes/product')); app.use('/api/order', require('./routes/order')); // 404 兜底必须放在所有路由之后 app.use((req, res) => { res.status(404).json({ message: '接口不存在' }); }); app.listen(3000, () => { console.log('server running at http://localhost:3000'); });这段代码里有三个细节要在答辩时能解释。第一,cors()必须放在最前面,因为跨域校验发生在请求进入业务处理之前,如果写成app.use(cors())之前还有别的中间件,浏览器可能已经拦截了响应。第二,bodyParser.urlencoded({ extended: true })不能省,有些表单提交的 Content-Type 是application/x-www-form-urlencoded,只配了json()解析器时请求体会变成空对象{},这是新手排查半天都找不到原因的经典问题。第三,404 兜底路由必须在所有业务路由注册之后,否则它会先捕获所有请求。
3.2 用 JWT 做登录鉴权:token 里该放什么、过期时间该设多长
前后端分离架构下,登录状态不依赖 session,因为前端和后端可能不在同一台服务器上,session 存在服务端内存里无法共享。标准做法是 JWT:用户登录成功后,后端用密钥对用户 id 签名生成一个 token 返回给前端,前端存到 localStorage,每次请求在 Header 里带Authorization: Bearer <token>。
// middleware/auth.js const jwt = require('jsonwebtoken'); const SECRET = 'pet-shop-secret-key'; // 实际项目放 .env module.exports = function auth(req, res, next) { const header = req.headers.authorization; if (!header) { return res.status(401).json({ message: '未登录,请先登录' }); } // 格式约定:'Bearer ' + token,split 后取第二部分 const token = header.split(' ')[1]; try { const payload = jwt.verify(token, SECRET); req.userId = payload.id; // 最关键:把用户 id 挂到 req 对象上,后面的路由直接用 next(); } catch (err) { return res.status(401).json({ message: 'token 无效或已过期' }); } };这段代码是鉴权中间件的标准模板。jwt.verify会同时校验签名和过期时间,token 被篡改或过期都只会走到 catch 分支返回 401。登录接口里签发 token 的逻辑:
// controllers/userController.js(节选) const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const db = require('../config/db'); exports.login = (req, res) => { const { username, password } = req.body; // 参数校验:空值直接返回 400,不要在 SQL 层面兜 if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } // 参数占位符 ? 防止 SQL 注入 db.query('SELECT * FROM user WHERE username = ?', [username], (err, results) => { if (err) return res.status(500).json({ message: '服务器内部错误' }); if (results.length === 0) { return res.status(400).json({ message: '用户不存在' }); } const user = results[0]; // bcrypt.compareSync 返回布尔值 const isMatch = bcrypt.compareSync(password, user.password); if (!isMatch) { return res.status(400).json({ message: '密码错误' }); } // 签发 token:有效期 7 天 const token = jwt.sign( { id: user.id, username: user.username }, SECRET, { expiresIn: '7d' } ); res.json({ token, username: user.username, message: '登录成功' }); }); };JWT 的过期时间设 7 天,这个选择值得在答辩时解释。管理后台系统通常设 30 分钟,因为操作敏感;电商前端用户不可能每半小时重新登录一次,7 天是「安全性和用户体验的折中」。你能说出这个理由,老师会觉得你不是随便选了一个数字。另外注意bcrypt.compareSync是同步方法,在真实高并发项目里应该用异步的bcrypt.compare,但毕业设计里同步方法足够,而且答辩时可以主动提起「我知道有异步版本,在高并发下性能更好」,这是一个主动展示知识边界的好机会。
3.3 商品分页接口:total 计数、offset 计算、LIMIT 绑定
商品列表是所有用户都能访问的接口,不需要 token。分页接口有两个容易踩的坑,源码里已经处理好了,但你需要知道原因。
第一个坑是分页偏移量的计算。前端传page和pageSize,后端算offset = (page - 1) * pageSize。很多新手直接把page当 offset 用,首页能出数据,翻到第二页就重复了。
第二个坑是 total 字段。分页组件要算总页数,就必须知道总记录数。所以接口返回里必须带total,否则前端只能靠「这页是否还有数据」来猜测有没有下一页。源码里的实现:
// controllers/productController.js(节选) exports.list = (req, res) => { const page = parseInt(req.query.page) || 1; const pageSize = parseInt(req.query.pageSize) || 10; const category = req.query.category || ''; // 动态拼接 WHERE 条件,前提是 category 不是用户可随意构造的复杂条件 let where = ''; const params = []; if (category) { where = 'WHERE category = ?'; params.push(category); } // 第一步:查总数 db.query(`SELECT COUNT(*) AS total FROM product ${where}`, params, (err, countResult) => { if (err) return res.status(500).json({ message: '查询失败' }); const total = countResult[0].total; const offset = (page - 1) * pageSize; // 注意:mysql2 旧版本在 LIMIT 子句里用 ? 占位可能会有问题 // 所以这里直接拼接数字,offset 和 pageSize 都已经通过 parseInt 转成了整数 const sql = `SELECT * FROM product ${where} ORDER BY id DESC LIMIT ${offset}, ${pageSize}`; db.query(sql, params, (err, rows) => { if (err) return res.status(500).json({ message: '查询失败' }); res.json({ list: rows, total: total, page: page, pageSize: pageSize }); }); }); };关于 LIMIT 子句的占位符问题,补充一句:mysql2 驱动在 2.x 版本之后已经支持LIMIT ?, ?绑定,如果你用的驱动版本较新,可以写成db.query(sql, [...params, offset, pageSize], cb)的形式。老驱动在 LIMIT 里用占位符可能直接报语法错误,所以源码里选择直接拼接整数。这个细节在答辩时被问到时,你可以回答:「为了兼容 mysql2 旧版本的驱动行为,offset 和 pageSize 经过 parseInt 后确认是整数,再直接拼进 SQL,不存在注入风险;如果驱动版本支持占位符,我会改成全绑定写法。」
4. 前端 Vue 对接:axios 拦截器、购物车状态和下单流程的先后顺序
4.1 前端目录与 axios 封装:请求拦截器里自动带 token
前端部分源码用的是 Vue 2 + Element UI,这套组合在毕业设计里极其常见。目录结构如下:
pet-shop-web/ ├─ src/ │ ├─ api/ │ │ ├─ request.js # axios 实例 + 拦截器 │ │ ├─ product.js # 商品接口封装 │ │ ├─ user.js # 登录注册封装 │ │ └─ order.js # 订单接口封装 │ ├─ views/ │ │ ├─ Home.vue # 商品列表 │ │ ├─ Cart.vue # 购物车 │ │ ├─ Order.vue # 订单确认 │ │ ├─ OrderList.vue # 订单列表 │ │ └─ Login.vue # 登录注册 │ ├─ router/index.js │ └─ App.vue └─ package.jsonaxios 封装是整个前端最值得写的一段代码。新手最容易犯的错是在每个页面里手动写请求头,token 字段在十几个文件里重复出现,项目一大就失控。正确做法是在请求拦截器里统一注入:
// src/api/request.js import axios from 'axios'; import router from '../router'; // 创建 axios 实例 const request = axios.create({ baseURL: 'http://localhost:3000/api', // 后端接口前缀 timeout: 10000 // 10 秒超时 }); // 请求拦截器:在每次发请求前自动带上 token request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { // 与后端 auth.js 里的约定一致:'Bearer ' + token config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:统一处理后端返回 request.interceptors.response.use( response => response.data, // 直接拿到后端 JSON 的 data 部分 error => { if (error.response && error.response.status === 401) { // token 失效,清掉本地登录态,跳登录页 localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } ); export default request;封装完成之后,每个接口文件就非常薄。以api/product.js为例:
import request from './request'; // 商品列表:page、pageSize、category 都是可选参数 export function getProductList(params) { return request.get('/product/list', { params }); } // 商品详情 export function getProductDetail(id) { return request.get(`/product/detail/${id}`); }response => response.data这一行作用是省去每个组件里都写一层res.data.data的麻烦。封装不好的项目里往往会出现res.data.data.data这种三层嵌套,就是因为 axios 默认返回的 response 对象包了一层{ status, headers, data }。这里直接剥到业务数据层。
4.2 购物车逻辑:未登录存 localStorage,登录后走后端接口
购物车的设计是这个项目里一个很有区分度的点。它的设计是:未登录时,购物车数据存在前端localStorage;用户登录后,购物车数据存 MySQL 的cart表。为什么要这么做?因为用户可能还没登录就开始逛,加了几件商品到购物车,然后去登录,登录完成后购物车里的商品不能丢。
这个「合并购物车」的逻辑一定是在登录接口成功的回调里执行的。伪代码如下:
// views/Login.vue 登录成功的回调(节选) async function handleLoginSuccess(token, username) { localStorage.setItem('token', token); // 1. 取出 localStorage 里未登录时存的本地购物车 const localCart = JSON.parse(localStorage.getItem('cart') || '[]'); // 2. 本地的商品逐条加入后端购物车 for (const item of localCart) { await addToCart({ productId: item.productId, quantity: item.quantity }); } // 3. 合并完成后清空本地购物车 localStorage.removeItem('cart'); // 4. 跳转首页 router.push('/'); }这个流程的先后顺序不能乱。如果先清空localStorage再合并后端购物车,刷新页面后购物车数据就丢了。如果你在答辩时把这个「先合并、后清空」的顺序主动讲出来,老师会觉得你处理过真实场景的边界问题。
商品列表页的加载逻辑相对简单,核心是在onMounted里调接口:
// views/Home.vue import { onMounted, ref } from 'vue'; import { getProductList } from '../api/product'; const list = ref([]); const total = ref(0); const page = ref(1); const pageSize = ref(8); async function loadData() { const res = await getProductList({ page: page.value, pageSize: pageSize.value }); list.value = res.list; total.value = res.total; } onMounted(() => { loadData(); });4.3 下单流程:创建订单主表、扣库存、生成明细、清空购物车的顺序
提交订单这一节值得多说两句,因为这里的坑非常典型。前端下单逻辑如果只调一个「createOrder」接口,看起来简单但扩展性差;如果拆成四步,又要面对「中间某一步失败怎么回滚」的问题。源码的做法是后端用 MySQL 事务把四个步骤包起来,前端只调一个接口。但为了理解业务语义,这里把四步拆开说明:
// controllers/orderController.js(简化示意,真实代码用事务包裹) exports.createOrder = (req, res) => { const { userId, address, items } = req.body; db.beginTransaction(err => { if (err) return res.status(500).json({ message: '事务开启失败' }); // 第一步:创建订单主表,拿到订单 id db.query('INSERT INTO orders (user_id, address, total_amount, status) VALUES (?, ?, ?, 0)', [userId, address, items.reduce((sum, i) => sum + i.price * i.quantity, 0)], (err, result) => { if (err) return db.rollback(() => res.status(500).json({ message: '创建订单失败' })); const orderId = result.insertId; // 第二步:逐条扣库存,扣减前必须先查库存是否充足 const updateStockPromises = items.map(item => { return new Promise((resolve, reject) => { db.query( 'UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?', [item.quantity, item.productId, item.quantity], (err, updateResult) => { // affectedRows === 0 说明库存不足,UPDATE 条件不满足 if (updateResult.affectedRows === 0) { reject(new Error(`商品 ${item.productId} 库存不足`)); } else { resolve(); } } ); }); }); Promise.all(updateStockPromises).then(() => { // 第三步:批量插入订单明细 const detailValues = items.map(i => [orderId, i.productId, i.quantity, i.price]); db.query('INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ?', [detailValues], (err) => { if (err) return db.rollback(() => res.status(500).json({ message: '订单明细写入失败' })); // 第四步:清空用户购物车 db.query('DELETE FROM cart WHERE user_id = ?', [userId], (err) => { if (err) return db.rollback(() => res.status(500).json({ message: '清空购物车失败' })); db.commit(err => { if (err) return db.rollback(() => res.status(500).json({ message: '事务提交失败' })); res.json({ orderId, message: '订单创建成功' }); }); }); }); }).catch(err => { db.rollback(() => res.status(500).json({ message: err.message })); }); }); }); };这段代码的核心设计点是:UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?这条 SQL 自带库存检查。如果在业务代码里先查库存再更新,中间有并发请求插进来就会超卖;把检查放进 UPDATE 的 WHERE 条件里,就是所谓「原子操作」,数据库引擎保证同一时间只有一条 SQL 在执行。这是电商项目里防超卖的标准做法,也是答辩时最亮眼的一个技术点。
另外一个细节:事务里任何一个步骤出错,前面所有已执行的操作都要回滚,否则会出现「订单表有记录但商品库存没减」的数据不一致。所以代码里每个出错分支都要调用db.rollback()。这个「要么全部成功,要么全部失败」的思想,在答辩时是绝对的高频考点。
5. 避坑与常见问题排查:环境权限、MySQL 启动、跨域与乱码
5.1 npm 无法加载文件:PowerShell 执行策略限制的官方解法
现象:在 Windows PowerShell 里运行npm start,报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
原因:Windows PowerShell 的默认执行策略是 Restricted,禁止运行任何 .ps1 脚本,而 npm 在 PowerShell 里的包装命令恰好是npm.ps1。不是 npm 安装坏了,是系统安全策略拦住了。
解决:以管理员身份打开 PowerShell,执行:
# RemoteSigned:允许本地脚本运行,远程下载的脚本必须带签名 Set-ExecutionPolicy -ExecutionPolicy RemoteSigned输出问询时输入Y确认。改完后退出 PowerShell 重新打开,再执行npm -v,能返回版本号就说明问题解决了。这里要提示一句:不要为了省事设成Unrestricted,它会允许所有脚本运行,有安全风险。
5.2 MySQL 服务启动失败:net start mysql 报错或自动停止
现象:net start mysql提示服务正在启动,几秒后报「服务无法启动」,或者服务列表里显示运行中但端口没监听。
原因:最常见的是免安装版 MySQL 没有初始化data目录,或者my.ini配置文件里的basedir和datadir路径写错。服务启动时找不到数据目录,直接退出。
解决:按照第 2.2 节提到的方法,先删除或确认data目录不存在,然后执行:
# 重新初始化数据目录 mysqld --initialize --console初始化完成后,确认my.ini里datadir指向的是刚生成的 data 目录路径。路径里如果有中文,可能会出问题,建议把 MySQL 放到纯英文路径下。还有一种情况是端口被占用——用netstat -ano | findstr 3306查看 3306 端口是否被其他进程占据,常见冲突是之前在电脑上装过 MariaDB 或者另一个 MySQL 服务。
5.3 前端请求被 CORS 拦截:Network 里能看到响应,但 axios 拿不到数据
现象:前端跑在http://localhost:8080,后端跑在http://localhost:3000,浏览器控制台报CORS policy错误,Network 面板里看这次请求其实已经发出去了,响应也回来了,但 axios 的then里拿不到数据。
原因:跨域是浏览器的安全限制,两个地址「协议 + 域名 + 端口」三者有一个不同就是跨域。浏览器默认不允许页面读取跨域响应。
解决:在app.js里用了cors()中间件后,响应头里会加上Access-Control-Allow-Origin: *,浏览器就不会拦截了。这里的关键是注册顺序:app.use(cors())必须在挂载路由之前。如果你的后端没引入 cors 包,可以手动加响应头:
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });OPTIONS请求是浏览器在正式请求之前发送的「预检」请求,后端必须正确处理,否则正式请求根本不会发出。前端如果使用了Authorization头,Access-Control-Allow-Headers里必须包含Authorization,否则登录后的接口全部失效。
5.4 数据库中文乱码:显示问号或显示成不认识的符号
现象:商品名称、用户昵称在页面和 MySQL Workbench 里都显示为???或乱码。
原因:字符集链路里任何一环断了都会乱码。建库用了 latin1、连接参数没指定 charset、或者 SQL 文件导入时被转码,这三者都可能。真正的问题往往出在连接串上——就算库和表都是 utf8mb4,如果 Node.js 连接 MySQL 时没有指定字符集,驱动默认可能用 latin1 和你通信。
解决:检查config/db.js里的连接配置:
const mysql = require('mysql2'); const connection = mysql.createConnection({ host: 'localhost', port: 3306, user: 'root', password: '你的密码', database: 'pet_shop', charset: 'utf8mb4' // 必须显式指定 }); module.exports = connection;同时确认建库语句里字符集是 utf8mb4。如果数据已经在数据库里显示乱码,改了配置也没用,因为那是已经存在的坏数据,需要删掉重插。所以我写这篇笔记时强调:第一步建库就要把字符集固定,后面的连接参数和创建表语句都统一用 utf8mb4,乱码问题根本不会出现。
5.5 接口报 404 但路由看起来没问题:路径拼写和基础前缀最容易出错
现象:前端调http://localhost:3000/api/user/login返回 404,但代码里app.use('/api/user', require('./routes/user'))和router.post('/login')都写得没错。
原因:这类 404 几乎都是路径拼接问题——前端 baseURL 写成了http://localhost:3000,但后端路由挂了/api前缀,最终请求拼成了http://localhost:3000/user/login。或者反过来,baseURL 写成了/api,但后端没有挂/api前缀。
解决:把 baseURL 和后端挂载路径拼起来验证一遍:前端baseURL: 'http://localhost:3000/api',后端app.use('/api/user')+router.post('/login'),最终请求路径是/api/user/login。另外检查路由文件里有没有写错router.post('/login/')这种情况——Express 会忽略末尾斜杠,但最好统一风格。排查路径问题时,打开浏览器的 Network 面板,看实际发出的 URL 是哪一个,对照后端路由表逐段核对。
6. 上线前验证:一条完整业务链路走通,以及一个值得试的进阶部署
项目跑通的标准不是页面打开没报错,而是「从注册到下单」这条完整链路能走通。我建议你在自己电脑上严格执行下面这份检查清单,每过一条画一个勾,全部通过再去考虑截图和答辩材料。
先用 Postman 或 Apifox 按顺序测后端接口:
| 顺序 | 操作 | 预期结果 |
|---|---|---|
| 1 | POST/api/user/register提交用户名和密码 | user 表新增一条记录,密码字段是一长串 bcrypt 哈希 |
| 2 | POST/api/user/login提交相同密码 | 返回token字段,登录成功 |
| 3 | GET/api/product/list不带 token | 正常返回商品列表和 total |
| 4 | POST/api/order/create不带 token | 返回 401,提示未登录 |
| 5 | POST/api/order/create带Authorization: Bearer <token> | 返回 orderId,product 表对应商品库存减少 |
第 3 步和第 4 步是对照组,用来验证「浏览商品不需要登录、下单必须登录」的权限设计。如果第 4 步没有拦下来,说明auth中间件没挂到订单路由上,回去检查routes/order.js里router.use(auth)的位置。
再走前端验证流程:注册账号 → 登录(确认 token 写入 localStorage)→ 浏览商品 → 切换分类筛选 → 加入购物车 → 购物车页改数量 → 提交订单 → 在订单列表页看到刚下的单。这八步全部走通,项目功能层面就算验收合格。
如果你有多余时间,我建议试一个进阶玩法:把后端部署到一台云服务器,前端打包后丢到 Nginx 里,用proxy_pass把/api路径转发到 3000 端口的 Node 服务。这一步如果做成了,简历上「独立完成项目部署」这一栏就能理直气壮地打钩。核心配置只有一段:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/pet-shop-web/dist; location / { try_files $uri $uri/ /index.html; } # 把 /api 开头的请求转发给 Node.js location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置解决了「前后端分离部署」的核心问题:浏览器访问 Nginx 拿到静态页面,页面里的请求指向同源的/api路径,Nginx 再转发给 Node 服务。这样前端和后端的地址统一了,也顺手解决了跨域问题。
我自己每次拿到一份源码包,第一件事永远不是读代码,而是先跑通。跑通之后再拆结构,拆完结构再改一个功能点验证自己是否真的理解了。从那以后,我每次接到项目都强制自己先「跑一遍完整链路再做任何修改」。因为你永远不知道这个源码包在你的电脑上会栽在哪个版本的坑里——PowerShell 执行策略、MySQL 临时密码、CORS 预检请求,每个人翻车的位置都不一样。希望这篇笔记能帮你在答辩前少走几个弯路,把时间花在真正值得讲清楚的技术点上。
本文还有配套的精品资源,点击获取