来聊聊这个网上超市管理系统
做这种“基于SpringBoot+Vue的网上超市管理系统”,说实话算是全栈开发里非常典型的一类练手项目,也是很多Java后端工程师入门时绕不开的实战课题。它不是一个“玩具级”的CRUD堆砌,而是把一个真实的电商业务——从商品展示、购物车到订单流转、库存扣减——完整落地,前端用Vue做交互,后端用SpringBoot撑接口,数据库用MySQL存数据,MyBatis负责SQL映射。这套组合同时也是国内中小型公司后端项目里出现频率最高的技术栈之一,你把这个系统吃透了,等于把Java后端岗位日常开发的主干线摸了一遍。
这篇文章我就按照自己实际做这一类项目的经验,把网上超市管理系统从需求拆解、数据库设计、后端接口实现到前端页面联动,完整过一遍。适合谁看?准备做毕业设计的在校生、自学Java想攒实战项目的初学者,还有刚入行想系统梳理全栈流程的后端开发。看完你不仅能复现这套系统,更能理解每一个设计决策背后的理由。
1. 整体设计:从需求到模块,先想清楚再动手
1.1 核心需求解析:网上超市到底要解决什么问题
很多人拿到“网上超市管理系统”这个题目,第一反应就是赶紧建表、写接口。但做过真实电商项目的都知道,需求分析阶段漏掉一个细节,后面可能要重构一整套逻辑。网上超市这个场景,本质上要解决三件事:用户怎么逛、用户怎么买、管理员怎么管。
用户怎么逛,指的是商品分类导航、商品列表展示、商品详情查看,以及关键词搜索。这里不是简单把商品表的数据查出来返回给前端就行,要考虑分类树怎么组织、商品上下架状态怎么控制、库存不足的商品怎么展示、搜索是走数据库模糊查询还是引入Elasticsearch(小项目里一般用模糊查询就够了)。这些看似基础的功能,恰恰决定了用户侧体验的下限。
用户怎么买,包括加入购物车、修改数量、批量结算、生成订单、模拟支付、查看订单状态。这里有个容易忽视的点:购物车数据存哪里。很多课设项目图省事直接存前端localStorage,但真实系统里购物车必须存后端,因为用户可能换设备、清缓存,而且购物车数据是后续生成订单的唯一依据,必须可靠。订单这块更是核心,涉及订单状态流转、库存扣减时机、超时未支付怎么处理,每一步都有学问。
管理员怎么管,包括商品管理(增删改查、上下架、库存调整)、分类管理、订单管理(发货、退款处理)、用户管理、统计报表。后台管理系统的核心诉求是效率和清晰,所以前端会单独做一套管理界面,跟用户端分开,权限也要区分。这部分看起来是“内部工具”,但功能量往往比用户端还大。
1.2 技术选型:为什么是SpringBoot + Vue + MySQL + MyBatis
技术选型这件事,我见过太多人踩坑了。有的同学一上来就追新,SpringBoot版本挑最新的3.x,JDK直接上17,结果MyBatis插件不兼容、前端依赖报错,光配环境就花了一周。选技术栈的第一原则是稳定、生态成熟、资料多,而不是最新。
SpringBoot 2.x系列是目前最稳妥的选择,文档丰富、社区问题沉淀多,大部分教程和源码也都是基于2.x,遇到问题一搜就能找到答案。SpringBoot解决了传统SSM项目大量XML配置的痛点,内嵌Tomcat,一个jar包就能跑起来,这对做项目来说省了太多事。
前端选Vue是因为它上手曲线平缓、中文资料多,而且Vue的双向绑定和组件化思维对后端同学转前端非常友好。Vue 2配合Element UI做后台管理界面,Vue 3配合Element Plus做新项目,这两个组合都是极其成熟的方案。我做这个项目用的是Vue 2 + Element UI,不是因为Vue 3不好,而是Element UI的组件更全、踩坑记录更多,应届生和初学者用起来更顺手。
MySQL作为关系型数据库,免费、轻量、通用性强,配合MyBatis做ORM映射,SQL由开发者自己掌控,既能享受SQL的灵活性,又能避免JDBC那套繁琐的样板代码。MyBatis对比JPA的优势在于:复杂多表查询时SQL逻辑清晰直观,性能可控性强,这也是国内很多公司仍然选择MyBatis的原因。
1.3 系统功能模块划分:前后台分离,权限分级
整个系统我把它拆成两个端:用户端(前台)和管理员端(后台)。
用户端面向普通消费者,功能模块包括:用户注册登录、首页轮播图和商品推荐位、商品分类浏览、商品列表搜索与排序、商品详情页、购物车管理、订单确认与提交、模拟支付、个人中心(个人信息修改、收货地址管理、我的订单)。这些模块组合起来,覆盖了“逛、选、买、跟”的完整消费链路。
管理员端面向运营人员,功能模块包括:管理员登录、工作台数据概览(今日订单数、销售额、库存预警)、商品管理(新增/编辑/上下架/批量操作)、分类管理(树形分类的增删改)、订单管理(按状态筛选、发货操作)、用户管理(查看用户列表、禁用/启用账号)、系统设置。所有的写操作都需要管理员权限校验,避免越权操作。
这里强调一个设计细节:用户端和管理员端虽然是两套界面,但共用同一个后端服务,通过拦截器对接口做角色权限校验。用户接口需要JWT令牌鉴权,管理员接口需要额外的角色判断,这样的划分既保证了安全性,也保持了代码的复用性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:表结构合理,后面能省一万个麻烦
2.1 核心数据表设计思路
数据库设计是整个系统的地基。我见过太多项目因为表设计不合理,写业务代码时各种join查不出来数据,或者需要频繁改表结构,所以表设计阶段一定要想清楚。
我设计的表结构一共10张表,在这里把核心的表和字段讲清楚:
-
user用户表:id、username、password(BCrypt加密存储)、nickname、phone、email、avatar、status(0禁用1正常)、create_time。用户表是所有业务表的“源头”,订单、购物车、地址都通过user_id关联。 -
category商品分类表:id、parent_id、name、level、sort、icon。parent_id支持父子级分类,比如“食品饮料”下面可以挂“休闲零食”“饮料冲调”,前端用递归组件渲染分类树。 -
product商品表:id、category_id、name、subtitle(副标题)、main_image、detail(富文本详情)、price(单位:分)、stock(库存)、status(0下架1上架2售罄)、sales(销量)、create_time、update_time。price用int存“分”而不是decimal存“元”,这是电商项目里避免浮点数精度问题的标准做法。 -
cart_item购物车表:id、user_id、product_id、quantity、checked(是否勾选结算)、create_time、update_time。联合唯一索引user_id + product_id,防止同一用户重复加入同一个商品。 -
order订单表:id、order_no(唯一订单号)、user_id、total_amount、pay_amount、freight_amount、status(0待付款1待发货2已发货3已完成4已取消)、receiver_name、receiver_phone、receiver_address、pay_time、delivery_time、finish_time、create_time。 -
order_item订单明细表:id、order_id、product_id、product_name、product_image、current_price(下单时的商品快照价格)、quantity、total_price。这里有个关键设计:商品名称和价格必须做“快照”,不能join商品表实时查。因为商品可能改价、改名、甚至被删除,但订单作为历史数据必须保持当时的真实情况。 -
shipping收货地址表:id、user_id、receiver_name、receiver_phone、receiver_province、receiver_city、receiver_district、receiver_address、is_default。 -
admin管理员表:id、username、password、role(区分超级管理员和普通管理员)、last_login_time。管理员表单独建,跟用户表分开,职责和权限都不同。 -
carousel首页轮播图表:id、image_url、redirect_url、sort、status。 -
banner(或推荐位表):id、type、product_id、sort、status。用于首页推荐商品位,运营后台可配置。
2.2 字段类型、索引与常用SQL的优化说明
表结构设计里,字段类型和索引是按常用SQL语句倒推的,不是凭空拍的。
价格字段,我用int存“分”。比如价格129.00元,数据库存12900。为什么不用decimal(10,2)?因为Java里用BigDecimal处理还好,但有些情况如果你不小心用了double来接收,会出现0.1 + 0.2 = 0.30000000000000004这种经典精度问题。用int存分,加减计算全部是整数运算,万无一失,展示时除以100转成元即可。
库存字段stock,类型用int,业务上还要加“乐观锁”控制的版本号version字段。后面下单扣库存会用到UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity},这条SQL本身就是原子安全的,如果影响行数为0说明库存不足。加上version字段,是为了防止并发情况下超卖。
索引的设计,核心几条SQL是这么跑的:
sql复制-- 商品列表按分类查询
SELECT * FROM product WHERE category_id = #{categoryId} AND status = 1 ORDER BY sales DESC LIMIT #{offset}, #{pageSize};
-- 用户购物车列表
SELECT * FROM cart_item WHERE user_id = #{userId};
-- 用户订单列表
SELECT * FROM `order` WHERE user_id = #{userId} ORDER BY create_time DESC;
所以category_id、user_id、order_no这三个字段必须建索引。order_no还要建唯一索引,因为订单号不可重复,这是业务上的硬性要求。
建表时养成习惯,一律使用InnoDB引擎、utf8mb4字符集。InnoDB支持事务,订单创建、扣库存、清购物车这三个操作必须在一个事务里完成;utf8mb4是为了支持emoji和一些特殊字符,商品名、收货地址都可能出现。表名和字段名用反引号包起来,避免跟MySQL保留字冲突,比如order这个表名就是MySQL的保留字,必须加反引号。
这里说一个建表时容易忽略的点:所有表都要带create_time和update_time字段,类型用datetime,默认值分别设为CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP。这样插入和更新记录时,时间戳自动维护,不用在代码里手动set当前时间,省事且统一。
3. 项目工程结构与前后端环境搭建
3.1 后端工程结构:按模块分包,不按技术分层
搭建SpringBoot工程的时候,我见过很多新人喜欢按技术分层建包,比如controller包、service包、mapper包一锅端。这种方式在小项目里没问题,但业务多起来之后找代码很痛苦。我更推荐按业务模块分包,把在线超市系统划分为若干业务域,每个域内部自己管controller、service、mapper。
我的后端工程目录结构大致是这样的:
text复制com.supermarket
├── common // 通用类:统一返回结果、异常处理、工具类
│ ├── Result.java
│ ├── ResultCode.java
│ ├── GlobalExceptionHandler.java
│ └── JwtUtil.java
├── config // 配置类:MyBatis分页插件、CORS跨域、拦截器注册
│ ├── WebMvcConfig.java
│ ├── MyBatisPlusConfig.java(如果用MyBatis-Plus就配这个,原生MyBatis则简化)
│ └── ...
├── interceptor // 拦截器:JWT令牌校验、管理员权限校验
│ ├── LoginInterceptor.java
│ └── AdminInterceptor.java
├── module
│ ├── user // 用户模块
│ │ ├── controller
│ │ ├── service
│ │ ├── mapper
│ │ └── entity
│ ├── product // 商品模块(商品、分类)
│ ├── cart // 购物车模块
│ ├── order // 订单模块
│ └── admin // 后台管理模块(商品管理、订单管理、统计)
└── SupermarketApplication.java
按业务分包之后,加一个“商品改价”的功能,你直接到product模块里改,不用在几十个controller里找哪个是管商品的。这也是很多公司从单体过渡到微服务时的思想——先按业务域把代码物理隔离,将来拆服务会轻松得多。
3.2 数据库初始化与MyBatis配置细节
数据库的初始化我用sql脚本管理,里面包含建库语句、建表语句、初始化数据(管理员账号、测试分类、测试商品)。这里强烈建议不手动在Navicat里一条条建表,而是写一个完整的init.sql脚本,用source命令一次执行。脚本的好处是:仓库里留底、别人拉下来直接跑、环境部署时可重复执行。
关键配置写在application.yml里:
yaml复制server:
port: 8080
servlet:
context-path: /api
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 你的密码
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.supermarket.module.*.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case这个配置肯定要开,它能把数据库的create_time自动映射成实体的createTime,省掉大量手写resultMap的麻烦。log-impl在开发阶段开成stdout能看到完整SQL,排查问题很有用,上线前再关掉。
3.3 前端工程搭建:Vue2 + Element UI + Axios + Vue Router
前端我用vue-cli初始化的项目。虽然现在Vite已经成了新宠,但对新手来说vue-cli的webpack配置资料多、报错信息友好,跟着教程走不容易卡住。
前端目录结构是这样的:
text复制src
├── api // 接口请求模块(按业务域划分)
│ ├── product.js
│ ├── cart.js
│ ├── order.js
│ └── user.js
├── assets // 静态资源(图片、全局样式)
├── components // 通用组件(分页、空状态、商品卡片)
├── router // 路由配置
│ └── index.js
├── store // Vuex状态管理
│ └── index.js
├── views // 页面视图
│ ├── home
│ ├── product
│ ├── cart
│ ├── order
│ ├── user
│ └── admin
├── utils // 工具函数(axios封装、token存取)
│ ├── request.js
│ └── auth.js
├── App.vue
└── main.js
request.js对axios做了统一封装,所有请求自动携带JWT令牌,响应非200状态码时统一弹出错误提示,401跳转登录页。这个封装是前端工程质量的分水岭——不做封装的话,每个页面都得重复写错误处理,代码冗余不说,改一处逻辑要动几十个文件。
安装依赖我用的是np m,虽然速度比不上pnpm,但兼容性最稳。npm install的时候如果卡住,切换一下国内镜像源(registry.npmmirror.com)基本能解决。这一点后面常见问题里还会细说。
4. 核心功能实现:从登录鉴权到订单流转
4.1 基于JWT的用户登录鉴权与拦截器配置
用户登录鉴权,我用的是JWT(JSON Web Token)方案。流程是这样的:前端把用户名密码POST给后端/api/user/login,后端校验通过后生成一个JWT令牌返回给前端,前端存到localStorage里,之后每次请求在请求头里加Authorization: Bearer <token>。后端拦截器从请求头取出token,验证签名和过期时间,然后把用户信息塞到request的attribute里,业务接口就能拿到当前登录用户了。
具体实现的核心代码:
后端登录接口:
java复制@PostMapping("/login")
public Result login(@RequestBody LoginRequest request) {
// 1. 根据用户名查用户
User user = userService.findByUsername(request.getUsername());
// 2. 判断用户是否存在、密码是否正确(password是BCrypt密文)
if (user == null || !BCrypt.checkpw(request.getPassword(), user.getPassword())) {
return Result.error("用户名或密码错误");
}
// 3. 判断用户状态是否被禁用
if (user.getStatus() == 0) {
return Result.error("账号已被禁用,请联系管理员");
}
// 4. 生成JWT token,有效期2小时
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
String token = JwtUtil.createToken(claims, 60 * 60 * 2);
// 5. 脱敏返回用户信息 + token
user.setPassword(null); // 绝对不能把密码返回给前端
return Result.success(new HashMap<String, Object>() {{
put("token", token);
put("userInfo", user);
}});
}
登录拦截器:
java复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行预检请求
if ("OPTIONS".equals(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
try {
Claims claims = JwtUtil.parseToken(token);
// 把用户信息放入request,方便后续业务逻辑直接取
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("username", claims.get("username"));
return true;
} catch (Exception e) {
// token过期或非法
}
}
response.setStatus(401);
response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}");
return false;
}
}
这里有几个容易踩坑的点,我一个个说:
- 密码绝对不能用MD5存。MD5是摘要算法,不是安全加密,彩虹表一查就破。用
BCrypt加盐哈希,每次加密结果都不同,安全性好很多。Spring Security里自带BCryptPasswordEncoder,但如果不想引入整个Spring Security,单独引一个spring-security-crypto依赖也行。 - 登录接口、商品列表、商品详情这些接口必须放行,拦截器不能把所有请求都拦了。我的做法是:在WebMvcConfig里注册拦截器时用
excludePathPatterns放行白名单,包括login、register、商品查询相关的url。 - JWT的过期时间别设太长,2个小时比较合理。用户操作中如果token过期,前端axios拦截器捕获401后跳转登录页,同时清空本地存储的token和用户信息。
- 管理员端接口单独加一层拦截器,登录拦截器通过之后再校验该用户是否有admin角色。拦截器链的执行顺序是登录拦截器先跑,然后再跑管理员拦截器,所以注册时要设置顺序。
4.2 商品管理:分类树、列表分页与上下架状态控制
商品模块是用户端和管理员端共用的核心模块,但关心的事情完全不同。
用户端关心的是:能看到什么商品,按什么顺序展示。我的实现思路是:首页加载一级分类导航(渲染顶部分类栏)→ 点击分类后级联查出子分类下的所有商品 → 商品列表支持按价格升序、降序、销量排序 → 搜索框支持按商品名称模糊搜索。
管理员端关心的是:商品怎么录入、怎么改、怎么上下架。后台商品管理页面用的是表格展示在售和已下架的商品,提供搜索和筛选功能,编辑弹窗里的商品信息包含基础信息、价格库存、图片上传、富文本详情。
商品列表分页查询的后端实现:
java复制@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "12") Integer pageSize,
@RequestParam(required = false) Integer categoryId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String sort) {
// 1. 分页参数处理,pageNum不能小于1
pageNum = pageNum < 1 ? 1 : pageNum;
// 2. 使用PageHelper分页插件,下一行执行的查询会自动带上LIMIT
PageHelper.startPage(pageNum, pageSize, "status = 1");
// 3. 动态SQL查询
List<Product> productList = productMapper.selectList(categoryId, keyword, sort);
// 4. 封装为PageInfo,拿到总条数和总分页数
PageInfo<Product> pageInfo = new PageInfo<>(productList);
// 5. 返回给前端的数据里带上total和list
return Result.success(pageInfo);
}
对应的MyBatis XML动态SQL是这样的:
xml复制<select id="selectList" resultType="com.supermarket.module.product.entity.Product">
SELECT id, name, subtitle, main_image, price, stock, status, sales
FROM product
<where>
<!-- 前台只查上架商品,后台查询不过滤status -->
<if test="status != null">
AND status = #{status}
</if>
<if test="categoryId != null">
AND category_id IN (
SELECT id FROM category
WHERE id = #{categoryId} OR parent_id = #{categoryId}
)
</if>
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
</where>
<choose>
<when test="sort == 'price_asc'">ORDER BY price ASC</when>
<when test="sort == 'price_desc'">ORDER BY price DESC</when>
<when test="sort == 'sales_desc'">ORDER BY sales DESC</when>
<otherwise>ORDER BY create_time DESC</otherwise>
</choose>
</select>
这段SQL里有个细节:分类查询用了IN子查询,同时查当前分类和它的子分类。这样用户点击一级分类“食品饮料”时,下面挂的二级分类“休闲零食”“饮料冲调”的商品也会一起显示。用<choose>处理排序比在Java代码里拼字符串安全得多,避免SQL注入的同时,逻辑也更直观。
商品上下架的状态控制,本质上就是一个字段的变更,但业务上要考虑周全。下架商品时,如果它正在某个用户购物车里,用户在结算时接口要校验商品状态,发现下架就提示“商品已下架”,并自动把购物车里的这个商品置为无效。修改商品价格时,新价格立即可用,但不会影响已经下单的订单——订单明细里存的是快照价,前面数据库设计里强调过的那个点,这里就起作用了。
4.3 购物车设计与订单生成:事务、锁与库存扣减的取舍
购物车这块,我用的是后端存储方案。加入购物车的接口:
java复制@PostMapping("/add")
public Result addToCart(@RequestAttribute("userId") Integer userId,
@RequestBody CartAddRequest request) {
// 1. 校验商品是否存在且上架
Product product = productService.findById(request.getProductId());
if (product == null || product.getStatus() != 1) {
return Result.error("商品不存在或已下架");
}
// 2. 校验加购数量是否大于库存
if (request.getQuantity() > product.getStock()) {
return Result.error("库存不足,当前仅剩" + product.getStock() + "件");
}
// 3. 查购物车是否已有该商品
CartItem existing = cartMapper.selectByUserIdAndProductId(userId, request.getProductId());
if (existing != null) {
// 已有则数量累加(再次校验累加后的数量是否超库存)
existing.setQuantity(existing.getQuantity() + request.getQuantity());
if (existing.getQuantity() > product.getStock()) {
return Result.error("库存不足,购物车已有" + (existing.getQuantity() - request.getQuantity()) + "件");
}
cartMapper.updateQuantity(existing);
} else {
// 没有则新增一条
CartItem cartItem = new CartItem();
cartItem.setUserId(userId);
cartItem.setProductId(request.getProductId());
cartItem.setQuantity(request.getQuantity());
cartItem.setChecked(1);
cartMapper.insert(cartItem);
}
return Result.success();
}
购物车接口看起来简单,但有两个容易忽略的点:一是加购时就要校验库存,而不是等到结算时才校验。等到结算时发现库存不够,用户要回购物车删掉再重新进来,体验很差。二是同一件商品重复加入购物车,业务规则是“数量累加”而不是“新增两条记录”,否则用户多点了两次按钮,购物车里出现两条一模一样的商品,很混乱。
订单生成是整个系统里最考验代码功底的部分,涉及事务、并发和数据一致性。核心流程是五个步骤,必须在一个数据库事务里完成:
java复制@Transactional(rollbackFor = Exception.class)
public Order createOrder(Integer userId, List<CartItem> checkedItems, Shipping shipping) {
// 1. 生成唯一订单号:时间戳 + 用户ID + 随机数
String orderNo = generateOrderNo(userId);
// 2. 遍历购物车明细,计算总价、运费、应付金额
long totalAmount = 0;
List<OrderItem> orderItems = new ArrayList<>();
for (CartItem cartItem : checkedItems) {
Product product = productMapper.selectByIdForUpdate(cartItem.getProductId());
// 校验商品状态和库存
if (product == null || product.getStatus() != 1) {
throw new BusinessException("商品[" + product.getName() + "]已下架");
}
if (product.getStock() < cartItem.getQuantity()) {
throw new BusinessException("商品[" + product.getName() + "]库存不足");
}
// 计算小计(价格是int分,乘法得到的是分)
long itemTotal = product.getPrice() * cartItem.getQuantity();
totalAmount += itemTotal;
// 生成订单明细快照
OrderItem orderItem = new OrderItem();
orderItem.setProductId(product.getId());
orderItem.setProductName(product.getName());
orderItem.setProductImage(product.getMainImage());
orderItem.setCurrentPrice(product.getPrice());
orderItem.setQuantity(cartItem.getQuantity());
orderItem.setTotalPrice(itemTotal);
orderItems.add(orderItem);
}
// 3. 扣减库存(使用乐观锁,防止超卖)
for (CartItem cartItem : checkedItems) {
int affectedRows = productMapper.deductStock(cartItem.getProductId(), cartItem.getQuantity());
if (affectedRows == 0) {
throw new BusinessException("商品库存不足,扣减失败,请刷新购物车");
}
}
// 4. 插入订单主表和订单明细表
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalAmount(totalAmount);
order.setFreightAmount(0L);
order.setPayAmount(totalAmount);
order.setStatus(0); // 待付款
// ... 设置收货人信息、创建时间
orderMapper.insert(order);
for (OrderItem item : orderItems) {
item.setOrderId(order.getId());
orderItemMapper.insert(item);
}
// 5. 清空购物车中已结算的商品
cartMapper.deleteByIds(checkedItems.stream().map(CartItem::getId).collect(Collectors.toList()));
return order;
}
这里我用了两个并发控制手段,说明一下取舍。
第一个是查询商品信息时用了SELECT ... FOR UPDATE行级锁。这样当一个用户下单时锁住了商品行,另一个用户同时下单就会被阻塞,直到事务提交或回滚。这是防止超卖的第一道防线。
第二个是扣减库存用乐观锁方案:UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}。这个SQL本身就是一个原子操作,只要影响行数为0就说明库存不足,直接抛异常回滚。
两个方案会不会重复?不完全是。FOR UPDATE锁的是行保证读写串行化,乐观锁保证并发下不会出现负数库存。实际项目中,通常用乐观锁就够了,但加上行锁更稳妥。这种“双保险”的思维,在真实电商系统里很常见。
关于@Transactional,必须rollbackFor = Exception.class。如果只写@Transactional,默认只在运行时异常时回滚,如果方法里抛的是检查异常(比如IOException),事务不会回滚,数据就半写入了。这个坑我在实际项目里踩过,后来就养成了习惯,凡是写事务注解必定带上rollbackFor。
4.4 订单状态机与超时取消的实现思路
订单状态我用整数枚举表示,0待付款、1待发货、2已发货、3已完成、4已取消。这个状态流转必须画清楚:
- 0待付款 → 支付成功 → 1待发货
- 0待付款 → 超时未支付/用户取消 → 4已取消
- 1待发货 → 管理员发货 → 2已发货
- 2已发货 → 用户确认收货 → 3已完成
支付功能在真实项目里要接微信支付、支付宝支付等第三方平台,涉及商户号、证书、回调通知等一堆东西,对课设和练手项目来说太重了。我的做法是做一个“模拟支付”,前端弹窗显示订单金额,点击确认后调用后端支付接口,直接把订单状态从0改为1,同时记录支付时间。这个设计足以讲清楚“支付完成后订单如何流转”的逻辑,又不会让项目被繁琐的支付对接拖死。
订单超时未取消,我用了Spring Boot自带的@Scheduled定时任务,每30秒扫描一次订单表,把超过30分钟仍未支付的订单状态改为已取消,同时回滚对应的库存。注意这里回滚库存不是简单地把库存加回去,而是要遍历订单明细,逐条把商品库存加回去。如果用户下单期间商品下架了,加回去后保持下架状态,不做额外校验。
java复制@Component
public class OrderTimeoutTask {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Autowired
private ProductMapper productMapper;
@Scheduled(cron = "0/30 * * * * ?") // 每30秒执行一次
@Transactional(rollbackFor = Exception.class)
public void cancelTimeoutOrders() {
// 1. 查询超时未支付的订单(30分钟前创建且状态为0)
Date timeoutTime = new Date(System.currentTimeMillis() - 30 * 60 * 1000);
List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(timeoutTime);
if (timeoutOrders.isEmpty()) {
return;
}
// 2. 遍历订单,逐单取消并回滚库存
for (Order order : timeoutOrders) {
List<OrderItem> orderItems = orderItemMapper.selectByOrderId(order.getId());
for (OrderItem item : orderItems) {
productMapper.restoreStock(item.getProductId(), item.getQuantity());
}
orderMapper.updateStatus(order.getId(), 4, new Date());
}
log.info("定时取消超时订单:{}个", timeoutOrders.size());
}
}
主启动类上要加@EnableScheduling注解,这个定时任务才会生效。在真实的高并发场景下,这种定时扫描方式不够高效,应该用延迟消息队列(RabbitMQ死信队列、RocketMQ定时消息),但对于中小型管理系统,定时任务完全够用,而且逻辑一目了然。
5. 前端页面的关键实现:从接口联调到页面交互
5.1 购物车页面:全选、反选、数量加减与总价联动
前端购物车页面是交互逻辑最密集的页面之一,我实现的时候做了三个联动:复选框全选/反选与单个勾选联动、数量加减与单项小计联动、勾选变化与页面底部总价联动。
这几个联动用Vue的computed是最优雅的,不需要手动监听事件:
javascript复制computed: {
// 是否全选
isAllChecked() {
if (this.cartList.length === 0) return false;
return this.cartList.every(item => item.checked === 1);
},
// 已勾选的商品列表
checkedItems() {
return this.cartList.filter(item => item.checked === 1);
},
// 总价
totalPrice() {
return this.checkedItems.reduce((sum, item) => {
return sum + item.product.price * item.quantity;
}, 0);
}
}
购物车列表的接口里,我返回的不是单纯的cart_item表字段,而是把商品基本信息(名称、主图、价格、库存)也一起返回了,前端拿到一次就能直接渲染。这个在后端是通过多表查询完成的:
xml复制<select id="selectCartDetailByUserId" resultType="com.supermarket.module.cart.vo.CartItemVO">
SELECT
c.id AS id,
c.product_id AS productId,
c.quantity AS quantity,
c.checked AS checked,
p.name AS productName,
p.subtitle AS productSubtitle,
p.main_image AS productMainImage,
p.price AS productPrice,
p.stock AS productStock,
p.status AS productStatus
FROM cart_item c
LEFT JOIN product p ON c.product_id = p.id
WHERE c.user_id = #{userId}
ORDER BY c.create_time DESC
</select>
这里注意,我用的是LEFT JOIN不是INNER JOIN。因为购物车里的商品可能在后台被真的删除了(物理删除),如果用INNER JOIN,这个购物车项就不会出现在结果里,但数据库里数据还在,用户在结算时会遇到莫名其妙的“商品不存在”。用LEFT JOIN配合productStatus字段,前端就能区分“正常商品”和“已失效商品”,失效商品置灰显示,不能勾选,并提供“删除失效商品”的快捷操作。
5.2 后台管理页面:Element UI表格、弹窗与分页
后台管理页面的核心是Element UI的el-table + el-pagination + el-dialog组合。商品管理页面的实现要点:
- 表格列按业务需要定制:商品主图用
el-image展示缩略图、价格列用格式化函数把“分”转为“元”、状态列用el-tag渲染不同颜色的标签(在售绿色、下架灰色、售罄红色)。 - 编辑弹窗用
el-dialog,内部放el-form表单,开启表单校验规则。商品名称必填、价格必须大于0、库存必须为非负整数。新增和编辑共用一个弹窗,点击不同按钮时弹窗标题和数据初始化逻辑不同。 - 图片上传用
el-upload组件,action指向后端的文件上传接口。上传成功后拿到图片URL,回填到表单的mainImage字段里。商品详情富文本用quill-editor组件,基于Vue 2的版本是vue-quill-editor。 - 分页用
el-pagination,当前页和每页条数跟后端分页参数绑定,页码变化时重新调用列表接口。这里要注意把total(总条数)存在data里,表格数据变化时同时更新total。
订单管理页面稍微复杂一点,需要按状态Tab筛选,不同状态下显示不同操作按钮。待发货状态显示“发货”按钮,点击后弹窗让管理员填物流单号;已发货状态显示“完成”按钮(模拟用户确认收货);已取消和已完成状态不显示操作按钮。这个用el-tabs + 不同状态下的操作列渲染判断就能实现,逻辑不复杂,但分支条件要写整齐。
5.3 路由权限控制:前端拦截 + 后端校验双保险
前端路由需要区分用户端和管理员端。用户端的路由是公开的,管理员端进入时判断登录状态和角色。
我的做法是在router.beforeEach全局前置守卫里做判断:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
// 需要登录才能访问的页面
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } });
return;
}
// 只有管理员才能访问后台页面
if (to.meta.requiresAdmin && userInfo.role !== 'ADMIN') {
next({ path: '/403' });
return;
}
// 已登录用户访问登录页,直接跳首页
if (to.path === '/login' && token) {
next({ path: '/' });
return;
}
next();
});
前端路由守卫的作用是优化体验,不能当作安全屏障。真正防止越权访问的后端拦截器必须严格校验,否则别人直接调接口照样能拿到数据。这也是我一直强调前后端“双保险”的原因——前端的拦截是为了少发无效请求,后端的拦截才是安全的核心。
6. 常见问题与排查技巧实录
做这个项目的过程中,我踩过不少坑,这里挑些典型的记录下来,希望能帮你少走弯路。
6.1 跨域问题:前端调不通后端接口,显示CORS错误
前后端分离项目,前端跑在8081端口,后端跑在8080端口,浏览器默认会拦截跨域请求。解决方案是在后端配置CORS。
最干净的方式是写一个配置类实现WebMvcConfigurer:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
注意allowCredentials(true)时,allowedOrigins不能用*,要用allowedOriginPatterns("*"),这是Spring Boot 2.x的细节。同时,登录拦截器要放行OPTIONS预检请求,否则CORS配置可能会被拦截器先拦下来。
6.2 MyBatis分页查询总数不对或SQL异常
用PageHelper分页时,最常见的坑是“分页插件不生效”或者“查询出来的count不对”。原因通常是PageHelper的依赖版本和Spring Boot版本不兼容,或者PageHelper依赖多个版本冲突。
我建议直接用com.github.pagehelper:pagehelper-spring-boot-starter这个starter,版本选最新稳定版,它内部会适配Spring Boot的自动配置,不需要手动写@Bean。调用时注意PageHelper.startPage(pageNum, pageSize)之后要紧接着执行你要分页的SQL,中间不要穿插任何无关的查询,否则那个查询也会被分页,导致数据错乱。
6.3 前后端交互时时间格式不一致
后端返回的LocalDateTime默认序列化格式是2024-01-15T10:30:00,前端如果不处理,展示出来很难看。我在application.yml里配置了jackson的日期格式为yyyy-MM-dd HH:mm:ss,前端就统一成熟悉的格式了。
另一个方案是在实体类的时间字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),但是每加一个字段都要写一遍很繁琐。全局配置更省事,除非有特殊格式需求才用注解覆盖。
6.4 MySQL 8.x无法连接,报Public Key Retrieval is not allowed
MySQL 8.x默认使用caching_sha2_password认证插件,Java连接时如果没有allowPublicKeyRetrieval=true参数,会出现这个报错。解决办法就是数据源URL里加上这个参数:jdbc:mysql://localhost:3306/supermarket?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai。
另外,如果密码里有特殊字符,URL里要转义,最好用Properties类加载数据库配置,避免URL拼接的转义问题。
6.5 npm install卡住或下载报错
前端依赖安装卡住,基本都是网络问题。npm默认镜像源在国外,国内访问不稳定。解决方案是设置镜像源:
bash复制npm config set registry https://registry.npmmirror.com
vue-quill-editor、element-ui这些大型依赖如果反复安装失败,试试删除node_modules和package-lock.json重新安装。另外,Node.js的版本要注意,Vue 2项目建议用Node 14或16,Node 18以上可能会报OpenSSL相关的错误,比如error:0308010C:digital envelope routines::unsupported,这种情况在package.json的scripts里加一句set NODE_OPTIONS=--openssl-legacy-provider &&能临时解决,但根治还是换Node版本。
6.6 并发下单导致库存变负数
这个问题在开发阶段可能不出现,但一旦用JMeter压测或者多个用户同时下单就会暴露。根因是库存扣减操作没有并发保护。我前面已经给出了解决方案——UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},这条SQL利用数据库的行锁和条件更新,天然保证并发安全。做项目复盘时,把“如何防止超卖”作为一个闪光点写进系统设计说明里,面试官一般都会眼前一亮。
7. 项目后续还能怎么扩展
系统做完之后,扩展方向其实很多,这里根据自己的经验提几个比较有价值的方向。
第一个方向是引入Redis缓存。商品列表和商品详情是读多写少的场景,用Redis做缓存可以显著提升QPS。具体做法是:商品详情查询时先查缓存,缓存没有再查数据库,然后回填缓存并设置过期时间;商品修改、上下架时主动删除缓存,保证数据一致性。接入Redis之后,还能用Redis存购物车、用Redis的过期键实现订单超时,这些都是很实际的优化点。
第二个方向是接入对象存储。当前商品图片是上传到本地服务器目录的,真实项目里图片应该传到云存储(比如阿里云OSS)或者FastDFS/MinIO这种分布式存储。本地上传的问题在于:单机磁盘容量有限、扩容困难、图片访问路径要经过后端应用服务器,浪费带宽。换成云存储后,图片访问直接走CDN,应用服务器专心处理业务请求。
第三个方向是引入Spring Security或Shiro做更细粒度的权限控制。目前我用的是简单的拦截器+角色判断,对中小系统来说够了。但如果管理员要分超级管理员、运营、客服等多个角色,每个角色的菜单和按钮权限不同,就需要引入权限框架。Spring Security + JWT是主流的方案,但学习曲线比较陡峭,如果不是刚需,不建议为了用框架而用框架。
第四个方向是增加数据统计报表。后台工作台目前展示的是订单数和销售总额这些基础指标,可以扩展成一个独立的统计模块,用ECharts展示近7日销售额趋势、商品品类销售占比、热销商品Top10等图表。做这个模块需要编写复杂的GROUP BY聚合SQL,还要处理时间窗口的补零问题,很能锻炼SQL能力。
最后再分享一点个人的实操心得:做这种管理系统,不要上来就飞到代码层面,先花一晚上把需求用例、表结构和接口清单写清楚,动手时会顺很多。遇到报错,优先看控制台的完整异常堆栈,定位到具体行号再改,不要盲目试。项目跑通后,再用测试工具把登录、下单、库存超卖这些关键链路多走几遍,把边界情况处理干净。这套流程走下来,你收获的不只是一份源码,而是对未来真实开发工作节奏的提前适应。
