☰
NodeJs+Express+MySQL前后端分离宠物商城毕业设计实战
2026/10/7 18:52:25 网站建设 项目流程

简介:面向毕业设计、课程设计及全栈初学者,提供基于Node.js+Express+MySQL前后端分离的宠物用品购物网站完整源码案例。涵盖用户认证与授权(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. 从零搭出项目骨架:NodeJs+Express+MySQL的最小可运行版本

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 ) ENGINE=InnoDB; CREATE TABLE `category` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(50) NOT NULL, `parent_id` INT DEFAULT 0 ) ENGINE=InnoDB; 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`) ) ENGINE=InnoDB; 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`) ) ENGINE=InnoDB; 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`) ) ENGINE=InnoDB; 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`) ) ENGINE=InnoDB;

表结构里有几个值得在答辩时讲的点:金额字段必须用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/promise,API 是 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 vue@latest pet-shop-web cd pet-shop-web npm install npm install axios

pet-shop-web是前端目录,和后端pet-shop-server平级,两者分开,这正是“前后端分离”在工程上的体现。开发时前端跑在 5173 端口,后端跑在 3000 端口,浏览器直接请求http://localhost:3000/api/products会被跨域策略拦截。虽然服务端已经加了cors()中间件,但更规范的做法是在开发环境下用 Vite 的 proxy 把请求转发到后端,这样前端代码里请求路径可以统一写成/api/...,不需要写死完整域名。

修改vite.config.js:

import { 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 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;

把root的认证方式改回mysql_native_password,mysql2 就能正常连接。如果不想动 root,可以创建一个新用户并指定认证方式:

CREATE USER 'pet_user'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456'; GRANT ALL PRIVILEGES ON pet_shop.* TO 'pet_user'@'localhost';

建议用第二种方式,给项目单独建库建用户,权限隔离,答辩时讲数据库安全也有素材。

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 save

pm2 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) ) ENGINE=InnoDB;

然后写推荐接口:

// 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 ) ENGINE=InnoDB;

统计点击率时,一条 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 到部署的路径能帮你避开那些无谓的报错,也让你在答辩时能理直气壮说清楚每一个设计决策,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询