實戰(zhàn):畢業(yè)設計完整指南)
簡介這是基于Node.js、Express與MySQL構建的前后端分離寵物用品購物網站畢業(yè)設計源碼面向計算機專業(yè)畢業(yè)生、課程設計學生以及全棧開發(fā)入門者可完整學習后端服務搭建、數據庫設計、接口開發(fā)及前端交互的電商項目實踐。壓縮包共669個文件總大小12.02MB包含193個js邏輯文件路由、中間件、業(yè)務處理、39個vue頁面組件、51個css樣式、28個html頁面、1個sql數據庫腳本以及5個bat輔助啟動腳本并配有svg、gif、jpg、png等圖片素材、字體文件及Markdown說明文檔目錄結構完整解壓后可直接部署運行。目前已有95人學習下載適合作為畢業(yè)設計答辯、課程作業(yè)或商城項目原型參考。項目覆蓋商品瀏覽、購物車、訂單管理、用戶注冊登錄等核心業(yè)務模塊同時涉及Node.js異步編程、Express路由與中間件、MySQL數據持久化、RESTful API設計、JWT用戶認證、模板引擎渲染、JSON數據交互、支付接口預留以及測試部署思路等關鍵技術點并附帶啟動腳本和數據庫初始化文件幫助讀者系統掌握全棧開發(fā)流程便于快速上手和二次擴展。1. 畢業(yè)設計選「寵物用品購物網站」最怕的不是做不完而是做完講不清畢業(yè)設計選「寵物用品購物網站」這個題目最怕的不是功能做不完而是做完之后講不清楚里面的技術點?;?Node.js Express MySQL 的前后端分離實現恰好壓在一個很舒服的位置功能上有商品瀏覽、搜索、購物車、下單、訂單管理足夠支撐起一份完整的畢業(yè)設計或課程設計技術上沒有引入 Redis、消息隊列這類難講清楚的東西前后端之間的每一次數據交換都能畫出明確的流程圖。這套源碼不追求炫技它的價值在于讓你在兩周內復現出一個能跑、能截圖、能在答辯時講明白的電商閉環(huán)。正在找設計題目的同學或者想用 Node.js 棧做項目實踐的開發(fā)者都可以用它作為起點。2. 動手之前Node.js 版本、MySQL 8.0 初始化和建庫的完整細節(jié)2.1 為什么是 Node.js Express MySQL而不是 Spring Boot 或 PHP選擇技術棧的底層邏輯不是「哪個火選哪個」而是「答辯被追問時你有多大的把握解釋清楚」。Java Spring Boot 在畢業(yè)設計里確實主流但很多同學在答辯時會被問到「Spring 的 Bean 生命周期」「自動配置原理」這些問題對于只做了 CRUD 的人來說很難答好。PHP 寫起來快但簡歷和項目呈現上都顯得不夠新。Node.js Express 的優(yōu)勢是語言單一前端 Vue 用的是 JavaScript后端 Express 也是 JavaScript你只需要熟練一種語法就能打通前后端。從架構上講這套組合的請求鏈路非常板書友好Vue 頁面通過 axios 發(fā)起 HTTP 請求 → Express 路由層接收 → Controller 處理業(yè)務邏輯 → 通過 mysql2 查詢數據庫 → 返回 JSON 給前端渲染。每一步都有一個明確的「層」每一層都有對應的目錄和文件這在答辯畫架構圖時是天然分層的。而且 Express 的中間件機制是必考點——app.use(cors())、app.use(bodyParser.json())、app.use(/api/user, require(./routes/user))這三行代碼擺在黑板上就能把「洋蔥模型」「中間件執(zhí)行順序」這些概念串起來老師在這一點上通常愿意給高分。2.2 環(huán)境搭建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后面跟著臨時密碼第一次登錄必須用它登錄后馬上改密碼。注意如果你執(zhí)行了mysqld --initialize卻沒記下臨時密碼那只能刪掉 data 目錄重新初始化沒有后悔藥。我從那以后每次初始化都把控制臺輸出截圖保存。驗證環(huán)境是否就緒node -v npm -v mysql -u root -p輸入密碼后能進入mysql提示符說明 Node 和 MySQL 兩個基礎環(huán)境都通了。這里有一個容易被忽略的細節(jié)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 字節(jié)字符而寵物用品詳情的文案里經常有 這類 emoji它們是 4 字節(jié)的。用 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 # 業(yè)務邏輯查庫、加密、簽發(fā) token │ ├─ productController.js │ └─ orderController.js ├─ middleware/ │ └─ auth.js # JWT 鑒權中間件 └─ package.jsonroutes 只負責定義 URL 路徑真正的邏輯在 controllers 里。這個分層習慣極其重要——如果所有代碼都寫在 app.js 里前期開發(fā)快但后期調試會非常痛苦。入口文件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); });這段代碼里有三個細節(jié)要在答辯時能解釋。第一cors()必須放在最前面因為跨域校驗發(fā)生在請求進入業(yè)務處理之前如果寫成app.use(cors())之前還有別的中間件瀏覽器可能已經攔截了響應。第二bodyParser.urlencoded({ extended: true })不能省有些表單提交的 Content-Type 是application/x-www-form-urlencoded只配了json()解析器時請求體會變成空對象{}這是新手排查半天都找不到原因的經典問題。第三404 兜底路由必須在所有業(yè)務路由注冊之后否則它會先捕獲所有請求。3.2 用 JWT 做登錄鑒權token 里該放什么、過期時間該設多長前后端分離架構下登錄狀態(tài)不依賴 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。登錄接口里簽發(fā) token 的邏輯// controllers/userController.js節(jié)選 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: 密碼錯誤 }); } // 簽發(fā) 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是同步方法在真實高并發(fā)項目里應該用異步的bcrypt.compare但畢業(yè)設計里同步方法足夠而且答辯時可以主動提起「我知道有異步版本在高并發(fā)下性能更好」這是一個主動展示知識邊界的好機會。3.3 商品分頁接口total 計數、offset 計算、LIMIT 綁定商品列表是所有用戶都能訪問的接口不需要 token。分頁接口有兩個容易踩的坑源碼里已經處理好了但你需要知道原因。第一個坑是分頁偏移量的計算。前端傳page和pageSize后端算offset (page - 1) * pageSize。很多新手直接把page當 offset 用首頁能出數據翻到第二頁就重復了。第二個坑是 total 字段。分頁組件要算總頁數就必須知道總記錄數。所以接口返回里必須帶total否則前端只能靠「這頁是否還有數據」來猜測有沒有下一頁。源碼里的實現// controllers/productController.js節(jié)選 exports.list (req, res) { const page parseInt(req.query.page) || 1; const pageSize parseInt(req.query.pageSize) || 10; const category req.query.category || ; // 動態(tài)拼接 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 里用占位符可能直接報語法錯誤所以源碼里選擇直接拼接整數。這個細節(jié)在答辯時被問到時你可以回答「為了兼容 mysql2 舊版本的驅動行為offset 和 pageSize 經過 parseInt 后確認是整數再直接拼進 SQL不存在注入風險如果驅動版本支持占位符我會改成全綁定寫法。」4. 前端 Vue 對接axios 攔截器、購物車狀態(tài)和下單流程的先后順序4.1 前端目錄與 axios 封裝請求攔截器里自動帶 token前端部分源碼用的是 Vue 2 Element UI這套組合在畢業(yè)設計里極其常見。目錄結構如下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; // 創(chuàng)建 axios 實例 const request axios.create({ baseURL: http://localhost:3000/api, // 后端接口前綴 timeout: 10000 // 10 秒超時 }); // 請求攔截器在每次發(fā)請求前自動帶上 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 失效清掉本地登錄態(tài)跳登錄頁 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 }。這里直接剝到業(yè)務數據層。4.2 購物車邏輯未登錄存 localStorage登錄后走后端接口購物車的設計是這個項目里一個很有區(qū)分度的點。它的設計是未登錄時購物車數據存在前端localStorage用戶登錄后購物車數據存 MySQL 的cart表。為什么要這么做因為用戶可能還沒登錄就開始逛加了幾件商品到購物車然后去登錄登錄完成后購物車里的商品不能丟。這個「合并購物車」的邏輯一定是在登錄接口成功的回調里執(zhí)行的。偽代碼如下// views/Login.vue 登錄成功的回調節(jié)選 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 下單流程創(chuàng)建訂單主表、扣庫存、生成明細、清空購物車的順序提交訂單這一節(jié)值得多說兩句因為這里的坑非常典型。前端下單邏輯如果只調一個「createOrder」接口看起來簡單但擴展性差如果拆成四步又要面對「中間某一步失敗怎么回滾」的問題。源碼的做法是后端用 MySQL 事務把四個步驟包起來前端只調一個接口。但為了理解業(yè)務語義這里把四步拆開說明// controllers/orderController.js簡化示意真實代碼用事務包裹 exports.createOrder (req, res) { const { userId, address, items } req.body; db.beginTransaction(err { if (err) return res.status(500).json({ message: 事務開啟失敗 }); // 第一步創(chuàng)建訂單主表拿到訂單 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: 創(chuàng)建訂單失敗 })); 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: 訂單創(chuàng)建成功 }); }); }); }); }).catch(err { db.rollback(() res.status(500).json({ message: err.message })); }); }); }); };這段代碼的核心設計點是UPDATE product SET stock stock - ? WHERE id ? AND stock ?這條 SQL 自帶庫存檢查。如果在業(yè)務代碼里先查庫存再更新中間有并發(fā)請求插進來就會超賣把檢查放進 UPDATE 的 WHERE 條件里就是所謂「原子操作」數據庫引擎保證同一時間只有一條 SQL 在執(zhí)行。這是電商項目里防超賣的標準做法也是答辯時最亮眼的一個技術點。另外一個細節(jié)事務里任何一個步驟出錯前面所有已執(zhí)行的操作都要回滾否則會出現「訂單表有記錄但商品庫存沒減」的數據不一致。所以代碼里每個出錯分支都要調用db.rollback()。這個「要么全部成功要么全部失敗」的思想在答辯時是絕對的高頻考點。5. 避坑與常見問題排查環(huán)境權限、MySQL 啟動、跨域與亂碼5.1 npm 無法加載文件PowerShell 執(zhí)行策略限制的官方解法現象在 Windows PowerShell 里運行npm start報錯npm : 無法加載文件 C:\Program Files\nodejs\npm.ps1因為在此系統上禁止運行腳本。原因Windows PowerShell 的默認執(zhí)行策略是 Restricted禁止運行任何 .ps1 腳本而 npm 在 PowerShell 里的包裝命令恰好是npm.ps1。不是 npm 安裝壞了是系統安全策略攔住了。解決以管理員身份打開 PowerShell執(zhí)行# RemoteSigned允許本地腳本運行遠程下載的腳本必須帶簽名 Set-ExecutionPolicy -ExecutionPolicy RemoteSigned輸出問詢時輸入Y確認。改完后退出 PowerShell 重新打開再執(zhí)行npm -v能返回版本號就說明問題解決了。這里要提示一句不要為了省事設成Unrestricted它會允許所有腳本運行有安全風險。5.2 MySQL 服務啟動失敗net start mysql 報錯或自動停止現象net start mysql提示服務正在啟動幾秒后報「服務無法啟動」或者服務列表里顯示運行中但端口沒監(jiān)聽。原因最常見的是免安裝版 MySQL 沒有初始化data目錄或者my.ini配置文件里的basedir和datadir路徑寫錯。服務啟動時找不到數據目錄直接退出。解決按照第 2.2 節(jié)提到的方法先刪除或確認data目錄不存在然后執(zhí)行# 重新初始化數據目錄 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 面板里看這次請求其實已經發(fā)出去了響應也回來了但 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請求是瀏覽器在正式請求之前發(fā)送的「預檢」請求后端必須正確處理否則正式請求根本不會發(fā)出。前端如果使用了Authorization頭Access-Control-Allow-Headers里必須包含Authorization否則登錄后的接口全部失效。5.4 數據庫中文亂碼顯示問號或顯示成不認識的符號現象商品名稱、用戶昵稱在頁面和 MySQL Workbench 里都顯示為???或亂碼。原因字符集鏈路里任何一環(huán)斷了都會亂碼。建庫用了 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。如果數據已經在數據庫里顯示亂碼改了配置也沒用因為那是已經存在的壞數據需要刪掉重插。所以我寫這篇筆記時強調第一步建庫就要把字符集固定后面的連接參數和創(chuàng)建表語句都統一用 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 面板看實際發(fā)出的 URL 是哪一個對照后端路由表逐段核對。6. 上線前驗證一條完整業(yè)務鏈路走通以及一個值得試的進階部署項目跑通的標準不是頁面打開沒報錯而是「從注冊到下單」這條完整鏈路能走通。我建議你在自己電腦上嚴格執(zhí)行下面這份檢查清單每過一條畫一個勾全部通過再去考慮截圖和答辯材料。先用 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路徑轉發(fā)到 3000 端口的 Node 服務。這一步如果做成了簡歷上「獨立完成項目部署」這一欄就能理直氣壯地打鉤。核心配置只有一段server { listen 80; server_name your-domain.com; # 前端靜態(tài)文件 root /var/www/pet-shop-web/dist; location / { try_files $uri $uri/ /index.html; } # 把 /api 開頭的請求轉發(fā)給 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 拿到靜態(tài)頁面頁面里的請求指向同源的/api路徑Nginx 再轉發(fā)給 Node 服務。這樣前端和后端的地址統一了也順手解決了跨域問題。我自己每次拿到一份源碼包第一件事永遠不是讀代碼而是先跑通。跑通之后再拆結構拆完結構再改一個功能點驗證自己是否真的理解了。從那以后我每次接到項目都強制自己先「跑一遍完整鏈路再做任何修改」。因為你永遠不知道這個源碼包在你的電腦上會栽在哪個版本的坑里——PowerShell 執(zhí)行策略、MySQL 臨時密碼、CORS 預檢請求每個人翻車的位置都不一樣。希望這篇筆記能幫你在答辯前少走幾個彎路把時間花在真正值得講清楚的技術點上。本文還有配套的精品資源點擊獲取