Spring Boot+Vue网上超市系统:全栈设计与部署实践指南
手上正好在做一个基于Spring Boot Vue的网上超市系统前后端分离、源码和数据库文档配套齐全算是一个比较典型的全栈项目。这套东西既适合拿来练手、补全项目经历也能当毕业设计框架直接改一改再用。今天就把整个项目的设计思路、表结构、前后端核心实现还有部署过程中的一些坑一次性讲清楚。写这篇文章的原因很简单市面上的商城项目要么太重微服务、分布式一套接一套根本跑不起来要么太玩具单表CRUD完事一点参考价值都没有。我这个“网上超市系统”属于中间档——单体Spring Boot做后端Vue做前端功能覆盖了用户、商品、购物车、订单、后台管理足够真实又没有堆不必要的复杂度。如果你正准备做一个类似的东西这篇文的每个环节都能直接抄作业。1. 内容整体设计与思路拆解1.1 这个系统解决什么问题超市行业的线上化需求其实很直白用户不想出门希望像逛天猫一样逛超市下单后等配送。和管理员的诉求要能对上——清楚知道进了多少货、哪件商品卖得快、订单走到哪一步了。从软件角度拆就是三个核心角色普通用户、后台管理员、系统本身。用户关心的是“能不能快速找到商品、购物车好不好用、下单和支付流程通不通”管理员关心的是“商品怎么上下架、分类怎么维护、订单状态怎么改、用户信息怎么查”系统则负责把这两拨人的操作串联起来保证数据不丢、状态不乱。我见过不少课程设计里的商城页面花里胡哨点进去发现购物车不能改数量、订单没有状态流转这种做完等于没做。这个项目在设计时就把核心闭环拉通用户注册登录浏览商品加购物车生成订单管理员处理后台上架商品、发货改状态用户再查看订单进度。闭环通了项目才有说服力。1.2 为什么选Spring Boot Vue这套组合选型这事不能拍脑袋尤其现在前后端分离已经成了主流你再用JSP、FreeMarker那套模板渲染写出来不仅过时答辩和面试也容易被追问住。Spring Boot的好处不用多说内置Tomcat自动配置干了几年Java的人基本绕不开它。配合MyBatis-Plus做持久层写SQL都不用自己拼BaseMapper那一套开发效率非常高。最重要的是Spring Boot在阿里云、腾讯云甚至本地Docker部署都极其顺滑这对后面演示项目非常有利。Vue这边我选择的是Vue 3 Element Plus Vite的组合。Vue 3的Composition API让代码组织更清晰Element Plus组件库是现成的表格、表单、弹窗、菜单后台管理页面基本就是用这些堆出来的。再加上Pinia管理登录状态和购物车数据Axios统一处理HTTP请求前端整个架构清晰明白。其次要说说为什么没上微服务。很多同学一上来就想用Spring Cloud Alibaba那一套Nacos、网关、分布式事务全配上结果是配置写了一堆服务一个都跑不明白。超市系统这种业务体量单应用加MySQL加Redis已经完全能扛住分布式架构是给自己找麻烦。真正的生产级单体应用先把横向分层做好后期真要拆微服务也完全来得及。2. 核心功能模块拆解2.1 用户端功能网上超市的用户端功能可以类比成线下逛超市的动线“进门口看促销海报——按分区找商品——往购物篮子装东西——排队结账——拿到购物小票回家”。对应到系统里就是几个模块注册与登录模块。用户注册时需要填用户名、密码、手机号密码不能明文存储必须用MD5加盐或者BCrypt加密后再进数据库。登录后用JWT生成Token前端每次请求带上Token后端解析后在Controller层直接得到当前用户信息。这样既不依赖Session也能天然支持将来多端登录。首页与商品浏览模块。首页除了商品列表还需要轮播图推荐位和分类导航。用户点击分类时前端动态传分类ID给后端后端根据分类ID和分页参数返回商品列表。商品搜索走模糊匹配我这里使用的是MySQL的LIKE查询加上商品名称和描述字段的索引数据量不大的时候性能完全够。商品详情模块。详情页展示商品的完整信息价格、库存、销量、详情图片。这里有个细节需要注意列表页的价格和详情页的价格尽量都从后端实时查询不要用静态写死的数据否则前端展示会出现不一致。购物车模块。用户把商品“扔进篮子”后商品数量可以加减勾选状态可以切换结算时只统计勾选商品。购物车数据存库里不存LocalStorage因为用户换设备登录时购物车不能丢这也符合真实商城的行为习惯。订单模块。用户从购物车勾选商品后点击结算填写收货人、电话、地址生成订单。系统为每个订单生成唯一订单号格式通常是日期加随机数例如20250610123045678加4位随机数。订单生成后状态从“待支付”开始流转管理员后台可以在“待发货”状态下把订单置为“已发货”用户则可以查看订单的当前状态和订单详情。2.2 管理端功能管理端是给超市运营人员用的页面设计不追求炫酷但信息密度要高、操作效率要快。商品管理。商品列表展示所有商品的缩略图、名称、价格、库存、上下架状态。管理员可以新增商品、编辑商品信息、删除商品也可以批量上架下架。新增商品时最关键的是图片上传我选择的是将图片上传到本机指定目录访问时通过后端映射为静态资源URL。生产环境一般会上传到OBS或OSS项目里的方案对本地演示已经足够。分类管理。分类采用二级结构大分类如水果蔬菜、肉禽蛋品下面挂小分类如苹果、香蕉。这样做的好处是首页导航可以展示大分类商品列表页可以按小分类筛选灵活度更高。订单管理。管理员按订单号、用户名、订单状态等条件筛选订单查看订单详情对订单进行发货操作。如果超市要求退款功能还需要设计“退款”状态和相应按钮这里项目里做了简化处理只保留发货与完成两个操作节点。用户管理。管理员可以查看全部注册用户包括用户名、手机号、注册时间也可以禁用某些异常账号。这个模块的实现很基础就是一张用户表加一个状态字段。统计报表。后台首页可以展示几个核心数据总销售额、总订单数、总用户数、总商品数再配一个近七天订单量的柱状图。这里用Vue里的ECharts组件就可以实现后端提供一个聚合查询接口返回统计数据非常简单但观感很好答辩时特别加分。2.3 购物车与订单流程购物车到订单的流转是整个系统里最容易写乱的部分很多人会在这里踩坑。正确思路是购物车表只承担“暂存商品”的职责结算时不再直接拿购物车表的字段直接生成订单而是先让前端把勾选的购物车项ID列表传给后端后端根据这些ID查询商品实时价格再计算订单总金额。这样就能避免用户下单后才发现商品价格变动也避免库存不足时才在后端报错。订单生成和扣减库存这两个操作必须放到一个事务里。如果只生成订单不扣库存超卖问题立刻就会出现如果扣了库存但订单没插入库存就凭空蒸发。用Transactional注解把两个操作包起来任何一步失败都会回滚这个细节是面试最爱问的点。3. 数据库设计与关键表结构3.1 核心数据表及关系数据库是这个系统的地基表设计不好后面写啥都别扭。咱们这个超市系统一共设计了9张核心表基本覆盖了所有业务场景。表名说明核心字段user用户表id、username、password、phone、rolecategory商品分类表id、name、parent_id、sort_orderproduct商品表id、category_id、name、price、stock、statusproduct_image商品图片表id、product_id、image_urlcart购物车表id、user_id、product_id、quantity、checkedorder订单表id、order_no、user_id、total_amount、statusorder_item订单明细表id、order_id、product_id、price、quantityaddress收货地址表id、user_id、receiver、phone、detailadmin管理员表id、username、password用户表和商品表通过购物车表建立多对多关联订单表和商品表通过订单明细表建立多对多关联这就是最经典的电商模型。业务查询时80%的SQL都是基于userId或者orderId进行的单表和多表联查索引设计上记住一条userId、orderId、categoryId、productId这几个外键字段必须建索引否则数据量上来了查询会明显变慢。3.2 关键表字段设计我直接用建表SQL来说明这比自己口头讲字段要直观得多。CREATE TABLE product ( id int(11) NOT NULL AUTO_INCREMENT, category_id int(11) NOT NULL COMMENT 分类ID, name varchar(128) NOT NULL COMMENT 商品名称, subtitle varchar(255) DEFAULT NULL COMMENT 商品副标题, price decimal(10,2) NOT NULL COMMENT 价格, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存, sales int(11) NOT NULL DEFAULT 0 COMMENT 销量, image varchar(255) DEFAULT NULL COMMENT 主图URL, detail longtext COMMENT 商品详情, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;商品表的price字段必须用decimal(10,2)坚决不用float或double。原因很简单浮点数精度会飘电商业务对金额要求是分毫不差。库存字段用int就够不必担心负数问题因为下单事务里会先做库存检查。订单表的设计有几个细节值得强调CREATE TABLE order ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int(11) NOT NULL COMMENT 用户ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint(4) NOT NULL COMMENT 0待支付 1待发货 2已发货 3已完成 4已取消, receiver_name varchar(32) NOT NULL, receiver_phone varchar(20) NOT NULL, receiver_address varchar(255) NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no加唯一索引这个点很容易被忽略。订单号是用户投诉时最核心的检索凭据如果生成逻辑出现重复后台一通乱套。虽然理论上随机数碰撞概率很低但唯一索引是最终防线宁可加索引也不赌概率。判断order表里的status状态时我一直建议用int值类型而不用String类型因为状态会有流转逻辑int值比较起来高效且不易写错。状态含义要写到表注释里团队协作或者自己过段时间再回来维护都不会看蒙。4. 后端核心实现4.1 项目结构与分层后端项目的包结构我习惯这样分com.supermarket ├── common // 全局返回结果、异常处理、工具类 ├── config // 跨域配置、WebMvc配置、静态资源映射 ├── controller // 接口层 ├── service // 业务层 │ └── impl // 业务实现 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端传递的参数对象 └── vo // 返回给前端的数据对象这种分层方式把Controller、Service、Mapper拆开职责清晰。很多初学者喜欢在Controller里直接写业务一张接口五六十行后期维护非常痛苦。规则很简单Controller只做参数接收和数据返回不写任何if-elseService做业务逻辑Mapper只做数据库操作。三层之间禁止越级调用。全局返回结构我用了统一格式public class ResultT { private Integer code; private String message; private T data; }code为200表示成功401表示未登录500表示服务器错误。前端Axios根据code统一判断和处理比每个接口单独返回状态要优雅得多。4.2 登录鉴权与拦截器登录鉴权我用JWT实现密码加密用BCrypt。BCrypt的优点是自带随机盐每次加密结果都不同即使两个用户密码一样存到数据库里的密文也不同能有效抵抗彩虹表攻击。Spring Boot后端只需要两个核心组件一个JwtInterceptor拦截器一个WebConfig注册类。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); String userId JwtUtil.parseToken(token); if (userId ! null) { request.setAttribute(userId, userId); return true; } } response.setStatus(401); return false; } }这里有个实践细节不要把用户ID放在Token的claims里就完事拦截器解析后还要把userId放进request的attribute里Controller的方法中就能直接通过request.getAttribute(userId)取到省得每个Controller方法都写一遍解析Token的代码。需要放行的路径包括用户注册、登录、商品列表和商品详情这些不依赖登录状态的接口在WebConfig里用excludePathPatterns配置即可。4.3 商品与订单的接口设计商品列表接口是一个典型的分页查询我用MyBatis-Plus的分页插件实现public PageProductVO queryProductPage(Integer categoryId, String keyword, Integer pageNum, Integer pageSize) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(categoryId ! null, Product::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Product::getName, keyword) .eq(Product::getStatus, 1) .orderByDesc(Product::getSales); return productMapper.selectPage(new Page(pageNum, pageSize), wrapper); }特别注意where条件里eq和like前面的布尔条件参数为空时自动忽略该条件这样列表页的筛选、搜索、排序就全部复用这个方法不用为每种场景单独写一个查询。订单生成的核心代码要展示一下事务控制Transactional(rollbackFor Exception.class) public Long createOrder(OrderRequestDTO dto, Long userId) { ListCartItem items cartItemMapper.selectCheckedItems(userId); BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CartItem item : items) { Product product productMapper.selectById(item.getProductId()); if (product.getStock() item.getQuantity()) { throw new BusinessException(商品[ product.getName() ]库存不足); } product.setStock(product.getStock() - item.getQuantity()); productMapper.updateById(product); totalAmount totalAmount.add(product.getPrice().multiply(new BigDecimal(item.getQuantity()))); // 构建订单明细... } Order order new Order(); order.setOrderNo(generateOrderNo()); order.setTotalAmount(totalAmount); order.setStatus(0); orderMapper.insert(order); // 批量插入订单明细清空购物车勾选项 return order.getId(); }这段代码的重点是商品价格一律以数据库实时查询为准绝不信任前端传过来的价格字段。即使前端把价格改了后端订单总额还是按库里价格计算堵住了越权改价的漏洞。5. 前端Vue实现要点5.1 前端路由结构与页面组织Vue前端项目我用了Vite构建路由基于Vue Router 4。前端一共设计了两套页面布局用户端布局和后台管理布局。用户端路由挂在Layout组件下包括首页、商品列表、商品详情、购物车、订单列表、订单详情、个人中心。后台管理路由挂在AdminLayout下包括控制台、商品管理、分类管理、订单管理、用户管理。两个布局通过路由的meta字段和登录状态做跳转控制。路由守卫是必须写的不然任何人都能翻到后台管理页面router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path.startsWith(/admin) (!token || role ! ADMIN)) { next(/login) } else if (!token to.path /cart) { next(/login) } else { next() } })这里我踩过一个坑本地存储里的角色信息不够安全前端只能做展示控制真正的权限校验一定要靠后端接口拦截。后端如果没做用户直接调接口就能管理商品这是致命漏洞。所以前端路由守卫只是用户体验优化不是安全边界。5.2 Axios封装与Token处理项目里我把Axios统一封装成一个request.js文件所有接口请求都走这个封装。核心思路是请求拦截器加Token响应拦截器统一处理code和401。const request axios.create({ baseURL: /api, timeout: 10000, withCredentials: true }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 200) { ElMessage.error(res.message || 系统错误) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )做这个封装最大的好处是登录失效的逻辑只写一遍所有页面共用。如果登录状态过期了用户任意操作都会自动被踢回登录页体验上比弹一堆错误提示友好得多。5.3 购物车状态的跨页面同步购物车是一个典型的跨越多个页面的状态用户在商品详情页点“加入购物车”购物车右上角小红点要立刻更新数量在购物车页面修改数量后商品列表页再次进入购物车看到的也得是改完后的数据。这种跨页面通信用Pinia管理状态最合理。// store/cart.js export const useCartStore defineStore(cart, { state: () ({ cartCount: 0, cartList: [] }), actions: { async fetchCartCount() { const data await api.getCartCount() this.cartCount data }, async addToCart(productId, quantity) { await api.addToCart(productId, quantity) await this.fetchCartCount() } } })这里必须要说一句购物车数据可以存内存但增删改查一定要走后端接口。很多人图省事把整个购物车存在本地存储里这在真实项目里会带来一堆问题。页面上展示的数据如果和库里不一致用户换设备后数据就丢了后端也无法统计加购转化率。6. 部署运行与源码使用6.1 环境准备不管你是拿这份源码学习还是做课程设计先把环境统一一下不要在这个环节耽误太多时间。后端需要JDK 1.8或11Maven 3.6MySQL 5.7或8.0Redis可有可无。前端需要Node.js 16以上npm或yarn都行。开发工具无所谓IDEA、Eclipse都能跑。MySQL版本要注意如果你的数据库是8.0要把pom.xml里的mysql-connector-java版本升级到8.x否则驱动类会报错。这是很多人导完项目跑不起第一个原因。前端依赖安装建议使用npm install而不是cnpm installcnpm虽然快但偶尔会有依赖丢失导致启动报错的问题。宁可用慢一点的npm也别给自己埋雷。6.2 数据库初始化源码配套的sql脚本一般是init.sql直接用Navicat或者命令行导入即可。导入之后要检查几个核心表是否创建成功重点看user表里有没有初始化的管理员账号。数据库连接配置在application.yml里需要改成你自己的数据库账号密码spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 你的密码连接串里serverTimezone一定要配不配的话时区默认是UTC插入时间会和本地时间差8个小时。这个问题排查起来特别隐蔽因为数据能存进去页面也能显示就是时间不对。6.3 前后端启动步骤启动顺序先后端后前端。后端启动直接在IDEA里运行Application类看到“Started Application in xx seconds”日志就是启动成功了。前端在项目根目录下执行npm install npm run devVite默认端口是5173启动后浏览器访问http://localhost:5173即可。后端默认端口8080前端页面里通过Vite的proxy把/api开头的请求转发到后端// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })使用代理的好处是前端请求不需要写完整的后端地址也不用在Axios里硬编码IP。以后部署到生产环境只需要改代理目标就行。如果不配置代理页面上请求会出现跨域报错所以在开发阶段就配好代理能省掉很多麻烦。7. 常见问题与排查技巧实录7.1 端口占用后端启动报Port 8080 was already in use这是最常见的启动问题。一般原因是电脑上已有一个Tomcat或者其他服务占用了8080端口。解决办法有两条路杀掉占用进程改端口。杀进程方法在Windows下是netstat -ano | findstr 8080查到PID后在任务管理器结束进程。更推荐的做法是直接改application.yml里的server.port改成8081或8088避免和已有的服务冲突。改端口后前端的代理target也要同步改否则页面请求会404。7.2 跨域请求报错如果前后端分离但没配置代理或者前端用的是全局请求地址而不是代理方式请求会报CORS错误浏览器控制台提示Access-Control-Allow-Credentials等相关信息。跨域的本质是浏览器的同源策略限制协议、域名、端口只要有一个不同就不让请求通过。解决跨域的方式有代理、CORS、JSONP等。前后端分离项目推荐用Vite代理最简单也最干净。如果一定要用CORS后端配置方式在Spring Boot里可以注册一个CorsFilter允许指定路径的跨域请求。7.3 数据库连接不上启动时报Cannot create PoolableConnectionFactory或者Access denied for user十有八九是数据库配置问题。按照以下顺序排查MySQL服务是否启动application.yml里的host、端口、用户名、密码是否和本地一致数据库名称是否写对了注意大小写MySQL 8.0的驱动和URL是否匹配特别注意第一点。很多同学装了MySQL但服务没启动或启动的是MySQL 5.7服务但驱动是8.x版本各种组合错乱。建议本地统一用MySQL 8.0配套的docker容器或者本地安装包都行。7.4 图片上传后不显示商品图片上传成功数据库里也有路径但页面访问图片返回404。这个问题的根源是图片URL没有被映射到静态资源目录。Spring Boot默认只扫描classpath下的一些静态目录自定义的上传目录必须在WebMvcConfigurer里做资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath /); }上传目录的路径建议写成配置文件里的变量例如D:/tmp/upload不要写死。因为换电脑部署路径就变了写死的话源码复用性会大打折扣。另外注意图片上传接口里要限制文件类型只允许jpg、png、jpeg这些常见格式防止用户上传可执行文件造成安全隐患。7.5 MyBatis-Plus的分页插件失效使用了Page对象但返回的总条数一直是0或者只有当前页的数据原因是分页插件没有注册。Spring Boot项目里必须显式添加PaginationInnerInterceptor配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个问题几乎每次搭建新项目都会遇到MyBatis-Plus默认不主动开启分页插件漏配后接口返回的数据看起来“没啥问题”但前端一旦需要总条数和总页数就露馅了。7.6 登录状态刷新后丢失很多人的前端刷新页面后用户信息就没了又被踢回登录页。原因很简单用户信息只存在Vuex或Pinia的内存里没有持久化到localStorage。解决办法登录成功时把Token和用户基本信息都写入localStorage。刷新时路由守卫里先读localStorage恢复用户信息再放行进入页面。这里有一个权衡点Token存localStorage会面临XSS风险但对课程设计和管理系统这种场景localStorage的便利性远大于理论风险。真要更安全可以用HttpOnly Cookie存Token但Vue实现起来会多一层CSRF防御复杂度明显增加。8. 项目扩展与核心体会这个系统做完之后我忍不住想给它再加点东西其实也建议你这么做。增删改查都是基本功想要项目在面试时更有竞争力可以从四个方向往上叠支付功能。真实商城不可能没有支付环节。可以接入模拟支付、支付宝沙箱或者微信支付沙箱至少要把“待支付”到“已支付”的状态流转跑通顺便考虑一下支付回调的通知接口怎么写。缓存优化。商品详情页访问频率高将来数据量大了以后每次都查数据库压力会很大可以考虑把热门商品缓存起来。在目前架构下这个改造点非常自然面试时聊到性能优化方案也更有说服力。搜索增强。商品数量过万之后数据库LIKE查询的效率就会明显下降这时可以考虑引入全文检索或者数据库全文索引。这个改造可以让项目从“课程设计”级别直接拔高到“生产级应用”级别。权限细化。目前管理员是单角色后续可以拆成运营、客服、超级管理员三种角色不同角色看到不同的页面和按钮。做权限控制时要从后端接口层面做不能只在前端隐藏按钮。最后说点个人的体会。做这种全栈项目最大的收获并不是会用某个框架的某个API而是理解了数据在前后端之间怎么流转用户点一个按钮前端如何发请求后端怎么处理参数怎么操作数据库怎么返回结果前端怎么渲染更新。这一整条链路打通之后你会发现以后无论是看别人的代码还是自己设计新功能都会快很多。源码和文档我会继续维护遇到任何问题都可以在评论区把报错信息贴出来我会根据实际情况帮你排查。毕竟这套系统我踩过的坑肯定不止上面这几个大家在跑的过程中如果遇到新坑告诉我也等于帮我更新这份避坑指南。