ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

NodeJs+Express+MySQL前后端分离宠物商城毕业设计实战

2026/10/7 18:53:03 拓冰建站 浏览量
NodeJs+Express+MySQL前后端分离宠物商城毕业设计实战 简介面向毕业设计、课程设计及全栈初学者提供基于Node.jsExpressMySQL前后端分离的宠物用品购物网站完整源码案例。涵盖用户认证与授权JWT/Session、RESTful API设计、JSON数据传输、模板渲染、支付集成等核心模块贴合真实电商业务场景便于理解全栈开发流程。案例共669个文件压缩包12.02MB以js后端逻辑、vue前端页面、css样式、html静态页、sql数据库脚本为主另有svg/gif/jpg等静态资源与部署脚本目录结构清晰方便按模块检索。已有95人浏览学习适合作为毕业设计参考或课程实践模板。通过该案例可掌握从数据库建模、后端接口设计到前端联调的整体思路从服务器端开发到数据库管理均有完整呈现可直接扩展或二次开发。1. 前后端分离的宠物用品商城为什么值得拿来做毕业设计每年毕业设计Web 方向总少不了“XX 购物网站”。但大多数同学用 JSP 或者前后不分的模板引擎做完答辩时被问一句“前后端怎么解耦的”就卡住了。这个标题选了 NodeJs Express MySQL外加前后端分离正好能把数据流向讲清楚前端只调 API后端只管业务和数据前端渲染和后端逻辑互不干扰。宠物用品这个垂直场景比通用电商好做深商品、分类、订单、购物车都是必练的项目需求边界清楚工作量适中。如果你是计算机相关专业的大四学生或者想拿一个能讲出亮点的求职项目这条路值得完整走一遍。这里我把从环境配置到上线部署的完整做法拆开讲连坑也一并标出来。2. 从零搭出项目骨架NodeJsExpressMySQL的最小可运行版本2.1 初始化 Express 项目与依赖选择的三个关键点在 Windows 或 macOS 上装 Node.js直接去官网下载 LTS 版本即可。新版 Node 自带 npm不需要额外装。安装完成后在终端验证一下node -v和npm -v。我习惯把 npm 镜像切到国内源避免拉包时长时间卡住npm config set registry https://registry.npmmirror.com然后创建项目目录并初始化mkdir pet-shop-server cd pet-shop-server npm init -y npm install express mysql2 cors jsonwebtoken dotenv npm install -D nodemon这里依赖的选择有讲究。express是 Web 框架mysql2是 MySQL 驱动cors解决浏览器跨域拦截jsonwebtoken做登录鉴权dotenv管理环境变量。开发依赖里的nodemon用于开发时修改代码自动重启不用每次手动node app.js。两个关键点第一npm init -y跳过交互式问答快速生成 package.json第二运行时依赖必须装在dependencies只有nodemon这类工具才放devDependencies。在package.json里配置脚本方便后面启动scripts: { dev: nodemon app.js, start: node app.js }然后写最基础的入口文件app.js// app.js - Express 服务入口 const express require(express); const cors require(cors); const dotenv require(dotenv); dotenv.config(); // 加载 .env 文件中的环境变量 const app express(); // 解析 JSON 请求体cors 允许不同端口的浏览器访问 app.use(cors()); app.use(express.json()); // 健康检查接口部署后先看这个再排查其他 app.get(/api/health, (req, res) res.json({ ok: true })); const PORT process.env.PORT || 3000; app.listen(PORT, () console.log(Server running on port ${PORT}));这段代码的作用是让项目先跑成一个最小服务。cors()在前后端分离开发时必须加否则前端从 5173 端口请求 3000 端口会被浏览器拦截。express.json()是内置中间件替代以前的body-parser用来解析前端发来的 JSON 数据。dotenv.config()提前读取环境变量后面数据库密码、JWT 密钥都放.env不写进代码里。2.2 设计宠物用品电商的数据库表商品、分类、订单与用户宠物用品商城和普通电商在表结构上差别不大但要体现出垂直场景的特点。比如商品分类可以分成主粮、零食、玩具、驱虫药、猫砂等订单里要记录商品快照防止商品改名后历史订单跟着变。下面这套 MySQL 建表语句我是按毕业设计答辩时最常问的“订单和商品关系”来设计的CREATE DATABASE IF NOT EXISTS pet_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE pet_shop; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE category ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, parent_id INT DEFAULT 0 ) ENGINEInnoDB; CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image_url VARCHAR(255), description TEXT, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_status (status), CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB; CREATE TABLE cart ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINEInnoDB; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已收货 4已取消, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB;表结构里有几个值得在答辩时讲的点金额字段必须用DECIMAL不能用FLOAT避免浮点误差购物车表用user_id和product_id的联合唯一索引防止同一商品重复加购order_item冗余了product_name和price这是因为订单是历史快照商品信息后续修改不能影响已经生成的订单。外键只在product.category_id上加因为订单和购物车更注重性能外键在并发写时会影响效率。2.3 用 Sequelize 还是原生 SQL这里推荐的原生 mysql2 连接池写法很多教程喜欢用 Sequelize ORM但我不推荐毕业设计用。原因很简单ORM 生成的 SQL 是黑盒子答辩时老师问“这条查询走了什么索引”“事务隔离级别怎么设置的”你答不上来。用原生 SQL 虽然多写几行但每个查询你都能讲清楚。这里用mysql2/promiseAPI 是 Promise 风格可以和async/await搭配完全不比 ORM 难用。新建db.js配置连接池// db.js - MySQL 连接池 const mysql require(mysql2/promise); const dotenv require(dotenv); dotenv.config(); const pool mysql.createPool({ host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || , database: process.env.DB_NAME || pet_shop, waitForConnections: true, // 连接池满时排队等待而不是直接报错 connectionLimit: 10, // 最大连接数 queueLimit: 0, charset: utf8mb4 // 显式指定字符集避免中文乱码 }); module.exports pool;参数说明waitForConnections设为true表示连接被占用时请求进入等待队列connectionLimit控制并发连接数开发环境 10 够用生产环境可以提高到 20 左右charset必须写成utf8mb4因为 MySQL 8 默认字符集可能是utf8mb4但保险起见连接层再声明一次三层字符集统一才能彻底避免乱码。写一个接口测试数据库连接// app.js 中新增 app.get(/api/db-test, async (req, res) { const [rows] await pool.query(SELECT 1 as ok); res.json({ ok: rows[0].ok }); });pool.query返回的是一个数组第一项是查询结果行第二项是字段元信息。这里用解构取第一项。如果访问/api/db-test返回{ok:1}说明数据库连接没问题接下来就可以安心写业务接口了。3. 后端 API 与前端解耦登录鉴权、商品列表与购物车接口落地3.1 JWT 鉴权在 Express 里的实现从生成 token 到拦截器前后端分离项目最常用的登录态方案是 JWT服务端不存 Session用户登录后拿到一个 token后续请求在请求头里带着它。服务端用密钥验证 token 是否有效。这也正好能解释“为什么前后端分离适合横向扩展”——服务端不需要保持会话状态。新建auth.js封装签名和校验逻辑// auth.js - JWT 签名与验证 const jwt require(jsonwebtoken); const SECRET process.env.JWT_SECRET || pet_shop_key; // 生成 token登录成功后返回给前端 function generateToken(userId, username) { return jwt.sign( { id: userId, username }, SECRET, { expiresIn: 7d } ); } // 中间件校验请求头里的 Authorization function authGuard(req, res, next) { const header req.headers.authorization || ; const token header.startsWith(Bearer ) ? header.slice(7) : ; if (!token) return res.status(401).json({ msg: 未登录 }); try { const payload jwt.verify(token, SECRET); req.user payload; next(); } catch (err) { return res.status(401).json({ msg: 登录已过期 }); } } module.exports { generateToken, authGuard };expiresIn设置成7d表示 token 有效期 7 天。SECRET必须放在.env里不能写死在代码中否则上传到公开仓库就泄露了签名密钥别人可以伪造任意用户。authGuard作为中间件挂到需要登录的接口前面比如购物车、下单、个人信息接口内部就能通过req.user.id拿到当前用户。登录注册接口放在routes/user.js// routes/user.js - 注册与登录 const express require(express); const router express.Router(); const pool require(../db); const bcrypt require(bcryptjs); const { generateToken } require(../auth); // 注册密码用 bcrypt 哈希后再入库 router.post(/register, async (req, res) { const { username, password } req.body; if (!username || !password) return res.status(400).json({ msg: 缺少必填字段 }); const salt await bcrypt.genSalt(10); const hash await bcrypt.hash(password, salt); try { const [result] await pool.query( INSERT INTO user (username, password_hash) VALUES (?, ?), [username, hash] ); res.json({ id: result.insertId, username }); } catch (err) { if (err.code ER_DUP_ENTRY) { return res.status(400).json({ msg: 用户名已存在 }); } res.status(500).json({ msg: 注册失败 }); } }); // 登录比对哈希通过后返回 token router.post(/login, async (req, res) { const { username, password } req.body; const [rows] await pool.query(SELECT * FROM user WHERE username ?, [username]); if (!rows.length) return res.status(400).json({ msg: 用户名或密码错误 }); const ok await bcrypt.compare(password, rows[0].password_hash); if (!ok) return res.status(400).json({ msg: 用户名或密码错误 }); const token generateToken(rows[0].id, username); res.json({ token, username }); }); module.exports router;密码一定不能明文存储。bcrypt.genSalt生成盐bcrypt.hash加密登录时用bcrypt.compare比对。这里用的bcryptjs是纯 JavaScript 实现不需要编译原生模块Windows 下不会出现安装失败的问题。SQL 全部采用?占位符参数化查询防止 SQL 注入。在app.js挂载路由app.use(/api/users, require(./routes/user));3.2 商品列表的分页、排序与多条件筛选接口怎么写电商首页和列表页必须支持分页、按分类筛选、关键词搜索和排序。这个接口几乎是所有商城项目的必考题目而且特别容易在 SQL 拼接上翻车。下面写一个健壮的商品查询接口// routes/product.js - 商品查询 const express require(express); const router express.Router(); const pool require(../db); router.get(/, async (req, res) { const { page 1, pageSize 10, categoryId, keyword, sort } req.query; const where []; const params []; if (categoryId) { where.push(category_id ?); params.push(Number(categoryId)); } if (keyword) { where.push(name LIKE ?); params.push(%${keyword}%); } // 默认只查上架商品下架的不展示 where.push(status 1); // 排序字段用白名单映射防止拼接注入 const sortMap { new: id DESC, price_asc: price ASC, price_desc: price DESC }; const orderBy sortMap[sort] || id DESC; const whereSql where.length ? WHERE where.join( AND ) : ; const countSql SELECT COUNT(*) as total FROM product ${whereSql}; const [countRows] await pool.query(countSql, params); const total countRows[0].total; const offset (Math.max(1, Number(page)) - 1) * Number(pageSize); const limit Math.min(100, Number(pageSize)); const sql SELECT id, name, price, image_url FROM product ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?; const [rows] await pool.query(sql, [...params, limit, offset]); res.json({ list: rows, total, page: Number(page), pageSize: limit }); }); module.exports router;这个接口的关键点有三个。第一where条件和params分开维护动态拼接 SQL 时参数永远用?占位不会出现字符串拼接注入。第二排序字段没有直接拼接用户输入而是用sortMap白名单映射price_asc和price_desc是合法的其他值统一走默认的id DESC。第三LIMIT和OFFSET也作为参数传入并且用Number()强转避免字符串类型的page引起计算错误。分页的pageSize上限设成 100防止前端传个大数字一次查全表。3.3 购物车与订单接口事务保证库存扣减不超卖购物车接口比较简单难的是下单时扣库存。两个人同时买最后一个“宠物背包”如果代码先查库存再更新可能出现超卖。解决办法是开启事务并在查询时用FOR UPDATE锁住商品行。下面这段下单接口是整套项目里最值得讲的逻辑// routes/order.js - 购物车结算下单 const express require(express); const router express.Router(); const pool require(../db); const { authGuard } require(../auth); router.post(/create, authGuard, async (req, res) { const userId req.user.id; const { cartIds } req.body; // 勾选的购物车记录 id 数组 if (!Array.isArray(cartIds) || cartIds.length 0) { return res.status(400).json({ msg: 请选择要结算的商品 }); } const conn await pool.getConnection(); try { await conn.beginTransaction(); const placeholders cartIds.map(() ?).join(,); // 查出购物车明细并锁住相关商品行 const [cartRows] await conn.query( SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock FROM cart c JOIN product p ON c.product_id p.id WHERE c.id IN (${placeholders}) AND c.user_id ? FOR UPDATE, [...cartIds, userId] ); if (cartRows.length ! cartIds.length) { throw new Error(部分购物车商品不存在); } let totalAmount 0; for (const item of cartRows) { if (item.stock item.quantity) { const error new Error(商品 ${item.name} 库存不足); error.status 400; throw error; } await conn.query( UPDATE product SET stock stock - ? WHERE id ? AND stock ?, [item.quantity, item.product_id, item.quantity] ); totalAmount parseFloat(item.price) * item.quantity; } const orderNo PO Date.now(); const [orderResult] await conn.query( INSERT INTO orders (order_no, user_id, total_amount) VALUES (?, ?, ?), [orderNo, userId, totalAmount] ); const orderId orderResult.insertId; for (const item of cartRows) { await conn.query( INSERT INTO order_item (order_id, product_id, product_name, price, quantity) VALUES (?, ?, ?, ?, ?), [orderId, item.product_id, item.name, item.price, item.quantity] ); } await conn.query(DELETE FROM cart WHERE id IN ( placeholders ) AND user_id ?, [...cartIds, userId]); await conn.commit(); res.json({ orderNo, totalAmount }); } catch (err) { await conn.rollback(); res.status(err.status || 500).json({ msg: err.message || 下单失败 }); } finally { conn.release(); } }); module.exports router;这里最关键的代码是SELECT ... FOR UPDATE它在事务内把命中商品行锁住其他事务要更新同一行时必须等这个事务提交或回滚。配合UPDATE ... WHERE stock ?条件更新即使极端并发下更新影响行数为 0 也能被事务回滚从数据库层面杜绝超卖。注意事务内所有 SQL 都必须用conn.query不能混用pool.query否则连接不一致事务就失效了。最后finally里conn.release()把连接还回连接池连接池没有连接时也不会泄漏。4. 前端 Vue 与后端联调代理、跨域与接口约定4.1 用 create-vue 搭前端并用 Vite 代理解决开发环境跨域后端接口写好了前端一般用 Vue 或 React。这里以 Vue 3 Vite 为例因为 create-vue 是目前社区默认脚手架零配置启动。npm create vuelatest pet-shop-web cd pet-shop-web npm install npm install axiospet-shop-web是前端目录和后端pet-shop-server平级两者分开这正是“前后端分离”在工程上的体现。开发时前端跑在 5173 端口后端跑在 3000 端口浏览器直接请求http://localhost:3000/api/products会被跨域策略拦截。虽然服务端已经加了cors()中间件但更规范的做法是在开发环境下用 Vite 的 proxy 把请求转发到后端这样前端代码里请求路径可以统一写成/api/...不需要写死完整域名。修改vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })配置后前端请求/api/products时Vite 开发服务器会把它转成http://localhost:3000/api/products。changeOrigin: true表示代理转发时把请求头里的Host改成localhost:3000避免后端有域名校验时误判。这个代理只存在于开发环境生产部署时由 Nginx 做同样的事所以前端代码里不需要区分环境统一用/api开头即可这也是前后端分离的一个好处。4.2 Axios 拦截器里统一处理 token 和 401 跳转前端调用后端接口每次都要在请求头带 token并且遇到 401未登录要跳回登录页。如果每个接口都写一遍重复不说还容易漏。用 Axios 拦截器统一处理后新接口只需要写业务逻辑。在src/utils/request.js中封装import axios from axios const request axios.create({ baseURL: /api, // 开发走 Vite 代理部署由 Nginx 转发 timeout: 10000 }) // 请求拦截器每次请求自动带上 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理业务错误和登录过期 request.interceptors.response.use( res { // 后端返回的数据在 res.data 里直接返回给调用方 return res.data }, err { if (err.response err.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(err) } ) export default request这里有一个常见的循环依赖坑不要在request.js里import router后调用router.push(/login)因为路由模块通常也依赖到 API 模块两个模块互相引会出问题。用window.location.href跳转虽然会刷新页面但在登录过期场景下是可接受的。Bearer前缀要和后端auth.js里的解析逻辑保持一致这里漏掉后端的header.slice(7)就会取到空字符串导致接口 401。5. 毕业设计避坑实录环境配置、中文乱码、端口占用与部署五个教训5.1 npm.ps1 无法加载文件因为在此系统上禁止运行脚本现象Windows 上在 PowerShell 执行npm install报错“npm.ps1因为在此系统上禁止运行脚本”。原因PowerShell 的执行策略默认是Restricted不允许运行本地.ps1脚本而 npm 在 Windows 上是通过 npm.ps1 这个脚本启动的。解决用管理员身份打开 PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser然后重新打开终端。RemoteSigned允许本地创建的脚本运行远程下载的脚本需要签名比Unrestricted安全得多。如果不想改策略也可以改用 CMD 或 Git Bash 执行 npm 命令避开 PowerShell。5.2 MySQL 8.0 密码加密方式导致连接失败现象mysql2连接本地 MySQL 时报ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server。原因MySQL 8 默认创建用户使用caching_sha2_password加密方式而老版本 mysql2 驱动或某些客户端不支持这种新协议。解决在 MySQL 里执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;把root的认证方式改回mysql_native_passwordmysql2 就能正常连接。如果不想动 root可以创建一个新用户并指定认证方式CREATE USER pet_userlocalhost IDENTIFIED WITH mysql_native_password BY 123456; GRANT ALL PRIVILEGES ON pet_shop.* TO pet_userlocalhost;建议用第二种方式给项目单独建库建用户权限隔离答辩时讲数据库安全也有素材。5.3 中文乱码建库、建表、连接串三处字符集要一致现象插入“狗粮”后数据库查出来是“???”。原因数据库默认字符集是latin1它不支持中文。解决建库时指定utf8mb4建表时也指定连接池charset也写成utf8mb4三处统一。用SHOW VARIABLES LIKE character_set%;检查数据库当前字符集重点看character_set_server和character_set_database。我一般在编写建库语句时第一行就加DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci后面建表不写也默认继承。utf8mb4是utf8的超集支持 emoji现在是 MySQL 8 的标准配置。5.4 端口被占用EADDRINUSE 的排查与一键处理现象执行npm run dev或node app.js时报Error: listen EADDRINUSE: address already in use :::3000。原因3000 端口被某个进程占用了可能是上次启动的 node 进程没有退出或者别的软件占用了这个端口。解决Windows 上执行netstat -ano | findstr :3000输出里的最后一列是 PID然后taskkill /F /PID pid强制杀掉。macOS 上用lsof -i :3000找到 PID再kill -9 pid。更保险的做法是监听一个不太常见的端口比如 8088避开系统占用。实践中也遇到过 3000 被占用后改了端口又忘了改前端代理里target的情况所以端口信息最好写进.env前端代理也支持读取环境变量。5.5 部署到云服务器用 PM2 守护进程和服务端口放行现象本地开发一切正常部署到云服务器后浏览器访问http://服务器IP:3000打不开。原因要么安全组没放行 3000 端口要么 node 进程在前台运行SSH 断开后进程就死了。解决云服务器控制台的安全组入方向放行 3000 端口然后使用 PM2 守护进程npm install -g pm2 pm2 start app.js --name pet-shop pm2 savepm2 start之后进程由 PM2 托管SSH 断开不会影响运行。pm2 save保存当前进程列表服务器重启后 PM2 会自动拉起。常用排查命令pm2 status看进程状态pm2 logs看输出日志pm2 restart pet-shop重启应用。如果域名和 HTTPS 也要配可以在前面加一层 Nginx 反向代理把 80 端口转发到 3000 端口前端构建后的静态文件也交给 Nginx 托管。6. 把“人工智能”塞进宠物商城一个不翻车的智能推荐小模块6.1 基于用户浏览行为的简单协同过滤用 Node 实现标题里带了“人工智能”很多同学以为必须上 TensorFlow 或 PyTorch。实际上毕业设计里的人工智能可以是轻量级的智能推荐。宠物用品商城适合做“物-物协同过滤”用户浏览过某件商品系统推荐浏览过同一件商品的其他用户还浏浏览过的其他商品。这个思路不需要训练模型用 SQL 就能实现。先建一张行为记录表CREATE TABLE user_behavior ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, product_id INT NOT NULL, type VARCHAR(10) NOT NULL COMMENT view 或 favorite, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_product (product_id) ) ENGINEInnoDB;然后写推荐接口// routes/recommend.js - 基于共现的推荐 const express require(express); const router express.Router(); const pool require(../db); router.get(/, async (req, res) { const userId Number(req.query.userId); const limit Math.min(10, Number(req.query.limit || 6)); // 1. 当前用户最近浏览过的商品 const [viewed] await pool.query( SELECT DISTINCT product_id FROM user_behavior WHERE user_id ? AND type view ORDER BY id DESC LIMIT 10, [userId] ); if (!viewed.length) { const [hot] await pool.query( SELECT id, name, price FROM product ORDER BY stock DESC LIMIT ?, [limit] ); return res.json({ list: hot, source: hot }); } // 2. 找浏览过相同商品的其他用户 const productIds viewed.map(v v.product_id); const placeholders productIds.map(() ?).join(,); const [users] await pool.query( SELECT DISTINCT user_id FROM user_behavior WHERE product_id IN (${placeholders}) AND user_id ! ?, [...productIds, userId] ); if (!users.length) return res.json({ list: [], source: empty }); // 3. 这些用户还浏览过哪些商品排除当前用户已浏览的统计共现次数 const userIds users.map(u u.user_id); const userPlaceholders userIds.map(() ?).join(,); const [recs] await pool.query( SELECT b.product_id, COUNT(*) as cnt FROM user_behavior b WHERE b.user_id IN (${userPlaceholders}) AND b.product_id NOT IN (${placeholders}) GROUP BY b.product_id ORDER BY cnt DESC LIMIT ?, [...userIds, ...productIds, limit] ); res.json({ list: recs, source: collaborative }); }); module.exports router;这段代码本质就是一个物-物协同过滤SQL 里三次查询分别完成“找种子商品”“找相似用户”“找推荐商品”。source字段标记结果来源方便验证时区分算法效果。第一次查询取不重复的product_id防止用户重复浏览某商品导致权重失衡。第三次查询里NOT IN排除当前用户已经浏览过的商品避免推荐已经看过的东西。6.2 验证方法用 A/B 对比点击率别只看精确率推荐模块做完答辩时最怕老师问“怎么证明它有效”。我的建议是做一个 A/B 实验把用户随机分成两组一组走协同过滤推荐一组走热门商品推荐前端埋点记录每次曝光的商品和用户点击行为。统计日志表CREATE TABLE track_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, strategy VARCHAR(30) NOT NULL, product_id INT NOT NULL, clicked TINYINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB;统计点击率时一条 SQL 就能对比SELECT strategy, SUM(clicked 1) AS clicks, COUNT(*) AS exposures, SUM(clicked 1) / COUNT(*) AS ctr FROM track_log GROUP BY strategy;如果协同过滤组的点击率高于热门组说明推荐算法有效如果差不多也能从行为数据量不足、用户冷启动等角度解释。做这类项目时我栽过不少跟头最坑的是数据库字符集没统一演示时中文全部乱码当场翻车后来每次建库第一件事就是指定utf8mb4。推荐模块也不要贪大先跑通共现再考虑加权重、加时间衰减。希望这套从 0 到部署的路径能帮你避开那些无谓的报错也让你在答辩时能理直气壮说清楚每一个设计决策希望帮到你。本文还有配套的精品资源点击获取