做Java后端开发的,尤其是刚入门那阵子,十个人里至少有八个都写过或者想写一个“商城系统”。原因很简单:商城业务足够典型——用户、商品、订单、购物车,几乎把后端CRUD、事务、权限、前后端交互这点事全占齐了。而这套基于SpringBoot+Vue的网上超市管理系统,恰好是这类项目的标准答案之一:后端SpringBoot配合MyBatis操作MySQL,前端Vue负责页面交互,前后端分离,接口走JSON。这套东西不花哨,但确实能帮你把一条完整的业务链路从前到后串起来。
我最早接触这类项目时,其实踩了不少坑:要么是数据库表没设计好,写着写着发现关联关系对不上;要么是前端跨域问题搞了半天;要么是下单时库存扣减和订单生成没放在一个事务里,出现了脏数据。所以当我看完这套“网上超市”的结构之后,我的第一反应是——它把那些新手最容易翻车的地方都提前考虑到了。这篇文章不打算只给你贴一堆源码截图带过,而是按我自己的复盘思路,把整个项目的设计逻辑、核心代码实现、前后端联调细节和踩坑记录都拆开讲一遍,希望能帮你少走几步弯路。
1. 项目整体设计与技术选型
1.1 为什么选SpringBoot+Vue这套组合
网上超市这类系统,本质上是一个典型的电商业务模型,既有面向消费者的前台(商品浏览、搜索、购物车、下单),又有面向运营的后台(商品管理、分类管理、订单处理)。如果用传统的单体JSP页面来做,前后端代码耦合在一起,改一个页面的样式都要重启服务,开发和维护起来会越来越别扭。
这套项目采用的是前后端分离架构:SpringBoot只负责提供RESTful API接口,承载业务逻辑、数据校验、数据库交互;Vue负责页面渲染和用户交互,通过Axios调用后端接口获取数据。两者之间只通过JSON格式的接口通信,前端可以单独用Mock数据开发,后端也可以用Postman先测接口,两边并行推进,联调阶段再合到一起,效率确实高很多。
从学习角度来说,这个组合也非常合适。SpringBoot把Spring那一套繁琐的XML配置简化成了自动配置和起步依赖,降低了上手门槛;Vue的学习曲线相对平缓,组件化写法贴近日常开发直觉。这套项目代码量适中,既不像玩具项目只有增删改查,也不会复杂到让人一看就想放弃,属于那种“可以完整跑通、每一步都能看懂”的类型。
1.2 数据层为什么用MyBatis方案
持久层选MyBatis而不选JPA,我个人的看法是:对于这种SQL逻辑相对复杂的业务系统来说,MyBatis对SQL的控制力更强。网上超市的商品列表往往涉及多条件筛选——按分类、按价格区间、按关键字搜索,这些条件组合用MyBatis的动态SQL来处理很直观,写在XML里一眼就能看清,也方便后续做SQL层面的性能优化。
MySQL则是这套项目的天然搭档,开源免费、文档丰富、大家都在用。项目里的商品、订单、用户这些数据都是标准的关系型数据,用MySQL存储完全够用。如果你的机器配置一般,MySQL 5.7或者8.0都行,注意驱动版本别配错就行。
1.3 核心功能模块划分
从整体功能上看,网上超市系统可以拆成三个大的业务端:
前台用户端:用户注册登录、首页商品展示、商品分类浏览、商品详情查看、购物车管理、订单提交与支付(模拟)、个人订单查看。
后台管理端:商品信息管理(上架、下架、编辑)、商品分类管理、订单状态处理、用户信息管理。
通用支撑模块:登录鉴权、接口数据校验、异常统一处理、分页查询。
这套功能覆盖了主流管理系统的所有核心场景。如果你是自己学习,我建议拿到源码后不要急着跑起来就完事,而是先把功能模块和数据表对应关系理清楚,弄明白一个完整的“用户点击购买”请求到底经过了哪些层、哪些表,这比你单纯把界面点点点一遍有价值得多。
2. 数据库设计:网上超市的地基
2.1 核心表结构与关联关系
数据库设计是这类项目的灵魂。我见过不少项目代码写得还行,但数据库表设计得一塌糊涂,比如订单表里反复存冗余的用户名、商品表缺少分类关联字段。这套项目的表结构比较标准,核心几张表可以这样理解:
用户表(user):存储账号、密码、昵称、手机号、头像、注册时间等基本字段。
分类表(category):商品分类,一般设计成父子层级或者扁平结构,本项目里用扁平结构就够用,包含分类名称、排序号、状态。
商品表(product):包含商品名称、副标题、主图、详情描述、分类ID、价格、库存、销量、上下架状态。
购物车表(cart):关联用户ID和商品ID,记录加购数量、选中状态。
订单表(orders):记录订单编号、用户ID、总金额、订单状态、收货信息、下单时间、支付时间。
订单明细表(order_item):一个订单对应多条商品记录,记录购买时的商品快照(名称、单价、数量、小计)。
从关系上看,用户对商品是多对多,中间通过购物车表和订单明细表体现;分类对商品是一对多;订单对订单明细是一对多。这套设计不复杂,但完全符合电商的常规玩法。
2.2 订单状态与库存联动
订单状态是这个项目的关键点之一。网上超市的订单流程一般是:待支付、已支付/待发货、已发货、已完成、已取消。每次状态变更都会影响库存和用户可见信息,所以状态字段建议用数字枚举而不是直接存中文,比如0待支付、1已支付、2已发货、3已完成、4已取消,后端用常量类统一维护,前端对应做文本映射。
库存扣减要特别注意:用户下单时扣库存,取消订单/退款时回补库存。这里如果处理不当很容易出现库存负数或者超卖问题。最稳妥的做法是在SQL层加条件判断,比如UPDATE product SET stock = stock - 1 WHERE id = #{id} AND stock > 0,用受影响行数判断是否扣减成功,而不是先查询再判断再更新,这样能避免并发下的覆盖问题。
2.3 字段设计与命名规范
数据库字段设计有几个细节值得留意:金额字段一律用decimal(10,2),不要用float或double,否则算总价的时候会出现0.1+0.2不等于0.3这种尴尬情况;商品图片URL字段长度给到255或更长,别设成50个字符,线上图片链接很容易超;时间字段直接用datetime类型,插入时由后端统一处理,别把字符串塞进去。
逻辑删除也是这类系统的常见做法。商品和订单不建议物理删除,加一个deleted字段,默认0,删除时改成1即可。查询列表时统一带上deleted = 0条件,这样能保留完整数据链,方便以后做数据分析。索引方面,商品表的分类ID、商品名称,订单表的用户ID、订单编号这几个字段是高频查询条件,都应该建立普通索引。
3. 后端核心实现与关键代码
3.1 SpringBoot工程结构与分层
拿到源码第一件事不是急着运行,而是先看包结构。这套项目的后端包划分比较典型,理解之后对任何SpringBoot项目都有借鉴意义。
com.supermarket ├── controller(接口层,接收请求、返回结果) ├── service(业务逻辑层,处理具体业务) ├── mapper(持久层接口,对应MyBatis的Mapper) ├── entity(实体类,对应数据库表) ├── dto(数据传输对象,接收前端参数) ├── vo(视图对象,返回前端数据) ├── config(配置类,比如拦截器、跨域配置) ├── common(公共类:统一返回值、异常、常量、工具类)分层的最核心原则是:Controller不做业务逻辑,只做参数接收和结果返回;Service处理业务规则;Mapper只做SQL操作。刚开始写代码的人最容易犯的错误是把一堆业务判断写在Controller里,导致接口层越来越臃肿。这套项目的分层值得照着学,后续如果你自己写别的系统,照搬这套结构基本不会出大问题。
3.2 登录鉴权的实现方式
登录模块使用的是JWT配合拦截器的方式。用户输入账号密码后,后端校验正确,生成一个token返回给前端。前端把token存到localStorage或者Vuex里,每次请求时放在请求头中携带给后端。
后端拦拦截器的核心逻辑很简单:只放行登录注册接口,其他接口统一验证token。token有有效期,过期就返回401,前端收到401后自动跳转到登录页。这种无状态鉴权方式不需要在后端保存session,对前后端分离部署非常友好。
配置拦截器时有个常见坑:静态资源和预检请求OPTIONS会被拦截。如果不对OPTIONS放行,前端联调时跨域请求会直接被挡掉。正确做法是在拦截器里加个判断,请求方法是OPTIONS就直接return true。
3.3 商品列表查询的MyBatis动态SQL
商品查询是前台最核心的接口。这里我用MyBatis动态SQL来实现多条件组合查询,避免了写一堆if判断拼SQL的麻烦。
<select id="selectProductList" resultType="com.supermarket.vo.ProductVO"> SELECT p.id, p.name, p.sub_title, p.main_image, p.price, p.sales, p.stock, c.name AS category_name FROM product p LEFT JOIN category c ON p.category_id = c.id <where> p.deleted = 0 <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.sub_title LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND p.price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.price <= #{maxPrice} </if> <if test="isHot != null and isHot == true"> AND p.sales > 100 </if> </where> ORDER BY p.id DESC </select>这里重点看<where>标签的妙用:它会自动去掉第一个多余的前缀AND,避免SQL语法错误。价格区间判断里用的是转义字符>和<,因为XML中对大于小于号有特殊规则,直接写>会解析报错,这个是新手最容易忽视的细节。
分页我建议直接用PageHelper插件,用法很简单,查询前调用PageHelper.startPage(pageNum, pageSize),紧接着执行Mapper查询,返回结果会自动封装成带total的PageInfo对象,前端就能拿到总条数和总页数,不用自己写Limit搞得乱七八糟。
3.4 购物车与下单的事务处理
下单是整个系统里最需要谨慎对待的操作。以“提交订单”为例,它的业务链路是:校验购物车选中商品、计算总金额、生成订单主记录、逐条生成订单明细、扣减库存、清空购物车。这中间任何一步失败,都会造成数据不一致,所以必须放在一个事务里。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 获取购物车选中商品列表 List<CartItem> cartItems = cartMapper.selectCheckedItems(dto.getUserId()); if (cartItems.isEmpty()) { throw new ServiceException("没有选中的商品"); } // 2. 计算订单总金额 BigDecimal totalPrice = BigDecimal.ZERO; for (CartItem item : cartItems) { totalPrice = totalPrice.add(item.getPrice().multiply( new BigDecimal(item.getQuantity()))); } // 3. 创建订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setTotalPrice(totalPrice); order.setStatus(OrderStatus.UNPAID); orderMapper.insert(order); // 4. 生成订单明细并扣库存 for (CartItem item : cartItems) { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(item.getProductId()); orderItem.setProductName(item.getProductName()); orderItem.setProductImage(item.getProductImage()); orderItem.setPrice(item.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); int rows = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new ServiceException("商品库存不足:" + item.getProductName()); } } // 5. 清空购物车 cartMapper.clearCheckedItems(dto.getUserId()); return order.getId(); }这里有几个细节很关键:@Transactional(rollbackFor = Exception.class)必须加上rollbackFor,否则默认只在RuntimeException时回滚,检查异常不会触发回滚;库存扣减用受影响行数判断是否成功,而不是先查库存再更新;订单明细中的商品名称、价格必须做快照,不能直接去关联商品表查询,因为商品改价后历史订单不能被影响。订单号也建议用时间戳加随机数生成,避免并发下重复。
4. 前端Vue实现与页面交互
4.1 前端工程结构与路由设计
前端部分用的是Vue 2加Element UI,这套组合在管理类系统中非常成熟,组件全、文档多、踩坑资料也丰富。工程目录结构如下:
src ├── api(接口请求封装,按模块拆分) ├── assets(静态资源) ├── components(公共组件) ├── router(路由配置) ├── store(Vuex状态管理) ├── views(页面视图) │ ├── home(前台页面:首页、商品列表、详情) │ ├── cart(购物车页面) │ ├── order(订单相关) │ ├── user(个人中心) │ └── admin(后台管理页面) ├── utils(工具函数) ├── App.vue └── main.js路由设计上需要做权限区分:普通用户访问前台,管理员访问后台。为了避免未登录用户直接输入URL进入后台,需要在路由守卫里做判断。我建议在main.js里加一个全局前置守卫:遍历要跳转的路由配置,如果路由meta里标记了requiresAdmin,就检查本地存储的用户角色,不是管理员就跳回首页。
4.2 Axios封装与Token注入
一个成熟的前端项目不会在每个页面里直接使用axios,而是先封装一个统一请求模块,挂在Vue实例上,或者在组件里引入后使用。封装的目的是统一处理BaseURL、请求头、错误提示、登录过期跳转。
import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 15000 }); // 请求拦截器,自动携带token service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }); // 响应拦截器,统一处理后端返回结果 service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { this.$message.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); this.$router.push('/login'); } return Promise.error(error); } ); export default service;这里要注意的是BaseURL。开发时前端通过Vue脚手架起在8080端口,后端在8081端口,跨域问题我们通过后端CORS解决,但更推荐的方式是在前端vue.config.js里配置代理转发,把/api开头的请求代理到http://localhost:8081,这样浏览器视角就不存在跨域了,线上部署时再用Nginx做反向代理,配置思路是一致的。
4.3 商品列表与详情页交互
前台首页的商品列表我用的是Element UI的Card卡片布局,配合el-row和el-col做栅格。商品卡片上的关键信息有:商品主图、名称、价格、销量、库存状态。库存为0的商品要置灰显示“已售罄”,有货的商品显示“加入购物车”按钮。
详情页的核心是商品信息展示和购买操作。商品数据通过路由参数id调用getProductDetail接口获取。页面下方是商品详情区,这里建议直接展示后端返回的富文本或者图片列表,不一定要把这个项目做成复杂的多图轮播,但主图放大查看和加购按钮的交互一定要顺畅。
搜索功能在前台很常用,首页顶部搜索框绑定了搜索关键字,提交后跳转到商品列表页,列表页在created钩子里读取URL查询参数,作为请求条件传给后端。这里有一个小坑:查询参数变了,列表数据不会自动刷新,需要监听路由变化并重新请求,新同学往往会漏掉这一步。
4.4 购物车与结算流程
购物车页面主要展示已加入的商品列表,每行包含商品图片、名称、单价、数量加减器、小计和复选框。选中状态我用Vuex里的一个数组来维护,便于在结算页统一获取所有选中项。
数量加减比较考验前端联调能力:每点击一次加减按钮,需要调用后端接口更新购物车数量,同时重新计算当前行的小计和购物车总金额。这个操作不建议每点一次就重新拉一遍购物车列表,那样会闪烁而且消耗大;更合理的做法是前端本地先更新数量和金额,在用户离开页面或者结算时再批量同步到后端。
点击“去结算”按钮时,需要校验登录状态。如果没登录,弹出提示并跳转登录页;已登录则把购物车里所有选中的商品信息带到订单确认页。订单确认页展示商品清单、总金额、收货人信息,确认后提交“创建订单”接口,后端返回订单号后跳转到支付页面。支付是模拟的,点击“模拟支付成功”后调用支付确认接口,把订单状态改为已支付,同时展示支付成功页面。
5. 环境准备与前后端联调实战
5.1 本地环境与项目启动
先列一下我实际跑通这套项目所用的环境:
- JDK 1.8(如果用的是SpringBoot 2.7及以下版本,1.8完全够用)
- Maven 3.6+(依赖下载慢就配阿里云镜像)
- MySQL 5.7(8.0也可以,但要注意驱动版本和时区配置)
- Node.js 14+,npm或者yarn都行
- 前端脚手架(Vue CLI,版本建议4.x或5.x)
后端启动前,先修改application.yml里的数据源配置:
server: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.supermarket.entity数据库需要提前导入项目自带的SQL脚本,然后在Maven里执行clean install打包,或者直接在IDE里启动Application类。前端进入工程目录后先执行npm install安装依赖,再执行npm run serve启动开发服务。整个启动过程其实不复杂,但环境问题导致的坑不少,下面统一整理。
5.2 跨域问题与接口调试
前后端分离的项目,跨域是绕不开的坎。后端接口在8081,前端页面在8080,浏览器出于同源策略会拦截跨域请求。解决方式有两种,项目里常见的是后端加CORS支持。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowedOrigins("*")在新版本SpringBoot里和allowCredentials(true)会有冲突,需要改用allowedOriginPatterns("*")。另外前面提到过,如果项目里配置了登录拦截器,一定要放行OPTIONS预检请求,否则前端接口看起来像是通了,但请求头里的自定义内容发不过去。
个人建议是开发阶段直接在vue.config.js里配代理,前端代码里请求路径全部写成相对路径/api/xxx,后端不打乱接口路径,这样联调更舒服,还能顺便避免线上部署时的跨域问题。调接口时配合Postman或者Apipost,先在后端单测接口,通过后再来对接前端,能少排查不少问题。
5.3 联调常见问题速查表
我整理了这套项目从开发到跑通过程中最容易遇到的一批问题,按排查顺序列出来:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 后端启动报数据库连接失败 | 数据库未启动、密码错误、驱动版本不对 | 检查MySQL服务,核对application.yml配置,注意MySQL 8要配com.mysql.cj.jdbc.Driver且加时区参数 |
| Maven依赖下载特别慢 | 没有配置国内镜像 | 在settings.xml里配置阿里云镜像仓库 |
| 前端npm install报错 | Node版本过低或网络问题 | 升级Node到14+,删除node_modules和package-lock.json后重装 |
| 请求接口返回404 | 前端路由路径和后端controller路径不一致 | 检查controller类上有没有加类级@RequestMapping,连接是否漏了前缀 |
| MyBatis报BindingException | Mapper接口和XML映射文件没绑定 | 检查mapper-locations配置、XML文件namespace是否等于接口全限定名 |
| 请求返回401 | 登录token过期或前端没传token | 重新登录,检查请求拦截器是否注入了Authorization头 |
| 下单后库存没变 | 扣库存的SQL条件有问题或事务没生效 | 检查deductStock的SQL,确认Service类被Spring管理,确认事务注解配置正确 |
| 图片加载不出来 | 图片存的是相对路径或没做静态资源映射 | 把图片放到Nginx/后端静态目录,或配置静态资源映射地址 |
| 支付后订单状态没变 | 支付回调接口没调用或状态更新条件写错 | 检查支付按钮绑定的方法,确认调用的是已支付更新接口 |
这张表不夸张,基本上把这几个问题解决掉,项目联调就通了一大半。剩下的都是些小细节,比如Element UI表格列字段名对不上、Vue路由跳转后页面不刷新之类的,按控制台报错逐步排查就行。
6. 避坑经验与后续升级方向
6.1 实战中容易踩的几个坑
这套项目虽然完整,但如果你不是直接照着跑,而是想自己动手敲一遍,有几个地方我建议你多留个心眼。
第一个是金额精度。我在第二章强调过用decimal,这里再补充一个场景:如果你在前端直接把价格当数字相加,JavaScript的浮点运算同样会出现精度问题,正确的做法是前端只做展示,后端算好总金额返回给前端,前端不要参与任何金额计算。
第二个是密码加密。项目里用户密码一定不要明文存库,用BCrypt或者至少MD5加盐处理。很多网上超市的演示源码并没有做这一步,你可以自己加上加密逻辑。注册时加密存储,登录时用匹配器校验。
第三个是状态管理混乱。如果同一份购物车数据既在页面里用了,又想在路由跳转后保持状态,必须用Vuex统一管理,不要每个组件里各存一份localStorage。否则用户改个数量、换个页面回来,数据对不上。
第四个是商品图片路径问题。开发时用相对路径,比如/upload/xxx.jpg,前端直接访问会404。解决方案是后端配置静态资源映射,把/upload/**映射到本地磁盘目录。到了生产上,这种位置建议直接挂到对象存储上,项目里能跑通就行,但你要理解这个设计思路。
6.2 项目可以继续做哪些升级
如果这段代码已经跑通,我强烈建议你试着做几个方向的改造,这对理解整个系统非常有帮助。
想做性能优化,就引入Redis做商品热榜缓存和购物车缓存。商品详情接口的高频访问肯定会压数据库,用Redis存一份热点数据能明显降低数据库压力。
想做业务闭环,就对接真实的支付接口,比如支付宝沙箱或微信支付沙箱。支付回调、订单超时关闭、退款流程这几个场景一加,你的订单模块就更贴近生产环境了。
想做工程化改造,就加上日志框架和全局异常处理器。现在可能只用了logback默认配置,把操作日志、异常日志打印完整,排查问题时能少花大量时间。
手上如果还有余力,可以把后台改成更完整的运营后台,加上统计分析、商品上下架审核、订单导出这些功能。做一圈下来,你对这套网上超市系统的理解就不是停留在“跑通了”,而是真正具备了自己设计一个业务系统的能力。
这套项目我断断续续看过几遍,每次都有不同的体会。第一次关注的是怎么把代码跑起来,第二次开始琢磨数据库表和订单流程的耦合关系,第三次看的就是事务边界和并发扣库存这些深坑了。所以如果你现在刚拿到源码,我建议你别只停留在“能登录、能下单”这个层面,试着从数据库出发,把用户、商品、购物车、订单、库存这条链路完整画一遍,哪怕暂时不动代码,整个系统的脉络也会清晰很多。之后再上手改代码,比如说给商品加个热销排序、给订单加个退款入口,你都会发现自己心里有底得多。动手实践和读成熟代码从来都是两回事,两者结合才是这类项目最正确的打开方式。