SpringBoot+Vue3+MyBatis实战:网上超市系统设计与开发解析

发布时间:2026/10/21 9:26:04
SpringBoot+Vue3+MyBatis实战:网上超市系统设计与开发解析 做这套网上超市系统之前我一直以为最麻烦的是商品列表和购物车页面的实现。真用 Java SpringBoot Vue3 MyBatis 这套前后端分离方案动手做了才发现难点从来不在单页功能而在边界没划清楚——商品、库存、购物车、订单互相牵扯中间改一个字段往往要跟着改三张表。数据库落在我选的 MySQL 上项目整体规模不大却覆盖了从商品浏览、加购、下单、支付状态流转到管理端商品维护的完整链路。如果你正在做毕业设计、课程设计或者要给小型商家搭一套自营超市平台这条实现路径里的取舍和坑值得你往下看。1. 网上超市的业务边界需求拆解与不做清单1.1 用户端必须跑通的四条主线网上超市的用户端本质上就四条主线浏览商品、管理购物车、提交订单、跟踪订单。浏览商品包括首页轮播图、分类导航、商品列表和商品详情购物车要支持加购、修改数量、勾选、删除提交订单要处理收货地址、应付金额和支付动作订单跟踪则是让用户看到每一单当前处在哪个状态。四条主线看着不多但每一条都会和后端接口、数据库表一一对应。做这类项目最忌讳的就是边写边加需求比如做到一半突然要加优惠券那基本上是从数据库到前端都要动一轮。我第一版就吃了这个亏本来只规划了商品和订单两个模块写着写着觉得没有购物车不像超市临时补上结果用户表、商品表、订单表的关联方式全要调整白白返工。1.2 管理端砍到什么程度才够用管理端我保留了商品管理、分类管理、订单管理三块。商品管理解决上架、下架、改库存、改价格、换图片分类管理解决商品怎么归类订单管理解决查看订单、确认发货。用户管理只做了简单列表没有做权限细分。后台功能做得越少越能把基本功打磨扎实。真正上线运营以后再加也来得及但一开始把范围拉得太大项目往往会烂尾。一个常被忽略的原则是少做一个功能比多做一个坏功能重要。管理端的订单列表如果没有任何筛选和分页操作员在几百单之后就没法用了所以订单查询接口一定要支持按状态筛选、按订单号模糊搜索、按时间排序这些从第一天就要想进去而不是等数据量上来了才补。1.3 支付与库存两个必须先定调的边界第一个是支付。接真实第三方支付需要有商户资质还要处理回调通知、对账、退款复杂度远超一个学习或演示项目本身。我的做法是做一个模拟支付接口用户点立即支付后端把订单状态从待支付改为待发货同时生成一个假的支付流水号。真实支付的回调解耦成一个预留扩展点注释写清楚等真正需要接入时再替换。第二个是库存。正规超市系统有采购入库、批次管理、盘点这里我简化为商品表里的一个 stock 字段但对扣库存这个操作本身绝不含糊必须用条件更新保证并发安全。库存这个边界如果不在设计阶段想清楚后面订单模块和商品模块就会互相打架。你可以在订单状态里做很多花样但库存一旦对不上用户投诉和售后成本会直接让你怀疑人生。2. 技术选型的真实取舍这套组合为什么既够用又不浪费2.1 规模决定架构单体应用是合理默认值很多同学一上来就想上微服务、Redis、消息队列觉得技术栈越多越高级。但实际上一个日活几百到几千人的超市系统单应用、单数据库完全扛得住。微服务要解决的是团队协作、独立部署、独立扩展的问题你现在没有这些问题引入它只会带来服务拆分、链路追踪、分布式事务三个新问题。Redis 同理如果只用来存购物车换来的是缓存一致性这个更大的麻烦。我的原则是能用 MySQL 一张表讲清楚的状态绝不放进中间件。2.2 SpringBoot把繁琐配置变成约定后端选 SpringBoot核心原因是它把大量繁琐配置变成了约定。内嵌 Tomcat 意味着一个 java -jar 就能跑spring-boot-starter-web 自动装配了 Spring MVC 和 JSON 序列化spring-boot-starter-validation 帮你做参数校验。你只需要关注 Controller、Service、Mapper 三层怎么写。这里提醒一句用 SpringBoot 不等于不懂原理。建议把自动装配的源码大致翻一遍至少要能说出 spring.factories 或者 AutoConfiguration.imports 里加载了哪些东西这决定了你排错的方向。比如你自定义了一个拦截器却发现不生效大概率就是没搞懂 WebMvcConfigurer 和 Spring Boot 自动配置的执行顺序。2.3 Vue3组合式 API 与 Element Plus 的开发效率Vue3 的组合式 APIscript setup 写法最大的好处是把业务代码按逻辑聚合而不是按选项散落。同一个购物车功能涉及的响应式数据、计算属性和方法在选项式 API 里要分开写三处在组合式 API 里可以收敛到一个函数里。配合 Pinia 做跨页面状态共享配合 Element Plus 直接拿到表格、表单、弹窗、上传这些现成组件开发效率非常可观。Vite 做开发服务器冷启动基本是秒开比 Webpack 时代的体验好太多HMR热更新也快改一行样式不会把整个页面状态打没。2.4 MyBatis 与 MySQLSQL 可控带来的长期收益持久层选 MyBatis 而不是 JPA理由很简单这个系统的查询条件组合特别多。商品列表要支持按分类、关键词、价格区间、上架状态筛选还要支持不同字段排序MyBatis 的动态 SQL 写起来最直白一个 where 加几个 if 就搞定。换成 JPA 的 Specification虽然也能实现但可读性差一截调试时还得猜它最终生成的 SQL 长什么样。数据库选 MySQL 的 InnoDB 引擎因为它原生支持事务和行级锁下单这种需要强一致性的操作必须依赖事务。字符集记得用 utf8mb4不然商品描述里放个特殊符号或生僻字就存不进去了这是个很低级但很常见的坑。排序规则选 utf8mb4_general_ci 就够不要在这上面花太多时间纠结。3. 数据库设计复盘从商品到订单的表该怎么建3.1 用户、商品、分类三张基础表的字段细节用户表 user 的字段是 id、username、password、nickname、phone、address、create_time。密码字段存的是 BCrypt 哈希不是明文也坚决不用 MD5——MD5 查表撞库是秒破的这个习惯从现在就要养成。商品表 product 是整个系统最核心的一张表id、category_id、name、subtitle、main_image、price、stock、sales、status、detail、create_time。price 必须用 decimal(10, 2)任何金额字段都不能用 float 或 double二进制浮点数在金额计算上永远有精度误差这是数据库设计的第一条铁律。status 用 tinyint1 上架 0 下架下架的商品在用户端列表里查不到但订单明细里的历史快照要能正常显示。分类表 category 是 id、name、parent_id、sort_order支持两级分类就够了超市的乳品烘焙、休闲零食下面挂具体商品不需要第三级。3.2 订单主表与明细表为什么商品信息要做快照订单和电商一样必须拆两张表。order 主表记录订单编号、用户 id、收货人姓名、收货电话、收货地址、总金额、状态、创建时间、支付时间、发货时间order_item 明细表记录每个商品的 id、名称、图片、下单时单价、数量、小计。为什么要把商品名称和价格冗余一份到明细里因为商品信息是可变数据商家随时可能改价、改名、下架。如果订单明细去关联实时商品数据三个月前的订单展示出来的价格和名称就可能和当时购买时对不上这在真实业务里是不可接受的所以必须做快照。order_no 订单编号我建议用时间戳加随机数生成不要直接用自增 id 暴露给用户否则别人可以通过订单号差值估算你的订单量而且自增 id 太规律容易被遍历。3.3 购物车 upsert 与库存扣减的并发安全购物车表 cart_item 用 user_id、product_id、quantity 三个基本字段加一个唯一索引 (user_id, product_id)。加入购物车时用 INSERT ... ON DUPLICATE KEY UPDATE quantity quantity 1 这种 upsert 写法避免先查一遍再决定插入还是更新省掉一次往返也避免在高并发下出现重复行。扣库存是另一个经典并发问题。绝对不能先 SELECT stock 再 UPDATE stock stock - quantity两个请求同时读到剩余 1 件库存就会都认为可以买最终库存变成负数。正确做法是直接执行条件更新UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}然后看这一条语句返回的受影响行数如果为 0说明库存不足抛出业务异常回滚整个下单事务。这套方案在并发量不极端的情况下足够可靠也不用引入乐观锁版本号那一套复杂机制。4. 后端实现骨架认证、商品、购物车、订单四条主线4.1 分层结构与统一返回体后端按标准的三层结构组织controller 只做参数接收和结果包装service 写业务逻辑mapper 只写 SQL。包名在 com.supermarket 下拆 config、controller、service、mapper、entity、common、util。所有接口返回统一用 Result 包装结构是 code、message、datacode 为 200 表示成功401 表示未登录500 表示服务异常。这样前端 axios 拦截器只需要判断一个 code不用每个接口单独写错误处理。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }再配一个全局异常处理器用 RestControllerAdvice 统一拦截业务异常和系统异常业务异常返回对应的错误码系统异常统一返回服务开小差并打日志。这一步看着简单却是整个后端体验的分水岭没有它前端拿到的错误信息就会是一堆英文堆栈。4.2 JWT 登录认证与用户上下文登录接口接收 username 和 password用 BCrypt 校验密码成功后生成 JWTpayload 里放 userId 和 username签名用 HMAC-SHA256过期时间我设为 2 小时。token 返回给前端后前端存 localStorage之后每个请求在 axios 请求拦截器里加上 Authorization: Bearer xxx。后端注册拦截器拦下除登录、注册、商品列表、详情以外的接口解析 token 拿到 userId 放到 ThreadLocalservice 层通过 UserContext.getUserId() 就能知道当前是谁在操作不需要每个方法都传一遍 userId。public class JwtUtil { private static final SecretKey KEY Keys.hmacShaKeyFor(your-secret-key-at-least-32-bytes.getBytes(StandardCharsets.UTF_8)); public static String generateToken(Integer userId, String username) { long now System.currentTimeMillis(); return Jwts.builder() .claim(userId, userId) .claim(username, username) .setExpiration(new Date(now 2 * 60 * 60 * 1000)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }需要特别说明的是JWT 是无状态的后端不存 sessiontoken 一旦泄露就只能等它过期。所以实际项目里要做短期 token 加刷新 token 的配合学习项目里把过期时间设短一点2 小时已经够用。我踩过的坑是忘记判断 token 过期异常的类型导致前端拿到一堆 500 而不是 401拦截器里把 ExpiredJwtException 单独 catch 出来转成 401这个问题就解决了。4.3 商品多条件筛选MyBatis 动态 SQL商品列表是前端调用最频繁的接口也是 MyBatis 最体现价值的地方。参数可能组合出现categoryId、keyword、minPrice、maxPrice、status、sort、pageNum、pageSize。用动态 SQL 把所有条件拼进一条原生 SQL而不是在 Java 内存里做条件过滤也不为每个组合写一个 mapper 方法。select idselectProductList resultTypecom.supermarket.entity.Product SELECT p.*, c.name AS categoryName FROM product p LEFT JOIN category c ON p.category_id c.id where if testcategoryId ! null and categoryId ! 0 AND p.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND p.name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if if teststatus ! null AND p.status #{status} /if /where choose when testsort price_ascORDER BY p.price ASC/when when testsort price_descORDER BY p.price DESC/when when testsort salesORDER BY p.sales DESC/when otherwiseORDER BY p.create_time DESC/otherwise /choose /select分页我用 PageHelperPageHelper.startPage(pageNum, pageSize) 之后紧跟着的下一条查询会被自动加上 limit 并返回 PageInfo。注意坑startPage 基于 ThreadLocal 实现只对紧接着的第一条查询生效所以使用规范是 startPage 之后立刻调用 mapper 方法中间不要插入其它查询或日志代码。另外一定要用 pagehelper-spring-boot-starter 的最新版本让分页拦截器自动注册否则会出现分页失效这种莫名其妙的问题。4.4 下单接口一个事务里的库存、金额与订单下单接口是整项目里事务最重的方法必须加 Transactional(rollbackFor Exception.class)。整体步骤是第一步从购物车里取用户勾选的商品项第二步批量查出商品最新信息校验是否上架第三步对每件商品执行条件更新的扣减库存任何一件受影响行数为 0 就抛异常回滚第四步用商品表里的最新现价计算总金额全程用 BigDecimal累加后 setScale(2, RoundingMode.HALF_UP)第五步插入 order 主表和 order_item 明细表第六步删除购物车中已下单的项。为什么这六步必须在同一个事务里因为库存、订单、购物车三者的数据必须强一致如果扣了库存但订单没生成用户没买到东西库存却少了这是超市系统无法接受的。InnoDB 在默认 REPEATABLE READ 隔离级别下行锁会持有到事务提交才释放所以并发下单时同一个商品的库存扣减是串行化的这也是为什么条件更新加事务这个组合在绝大多数场景都够用。5. Vue3 前端落地页面、状态与接口的协作方式5.1 工程结构与路由设计前端用 Vite 创建 Vue3 项目安装 vue-router、pinia、axios、element-plus。目录结构大致是src/api 放接口调用、src/views 放页面组件、src/components 放复用组件、src/store 放 Pinia、src/router 放路由、src/utils/request.js 放封装好的 axios 实例。路由分两块用户端是 /、/product/:id、/cart、/checkout、/orders、/login、/register管理端在 /admin 下用嵌套路由搭侧边栏布局并通过路由守卫判断登录状态和角色。管理端必须做路由级守卫只隐藏入口按钮是挡不住手动输 URL 的。用户端和管理端最好拆开两个布局组件用户端的 Header 是搜索栏和购物车角标管理端的 Sidebar 是菜单混在一起会让导航逻辑变得很乱。5.2 Pinia 管理购物车状态购物车是典型的跨页面共享状态。商品详情页的加入购物车按钮、首页右上角的购物车角标、购物车列表页、结算页四处都要访问购物车数据或数量。如果每个页面各自请求一遍接口代码大量重复不说状态还会不同步——你在购物车页删了一个商品回到首页角标还是旧数字。用 Pinia 的做法是把 cartItems 和 cartCount 放进全局 store所有页面通过 store 读写。import { defineStore } from pinia import { getCartList, addCart, removeCart } from /api/cart export const useCartStore defineStore(cart, { state: () ({ cartItems: [], cartCount: 0 }), getters: { totalPrice: (state) state.cartItems .filter(item item.checked) .reduce((sum, item) sum item.currentPrice * item.quantity, 0) }, actions: { async fetchCart() { this.cartItems await getCartList() this.cartCount this.cartItems.reduce((sum, item) sum item.quantity, 0) }, async addToCart(productId, quantity) { await addCart({ productId, quantity }) await this.fetchCart() }, async removeFromCart(id) { await removeCart(id) await this.fetchCart() } } })组件里只需要调用 useCartStore().addToCart()内部再去调后端接口成功后更新本地 state。这样加入购物车按钮和右上角角标永远是一致的不需要手动同步。注意 totalPrice 里不要自己用浮点累加我用的是从后端返回的小计字段如果一定要在前端算记得把所有值都按分转成整数再相加避免浮点误差。5.3 axios 封装与 Vite 代理请求封装就做三件事创建 axios 实例、请求拦截器自动带 token、响应拦截器统一处理业务码。响应拦截器里 code 为 200 直接返回 data401 清掉本地登录信息并跳登录页其它错误弹 Element Plus 的 ElMessage 提示。import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( res { const { code, message, data } res.data if (code 200) { return data } if (code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(message || 请求失败) return Promise.reject(new Error(message)) }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )开发环境的跨域问题用 Vite 的 server.proxy 解决把 /api 开头请求代理到 http://localhost:8080后端不需要额外配 CORS。生产环境更简单Nginx 配一个 location /api 反向代理到后端服务。统一走 /api 前缀的好处是开发和生产的切换只改配置代码一行不动。5.4 管理端Element Plus 组件化的商品维护管理端尽量复用 Element Plus 的现成组件el-table 展示商品列表el-dialog 套 el-form 做编辑弹窗el-upload 做图片上传el-select 和 el-input-number 处理分类和库存。商品编辑弹窗里最麻烦的是图片回显el-upload 需要 fileList 格式的数据而后端返回的往往只有一个 URL这里要写一个转换函数封装。图片上传的后端接口用 MultipartFile 接收存到服务器本地目录再通过静态资源映射把目录暴露成 URL。开发时在 WebMvcConfigurer 里用 addResourceHandlers 把 /upload/** 映射到磁盘绝对路径。这个方案只适合学习和单机部署真要上线还是得换对象存储但本地映射逻辑先打通后面换存储只需要改 controller 那一个接口的实现前端完全无感。6. 联调阶段最容易翻车的三个地方6.1 跨域问题前后端分离项目联调跨域是第一道坎。很多同学一开始喜欢在后端配 CorsConfig 允许全部跨域然后把前端请求改成直接访问 http://localhost:8080这能跑但很混乱。我更推荐统一走代理前端只认 /api 这个前缀由 Vite 或 Nginx 转发到后端浏览器看到的所有请求都是同源的自然没有跨域问题。开发环境挂代理、生产环境 Nginx 反代前后端都只认 /api 这个约定切换环境时只改代理配置。只有一种情况需要后端主动开 CORS你打算把接口开放给别人域名调用做成开放平台那才需要在后端统一配置允许来源。否则后端一直保持不开 CORS 反而干净。6.2 时间格式的序列化SpringBoot 默认用 Jackson 序列化 LocalDateTime输出格式是带 T 的 ISO 标准格式比如 2025-01-02T10:30:00前端直接显示会很难看。而且不同版本的 Jackson 对时间处理不一样有时还序列化成数字数组。解决方式推荐两个一个是在全局配置文件里加spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8另一个是在实体类时间字段上显式加 JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。我习惯两者都用全局兜底关键字段再显式标注这样不管哪个环境都不会出现前端显示一串 T的诡异问题。6.3 金额精度金额精度问题是这个项目里最容易被忽略又最难排查的坑。数据库字段用 decimal(10,2)Java 实体用 BigDecimalJSON 传给前端后前端只做展示层处理不参与任何金额运算。哪怕前端要显示购物车合计也是展示从后端接口算好的金额前端只调用格式化函数保留两位小数。千万别在 JavaScript 里做 0.1 0.2 这种运算结果是 0.30000000000000004。订单列表的总价、商品价格的展示统一用 toFixed(2) 格式化成两位小数。凡是前端有金额加减的地方都要重新审视最好全部挪到后端接口。记住一句话金额的运算只允许发生在后端 BigDecimal 里前端只是展示器不是计算器。7. 上线前检查清单与几段实在话7.1 可以直接抄的发布前检查清单上线之前我建议至少过一遍这份清单每一条都是实际项目里踩过的管理员账号是否能用初始化 SQL 一键创建而不是手工往数据库插。所有管理端接口是否做了角色校验别只靠前端隐藏按钮。文件上传目录的权限和大小限制是否配置别让人传个超大文件把磁盘占满。日志级别是否调到 info异常堆栈是否完整记录别上线后查问题全靠猜。数据库是否做了定时备份哪怕只是服务器上配一个每天凌晨的 mysqldump 定时任务。生产环境 profile 是否把调试接口和开发配置关闭。Nginx 的 client_max_body_size 是否限制了上传体积。商品下架和库存为零时用户端是否给了明确提示而不是报错。这些项目看着细但每一个都决定系统能不能真正让人放心地用。清单之外还要用测试账号完整走一遍用户下单、管理端发货、确认收货的闭环确保状态流转没有死路。7.2 踩过坑之后的三条心得第一先画表再写代码。这个系统我第一版是边想边写结果商品和订单的关联字段返工了两次。第二次老老实实先画 ER 图、列字段、定类型写代码的速度反而快了很多。表结构稳定了controller 和页面基本就是照着接口抄。拿到类似源码项目时也一样先看数据库脚本把每个表字段过完一遍再碰代码这是最快的上手路径。第二Controller 一定别写肥。我见过有人把下单逻辑直接堆在 Controller 里一个方法两百行参数校验、业务判断、数据组装全挤在一起。Controller 只做三件事接参数、调 service、返回 Result。业务逻辑全部进 service这是底线。看一个后端代码健不健康先看 Controller 平均行数超过五十行就该警觉。第三空指针要提前防御。前端传一个不存在的商品 id 进来service 应该在查不到时抛明确的业务异常code 和 message 都写清楚而不是让 null 一路传到 Controller 再变 NullPointerException。全局异常处理器 RestControllerAdvice 要提前写好把业务异常和系统异常分开返回联调时就不用来回对你报错了但我不知道错在哪的哑谜。最后补一句前后端分离项目哪怕只有你一个开发接口文档也值得写。我在项目里集成了 OpenAPI 生成 Swagger 文档把每个接口的参数、返回结构、示例注释清楚联调阶段省下了大量这个字段是什么意思的沟通成本。这套网上超市系统做完你会发现真正有价值的不是那一堆接口而是你在边界设计、数据建模和异常处理上做过的每一个取舍这些经验换一个项目照样能用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询