我前后花了两周时间,把一个零食商城的销售系统从零搭到了能跑、能下单、能发货的程度。技术栈就是标题里写的那套:Vue 3做前端界面,Node.js + Express提供后端接口,MySQL存数据。这个组合在当下的全栈入门项目里确实很常见,但真正动手做一遍,你会发现网上抄来的零散代码根本不够用——商城系统的订单状态流转、SKU规格选择、购物车加减、接口鉴权,每一个环节都会引出不少细节问题。
这篇文章我不打算只贴一堆代码片段了事,而是把这套“基于Vue和Node.js的零食商城销售系统”从需求拆解、数据库设计、接口开发到前后端联调部署的完整思路写出来。不管是准备做课程设计、毕业设计,还是单纯想练全栈项目的同学,顺着这条线走一遍,应该能绕开我踩过的大部分坑。
1. 零食商城的业务特点与需求范围拆解
1.1 零食商品的业务特征决定了系统设计方向
很多人一上来就急着建工程、写代码,结果写到一半发现购物车、订单、库存之间的逻辑对不上,只能推倒重来。零食商城表面看和普通电商系统没什么区别,但零食这类商品有自己的业务特征,会直接影响表结构和接口设计。
第一个特征是SKU(库存量单位)碎片化。一包薯片可能有原味、番茄味、烧烤味三种口味,一种口味又有袋装和桶装两种规格,组合下来就是六个SKU。库存、价格、图片甚至条形码都要跟着SKU走,不能只挂在商品层级上。第二个特征是客单价低、订单量大,用户一次可能会买十几样东西,所以订单和订单明细必须分开两张表,否则一份订单塞十几个商品字段,数据库设计直接就乱套了。第三个特征是零食天然适合做关联推荐,同一品类下的辣条、豆干、素肉往往是搭配着买的,商城首页做一个简单的“猜你喜欢”模块,对提高客单价很有帮助。
1.2 用户端和管理端的功能边界划分
根据这些业务特点,我把系统拆成用户端和管理端两部分,功能清单如下:
用户端
- 账号体系:注册、登录、退出登录,登录后才能加购物车和下单
- 商品浏览:分类导航、关键词搜索、价格排序、分页加载
- 商品详情:多图轮播、SKU选择(口味+规格)、加入购物车
- 购物车:增删商品、修改数量、勾选商品结算、合计金额实时计算
- 结算下单:选择收货地址、填写订单备注、提交订单
- 订单中心:订单列表、订单详情、取消待支付订单
- 个人中心:修改昵称、维护收货地址
管理端
- 商品管理:新增商品、编辑信息、上下架、调整库存
- 分类管理:维护商品分类树
- 订单管理:查看订单明细、发货、处理退款
- 用户管理:查看注册用户列表
- 数据看板:销量Top统计、近七日订单趋势
注意:做毕设或练手项目时,功能多不一定好,关键是每个功能都能讲清楚“为什么这么设计”。比如订单为什么要有“待支付—已支付—已发货—已完成”四个状态,这就是后面接口和表结构的核心依据。
2. 技术选型的对比分析与整体架构设计
2.1 为什么选Vue + Node.js,而不是单体模板渲染
关于技术选型,我见过很多人在Spring Boot、Django、Flask、Node.js之间反复纠结。零食商城这类销售系统用Vue + Node.js,核心原因是前后端分离的协作模式更适合当前开发习惯。
如果用Spring Boot + Thymeleaf或者Express + EJS做服务端渲染,每一个页面都要走后端模板引擎,交互逻辑分布在前端模板和后端控制器两层,改一个购物车的交互都要牵动后端代码。而Vue + Node.js则是彻底的前后端分离:后端只负责提供JSON格式的接口,所有页面渲染和交互都在浏览器端完成。这个模式下,前端开发和后端开发可以完全并行,单个页面的响应速度也更快——商品列表页只需要拉一次接口,后续的筛选、排序、分页全部在前端处理,不需要反复刷新整个页面。
从学习成本来看,Vue是国内使用率很高的前端框架,中文资料充足;Node.js又是JavaScript技术栈,前后端语言统一,不用在Java和JavaScript之间来回切换思维模式。做一个课程设计或者毕设,这套组合在“工作量”和“讲解深度”上也很容易拉开档次:前端能讲组件化设计、状态管理、路由守卫,后端能讲RESTful接口、中间件机制、鉴权策略,每一块都有实实在在的内容可写。
2.2 框架选型的具体对比
前端我选了Vue 3而不是Vue 2,理由很简单:Vue 3的Composition API在处理商品筛选、购物车状态这类多条件联动场景时,逻辑聚合度比Vue 2的Options API高很多。比如商品列表页的“分类筛选+价格区间+分页页码”,用reactive定义一个筛选条件对象,选项变了直接把对象传给接口,代码写起来干净利落。
状态管理用了Pinia而不是Vuex。Pinia的API更简洁,对TypeScript的支持也更好,一个defineStore就能把购物车的商品列表、勾选状态、总价计算全部封装好。UI组件库选了Element Plus,它的表格、表单、分页、弹窗组件非常适合后台管理系统,能省下大量写样式的时间。
后端选择了Express而不是Koa或NestJS,主要考虑是Express的中间件生态非常成熟,资料多、问题排查容易。NestJS虽然结构更工程化,但对刚接触Node.js后端的同学来说,装饰器、依赖注入这些概念反而会增加学习负担。项目里用到的中间件就几个:cors处理跨域、json解析请求体、multer处理图片上传、jsonwebtoken生成和校验登录令牌,都是Express生态里非常稳定的方案。
数据库选了MySQL而不是MongoDB,原因是零食商城的订单、库存、金额这些数据有极强的结构化和事务性要求。比如用户下单时,购物车商品和库存扣减必须保持数据一致,MySQL的事务机制能保证“库存扣减失败就整个订单回滚”。MongoDB做这种强一致性的交易数据存储,开发时会花很多力气处理数据校验的问题,没必要。
2.3 系统整体架构
整个系统的请求链路是这样的:
浏览器(Vue 3前端) ↓ 发起HTTP请求(携带JWT token) Node.js后端(Express路由层) ↓ 中间件解析 → 鉴权校验 → 业务逻辑 数据访问层(mysql2连接池 + SQL) ↓ MySQL数据库前端工程通过Vite启动,开发环境下Vite代理把/api开头的请求转发到后端的3000端口,这样就规避了开发阶段的跨域问题;生产环境下前端构建后的静态文件放在Nginx中,后端接口单独跑在服务器的3000端口,Nginx再把/api请求反向代理到3000端口。前后端部署完全分离,哪一侧出问题都可以单独重启,不用互相影响。
3. 数据库设计:八张核心表的关系与订单状态流转
3.1 数据表逻辑关系
零食商城的数据库我最终拆了八张表,每一张表都有明确的存在理由:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| users | 系统用户(包括普通用户和管理员) | id, username, password, role, avatar, created_at |
| categories | 商品分类树 | id, parent_id, name, sort_order |
| products | 商品基础信息 | id, category_id, name, description, sales_count, status |
| product_skus | 商品规格库存 | id, product_id, spec_1, spec_2, price, stock, image |
| cart_items | 购物车 | id, user_id, sku_id, quantity, checked, created_at |
| orders | 订单主表 | id, order_no, user_id, address_snapshot, total_amount, status |
| order_items | 订单明细表 | id, order_id, sku_id, product_name, spec, price, quantity |
| addresses | 收货地址 | id, user_id, receiver, phone, province, city, detail |
这里重点说一下为什么商品信息要被拆成products和product_skus两张表。零食的“商品”和“可购买的最小单元”是不同概念:一个“乐事薯片”是商品,但用户真正下单买的是“原味/袋装/6元”这个SKU。把价格和库存放在SKU表里,就能支持同一个商品不同规格卖不同价格;在orders表里保存下单时的address_snapshot,则是为了保证订单创建后即使收货地址被修改,这份订单的配送信息也保持不变。
分类表用parent_id做自关联,支撑无限层级分类。比如“膨化食品”是一级分类,“薯片”是二级分类,“乐事”品牌可以挂在薯片下面。管理后台维护分类树时,前端组件会递归渲染每一层,数据的结构天然就匹配。
3.2 订单状态流转与数据一致性设计
订单状态是商城系统的核心枢纽,我设计了四个状态,配合数据库字段status存储:
待支付(0) → 已支付(1) → 已发货(2) → 已完成(3) ↓ 已取消(4)待支付订单超时未付款,前端展示为“已取消”;用户主动取消待支付订单也算“已取消”。这里涉及一个关键逻辑:取消订单要恢复SKU库存。如果用户提交订单成功但一直不支付,他占用的库存不释放,其他用户就买不到这个商品了,所以无论超时取消还是主动取消,后端都要写一个事务:更新订单状态为已取消,同时把对应SKU的库存加回来。
下单扣库存的核心代码我用事务包裹,保证这两个操作要么全部成功,要么全部失败:
const connection = await pool.getConnection(); try { await connection.beginTransaction(); // 扣减库存 await connection.execute( 'UPDATE product_skus SET stock = stock - ? WHERE id = ? AND stock >= ?', [quantity, skuId, quantity] ); // 创建订单 await connection.execute( 'INSERT INTO orders (order_no, user_id, address_snapshot, total_amount, status) VALUES (?, ?, ?, ?, ?)', [orderNo, userId, addressJson, totalAmount, 0] ); // 写入订单明细 for (const item of items) { await connection.execute( 'INSERT INTO order_items (order_id, sku_id, product_name, spec, price, quantity) VALUES (?, ?, ?, ?, ?, ?)', [orderId, item.skuId, item.productName, item.spec, item.price, item.quantity] ); } await connection.commit(); } catch (error) { await connection.rollback(); // 返回下单失败,购物车数据保持不变 } finally { connection.release(); }注意:SQL里的
AND stock >= ?这个条件很关键,它让“库存不足时更新影响行数为0”,再通过判断受影响行数决定是否回滚。如果只用SET stock = stock - ?,库存为负的时候SQL仍然会成功执行,这是很隐蔽的bug。
4. 前端实现:从Vite初始化到核心页面交互
4.1 工程目录与路由结构
前端我用Vite创建了Vue 3项目,目录结构围绕“页面—视图—组件—状态”这条线来组织:
src/ ├── api/ # 所有接口请求封装 │ ├── product.js │ ├── cart.js │ └── order.js ├── views/ # 页面级组件 │ ├── Home.vue │ ├── ProductList.vue │ ├── ProductDetail.vue │ ├── Cart.vue │ ├── Checkout.vue │ ├── OrderList.vue │ ├── OrderDetail.vue │ ├── Login.vue │ └── Register.vue ├── admin/ # 管理端页面 │ ├── AdminLayout.vue │ ├── ProductManage.vue │ ├── OrderManage.vue │ ├── CategoryManage.vue │ └── Dashboard.vue ├── stores/ # Pinia状态管理 │ ├── cart.js │ └── user.js ├── router/index.js # 路由配置 └── utils/request.js # Axios实例封装路由设计上,用户端和管理端分成了两套布局。用户端路由挂在HomeLayout下,包含首页、商品列表、购物车、订单等页面;管理端路由挂在AdminLayout下,并设置了一个全局前置守卫——只有role字段为admin的用户才能进入管理端路由,否则跳转登录页。
router.beforeEach((to, from, next) => { const userStore = useUserStore(); if (to.meta.requiresAuth && !userStore.token) { next('/login'); } else if (to.meta.requiresAdmin && userStore.role !== 'admin') { next('/'); } else { next(); } });4.2 商品列表页:多条件筛选与分页的实现思路
商品列表页是零食商城用户端最核心的页面。筛选条件包含三级分类、关键字、排序方式(默认综合、销量优先、价格升序/降序),这些条件在页面上看起来是单独的组件,但数据上必须聚合成一个“筛选状态对象”。
用Vue 3的reactive来管理这个状态非常顺手:
const filters = reactive({ categoryId: 0, keyword: '', sortBy: 'default', page: 1, pageSize: 12 }); function handleSearch() { filters.page = 1; loadProducts(); } function loadProducts() { loading.value = true; productApi.getList(filters).then(res => { products.value = res.list; total.value = res.total; }).finally(() => { loading.value = false; }); }分页组件用的是Element Plus的el-pagination,页码变化时修改filters.page再重新请求数据。这里有一个性能优化的细节:切换排序方式时不需要重置页码,但切换分类或关键字时必须把页码重置为1,否则用户在第5页搜索新关键词时,接口收到的还是page=5,很可能返回空白列表。这个逻辑不复杂,但不写的话就是明显的交互bug。
4.3 购物车与结算流程的交互细节
购物车页面用Pinia管理状态,核心数据结构是一个SKU数组,每一项包含skuId、名称、规格、单价、数量、选中状态。
这里最容易踩坑的是“本地计算总价”和“后端计算总价”不一致的问题。用户勾选商品、修改数量后,前端会立刻重新计算选中商品总价展示在页面上,但这个金额只是“预估”,真正下单时必须以服务端根据当前SKU价格重新计算的结果为准,然后把服务端计算出的total_amount展示在确认订单页。否则用户前端显示10元,提交订单时后端算出来12元,就会出现金额对不上的尴尬情况。
结算流程我设计成两步:购物车点击“去结算”时,只把勾选的SKU信息通过路由参数传到确认订单页;确认订单页里重新从后端拉取“商品最新价格+默认地址”,展示给用户确认后,再调用创建订单接口。这个设计能同时保证数据新鲜度和体验流畅度,是电商系统里很常规的做法。
5. 后端实现:Express接口开发与JWT鉴权
5.1 后端工程结构与中间件配置
后端我按“路由层—控制器层—数据访问层”做了三层拆分,而不是把所有逻辑堆在入口文件里:
server/ ├── app.js # Express实例、中间件注册 ├── routes/ # 路由定义 │ ├── auth.js │ ├── product.js │ ├── cart.js │ ├── order.js │ └── admin.js ├── controllers/ # 业务逻辑处理 ├── middleware/ # 自定义中间件 │ ├── auth.js # JWT校验 │ └── adminGuard.js # 管理员权限校验 ├── db/ │ └── index.js # mysql2连接池 └── utils/ ├── response.js # 统一响应格式 └── jwt.js # Token生成与验证工具连接池用mysql2/promise创建,配置里有一个容易被忽略的参数dateStrings: true,把MySQL的日期类型直接返回成字符串,避免前端拿到Date对象后再做格式转换。响应格式我统一封装成{ code: 0, message: 'success', data: ... },成功时code为0,失败时非0。前后端约定一致的响应结构,联调的时候省很多事。
5.2 登录注册与JWT鉴权实现
登录接口是整套系统的鉴权入口。用户提交用户名和密码后,服务端用bcryptjs比对密码哈希,比对成功就生成一个JWT令牌返回前端:
const jwt = require('jsonwebtoken'); function generateToken(user) { return jwt.sign( { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: '7d' } ); }前端拿到Token后存在localStorage里,每次发请求时Axios拦截器自动加上Authorization: Bearer <token>头。后端用一个中间件统一解析和校验所有需要登录才能访问的接口:
const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization || ''; const token = authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ code: 401, message: '未登录或登录已过期' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (error) { return res.status(401).json({ code: 401, message: 'Token无效' }); } };购物车、订单、地址这些接口都要挂上这个中间件,后端通过req.user.id知道当前操作的用户是谁,再校验对应数据确实属于这个用户。比如删除地址的接口,SQL里的条件必须是DELETE FROM addresses WHERE id = ? AND user_id = ?,防止用户通过篡改请求参数操作别人的数据。
5.3 商品和订单核心接口详解
商品列表接口是整个系统调用量最大的接口,SQL层面做了联表查询:
SELECT p.id, p.name, p.description, p.sales_count, ps.id AS sku_id, ps.price, ps.stock, ps.spec_1, ps.spec_2 FROM products p LEFT JOIN product_skus ps ON p.id = ps.product_id WHERE p.status = 1 AND (? = 0 OR p.category_id IN (SELECT id FROM categories WHERE parent_id = ? OR id = ?)) ORDER BY CASE WHEN ? = 'sales' THEN p.sales_count END DESC, CASE WHEN ? = 'price_asc' THEN ps.price END ASC, CASE WHEN ? = 'price_desc' THEN ps.price END DESC, p.id DESC LIMIT ? OFFSET ?处理分类筛选时,我先查当前分类及其所有子分类的ID集合,再用IN查询匹配,这样用户点击“膨化食品”这个一级分类时,底下“薯片”“虾条”等二级分类的商品也能全部展示出来。排序字段用CASE WHEN动态拼进ORDER BY,避免字符串拼接SQL带来的注入口风险。
订单创建接口的完整逻辑是:解析出商品SKU列表 → 校验库存 → 按SKU当前价格算总额 → 扣库存 → 生成订单主表和明细 → 清空购物车对应项 → 返回订单号。这些步骤必须在一个事务里完成,我在上面已经贴过核心代码。有一个容易漏掉的细节点:用户提交订单后,购物车里已经下单的商品要清掉,但只是清掉勾选结算的那几项,没勾选的购物车项要保留。这个细节没处理好,用户会骂“我还没买的零食怎么被清空了”。
6. 联调和部署阶段的五个坑
6.1 跨域问题的完整排查链路
开发阶段最常见的第一个问题是跨域报错。前端跑在localhost:5173,后端跑在localhost:3000,端口不同,浏览器自然拦截跨域请求。我在后端用了cors中间件做处理,但这里有个实际发生的坑:后端配置里写了白名单origin: ['http://localhost:5173'],前端却不走代理而是直接调用了http://localhost:3000接口,导致跨域中间件被绕过后依然报错。
排查过程是这样的:先看浏览器Network面板确认请求有没有发出去,发现OPTIONS预检请求返回了403;再去看后端日志,发现cors中间件的白名单配置没有覆盖到“前端直连3000端口”这种情况。最终处理方案是开发环境统一走Vite代理,生产环境走Nginx反代,cors中间件放开所有来源但只允许带Token请求。这样前后端请求路径只有一个入口,跨域问题从根源上消失了。
6.2 SKU图片管理的两个坑
第一个坑是图片上传后,数据库只存了文件名,没有存完整的访问URL。结果商品详情页直接拼接相对路径,开发环境下能显示,因为前端服务器和后端在同一台机器上;部署到服务器后,前端跑在80端口,图片接口在3000端口,相对路径就指向了错误的位置。后来我统一上传到服务器的/data/uploads目录,接口返回时拼出http://域名/uploads/文件名的完整URL,问题才解决。
第二个坑是商品详情页切换SKU时,图片没有跟着变。零食商城里“原味薯片”和“辣味薯片”通常是不同图片,但前端一开始只绑定了products主表的一张主图,用户切换规格后图片纹丝不动。我把SKU表加了一个image字段,切换SKU时联动更新图片,这才符合用户对商城的基本认知。
6.3 前端路由刷新404与Nginx配置
部署上线后遇到一个经典问题:在商城首页点进商品详情页一切正常,但刷新详情页就变成404了。原因是Vue Router用了history模式,刷新时浏览器向服务器请求了详情页的真实路径,而Nginx默认找不到这个路径对应的文件。
解决办法是在Nginx配置里加一条try_files指令,把所有不存在的路径都回退到index.html,由前端路由接手处理:
location / { root /opt/snack-shop/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这里注意try_files的配置顺序:先匹配真实存在的静态文件,再匹配目录,最后回退到index.html。顺序写反的话,JS、CSS等静态资源也会被错误回退,页面直接白屏。
6.4 并发下单时的库存超卖问题
这是整个项目里最值得讲的一个坑。最初扣库存的SQL写的是UPDATE product_skus SET stock = stock - ? WHERE id = ?,单用户测试完全没问题,但我用并发脚本模拟两个用户同时买最后一个库存时,数据库里出现了-1的负库存。
排查链路是这样的:先怀疑前端重复提交,给下单按钮加了防重复标记,但并发测试依然超卖;再怀疑接口被人绕过前端直接调用,于是把扣库存SQL改成UPDATE product_skus SET stock = stock - ? WHERE id = ? AND stock >= ?,然后通过受影响行数判断库存是否充足。第二版SQL在并发下依然有问题,因为两个请求同时读到stock=1,都通过了stock >= 1的条件,但MySQL的行锁会让两个更新串行执行,结果是第二个更新把库存扣成了-1。
最终的解决办法是让扣库存操作“原子化落地”:直接用一条带条件的UPDATE语句依赖MySQL自身的行锁保证安全。UPDATE product_skus SET stock = stock - ? WHERE id = ? AND stock >= ?执行时,第一个事务先拿到锁、扣库存成功、提交事务;第二个事务等锁释放后重新判断stock >= ?条件,此时库存已经是0,条件不满足,更新影响行数为0,业务层据此判断“库存不足”并回滚订单。这个方案不用引入分布式锁,也不用改代码复杂度,对单机MySQL部署的商城项目来说是性价比最高的做法。
6.5 图片上传的静态文件映射
管理端上传商品图片后,前端能上传成功但看不到预览。排查后发现是后端没有将/uploads目录暴露为静态资源。Express默认是不会提供静态文件服务的,必须手动配置:
const path = require('path'); app.use('/uploads', express.static(path.join(__dirname, '../uploads')));这一步非常基础,但很容易在“功能能跑”的阶段被忽略,直到联调页面时才暴露出来。我建议在后端工程搭建初期就把uploads目录的创建和静态映射写好,不要等开发图片功能时再补。
7. 项目管理端的实现要点
写到这里,用户端的流程已经完整走通了。但一个销售系统只有用户端是不够的,管理端是整个系统能够自运转的后台支撑。我把管理端的几个关键页面也单独说一下,因为它们的设计逻辑和用户端有一定差异。
管理端路由整体挂在AdminLayout下,左侧菜单包含商品管理、分类管理、订单管理、用户管理和数据看板。整个管理端复用了绝大部分Element Plus组件,商品管理页用了一个复杂的弹窗表单:基础信息(名称、描述、分类)、SKU列表(动态增删规格行)、图片上传。
SKU动态列表这个功能值得展开讲一下。用户可以在表单里点击“新增规格”,动态添加一行包含规格名、价格、库存、图片的表单,删除时对应数据也要在界面和提交数据中同步移除。实现上我用一个响应式数组skuList,每行数据绑定skuList[index]的属性,提交时直接把这个数组传给后端,后端在事务里先删掉旧的SKU再批量插入新的SKU。这种处理方式比逐条更新简单得多,而且能避免修改商品规格时出现多余的残留数据。
数据看板我做了两个核心指标:所有商品按sales_count降序排列的销量Top10,以及近7天订单量的折线图。销量排行用一条简单的SQL+聚合就能拿到:
SELECT p.id, p.name, SUM(oi.quantity) AS total_sales FROM order_items oi JOIN products p ON oi.product_id = p.id JOIN orders o ON oi.order_id = o.id WHERE o.status IN (1, 2, 3) GROUP BY p.id, p.name ORDER BY total_sales DESC LIMIT 10近7天订单趋势则用GROUP BY DATE(created_at)分组统计。这两块数据足够撑起一个管理端首页的展示,再做复杂反而容易偏离零食商城的管理场景。
写在最后
这套零食商城系统做了两周,最大的体会是:设计阶段的数据库建模和状态流转设计,比写代码本身更重要。订单状态怎么流转、库存怎么扣减、购物车数据什么时候清理,这些逻辑一旦确定下来,接口开发基本就是按图索骥。我后来帮某同学看他的毕设项目,问题基本都出在这几个地方——表结构里没有独立的SKU表、扣库存没有事务保护、订单状态枚举混乱。如果你的项目也准备用Vue + Node.js做商城,建议先把业务状态流转画清楚、把数据表关系想明白,再动手写第一行代码。这样整个过程会顺畅很多。