
简介这是基于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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 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 详情描述支持中文和表情符号 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么用 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 tokensplit 后取第二部分 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 按顺序测后端接口顺序操作预期结果1POST/api/user/register提交用户名和密码user 表新增一条记录密码字段是一长串 bcrypt 哈希2POST/api/user/login提交相同密码返回token字段登录成功3GET/api/product/list不带 token正常返回商品列表和 total4POST/api/order/create不带 token返回 401提示未登录5POST/api/order/create带Authorization: Bearer token返回 orderIdproduct 表对应商品库存减少第 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 预检请求每个人翻车的位置都不一样。希望这篇笔记能帮你在答辩前少走几个弯路把时间花在真正值得讲清楚的技术点上。本文还有配套的精品资源点击获取