
1. 项目定位与技术选型为什么这套组合是2025年依旧稳妥的答案网购平台管理系统是个非常经典的业务场景但每次有人问我“想练全栈应该拿什么项目入手”我给的答案总是同一类SpringBoot Vue MyBatis MySQL。这个组合看起来没什么新意甚至被很多人说成“八股文项目”但在2025年回过头来看它反而是最不容易跑偏的一套技术栈。这套系统说白了就是解决三件事前台用户能逛商品、加购物车、下单付款后台管理员能管商品、管订单、管用户整个系统得有一套清晰的角色权限机制不能让普通用户偷偷调管理接口。标题里提到的每项技术正好各管一块SpringBoot负责把后端的业务接口和事务逻辑组织起来Vue负责把页面交互和接口数据渲染成用户能看能点的界面MyBatis负责把Java对象和MySQL表之间的数据操作变成可控的SQLMySQL则是所有商品、订单、用户数据的最终归宿。如果你正在准备课程设计、毕业设计或者想做一个能写进简历的完整全栈项目这套组合是目前性价比最高的选择。它不像微服务那套动辄拉十几个中间件也不像纯前端方案那样绕不开Mock数据和假接口。单体应用加清晰分层足够让你把业务逻辑、数据库设计、接口联调、权限控制这些全栈基本功完整地过一遍。还有一个原因值得说这套技术栈的社区沉淀足够厚。你在开发中十有八九会遇到的问题比如MyBatis的XML映射写错导致SQL报错、Vue打包后接口路径不对、JWT跨域拦截失效几乎都能快速找到解决方案。相比之下如果选了一个很小众的框架组合光是环境问题就能劝退一半人。1.1 技术栈里每一项的具体分工先理清每层技术到底做了什么这对后面看懂系统整体结构很有帮助。SpringBoot后端应用的基础框架负责提供HTTP接口、管理Bean的生命周期、处理事务和异常。它把传统的Spring配置大幅简化内嵌Tomcat打包成Jar就能跑。在管理系统里Controller层接收前端请求Service层写业务逻辑Mapper层操作数据库这一套分层模型非常直观。Vue前端SPA框架负责把数据和界面绑定起来。网购平台里最典型的就是商品列表的筛选、购物车数量的实时更新、订单状态的切换这些场景如果不用响应式框架手动操作DOM会非常痛苦。MyBatis持久层ORM框架与SpringBoot集成时通过Mapper接口加XML文件管理SQL。它跟Spring Data JPA最大的区别是SQL由你自己完全掌控对于订单列表这种需要多表联查、条件动态拼接的复杂场景写原生SQL比自动生成的查询更可控。MySQL关系型数据库存数据。购物平台的数据关系其实很复杂——商品有分类订单下有明细用户有地址这些都需要通过外键逻辑或业务逻辑去关联。MySQL的事务特性InnoDB保证了下单时“扣库存、生成订单、清空购物车”这几步要么全成功要么全失败。1.2 为什么不选更“新”的架构2025年已经有很多人鼓吹“前后端分离必须上微服务”“MyBatis过时了用MyBatis-Plus或者JPA”。我的看法是如果你的目标是理解系统本身而不是演练分布式架构那么单体应用加经典持久层框架反而是最优路径。微服务带来的注册中心、网关、配置中心、分布式事务这些概念在真实项目中确实重要但放在一个课程设计或小型管理系统中它们只是额外负担。你会把大量时间花在解决框架本身的问题上而不是业务逻辑。同样的道理如果一上来就无脑用MyBatis-Plus的代码生成器虽然CRUD写起来快但你对SQL映射、动态SQL这些底层机制的理解会缺失一大块。我始终觉得做管理系统这类项目核心不是技术的“新”而是你把一条完整的数据链路打通的能力用户在页面点了一个按钮数据经过Vue组件、Axios请求、Controller、Service、MyBatis、MySQL再原路返回渲染到页面上你能清晰地讲出每一层发生了什么这才是面试官真正想看到的。2. 核心功能模块与数据模型设计电商系统从哪里开始下手拿到一个网购平台管理系统最忌讳的是一上来就写代码。首先要做的是把功能边界和数据关系理清楚。这个系统按角色划分本质上只有两种人普通用户和管理员。用户端的功能围绕“逛-买-查”展开管理端围绕“维护-处理-统计”展开。我这次整理这套源码时按模块拆成了几个部分用户模块注册登录、个人信息、收货地址、商品模块商品分类、商品列表、商品详情、库存、购物车模块增删改查、选中状态、订单模块创建订单、订单列表、订单状态流转、后台管理模块商品管理、分类管理、订单处理、用户管理。这些模块合在一起才是一个能自洽的电商闭环。2.1 数据库表的设计一开始就要想清楚的几张表有经验的开发者都知道管理系统的代码不难难的是表结构设计。表设计不合理后面写SQL时每一条都会很痛苦。这套系统的核心表我建议按下面这个清单来建表名用途关键字段user普通用户信息id, username, password, nickname, phone, addressadmin管理员账号id, username, password, rolecategory商品分类id, name, sort_orderproduct商品id, category_id, name, description, price, stock, image, statuscart_item购物车项id, user_id, product_id, quantity, checkedorder订单主表id, order_no, user_id, total_amount, status, address, create_timeorder_item订单明细id, order_id, product_id, product_name, price, quantityaddress收货地址id, user_id, receiver, phone, detail订单主表和订单明细表为什么要拆开这是一个很多人忽略的点。订单主表存的是一次下单的整体信息比如总金额、订单状态、收货地址订单明细表存的是这次订单里到底买了哪些商品、每个商品当时的价格和数量。拆开的理由是商品的价格和名称可能随时变化但订单一旦生成必须保留下单那一刻的快照否则后续对账和售后就说不清了。还有一个细节是订单编号的设计。我看到很多初学者喜欢直接用数据库自增ID当订单号这样不好因为外部能看到你的订单量而且多个业务系统对接时容易冲突。稳妥的做法是生成一个唯一的业务编号比如时间戳加随机数或者日期加用户ID加序列号存成order_no字段对外展示和查询都走这个编号。2.2 订单状态机让状态流转在代码里处处可见订单状态是管理系统里最容易写乱的部分。用户点击下单订单状态变成待支付支付回调之后变成已支付商家发货后变成已发货用户确认收货后变成已完成期间用户可以申请取消。把这个流程在代码里写成状态机的形式比到处散落的if order.getStatus() 1清晰得多。我用的方案是在订单实体里定义一个状态字段用数字表示再用一个常量类统一管理public class OrderStatus { public static final int PENDING_PAYMENT 0; // 待支付 public static final int PAID 1; // 已支付/待发货 public static final int SHIPPED 2; // 已发货 public static final int COMPLETED 3; // 已完成 public static final int CANCELLED 4; // 已取消 }后端在Service层提供状态流转的方法比如pay(orderId)、ship(orderId)、complete(orderId)、cancel(orderId)每个方法里第一件事就是检查当前状态是否允许这次流转。以取消订单为例只有待支付状态才能由用户主动取消已发货的订单用户不能直接取消这是业务规则写代码时必须体现出来。实测下来把状态流转的方法集中到一个Service类里配合测试用例覆盖每一条流转路径比在Controller里写一堆散落的逻辑改动成本低得多。这也是这套源码里我比较满意的一个设计。3. 后端骨架搭建SpringBoot 与 MyBatis 的协作细节后端是整个系统的中枢前端页面所有能看到的、能点击的数据操作最终都要落到后端接口上。我在搭建这套系统时沿用了最经典的Controller → Service → Mapper 三层架构每层只做自己该做的事。这里有几个关键细节值得好好说说。3.1 分层结构、统一返回体和全局异常处理项目包结构大致长这样com.example.mall ├── controller │ ├── UserController.java │ ├── ProductController.java │ ├── CartController.java │ └── OrderController.java ├── service │ ├── UserService.java │ ├── ProductService.java │ ├── CartService.java │ └── OrderService.java ├── mapper │ ├── UserMapper.java │ ├── ProductMapper.java │ ├── CartMapper.java │ └── OrderMapper.java ├── entity ├── dto ├── config └── commonController里不写业务逻辑只做参数接收、调用Service、返回结果。Service里处理事务和业务流程。Mapper里只定义接口方法SQL写在对应的XML文件里。为了让前端对接起来省心接口统一返回固定格式的对象。我在common包里定义了一个ResultT类包含code、message、data三个字段。成功时code200业务失败时code400未登录时code401无权限时code403。前端根据code值做全局统一处理而不是每个接口单独判断。同时配一个全局异常处理器用RestControllerAdvice捕获Service层抛出的自定义业务异常直接转成Result返回。这个做法的好处是代码里不用到处写try-catch业务校验不通过就throw new BusinessException(库存不足)由统一的地方兜底处理。3.2 MyBatis XML 映射动态SQL比想象中更重要MyBatis最核心的用法是Mapper接口加XML。接口里定义方法XML里写SQL通过namespace绑定。以商品分页查询为例前端会传分类ID、关键字、价格区间、页码、每页条数这些条件组合起来是典型的动态SQL场景。XML里的写法大概是这样的select idselectByCondition resultTypecom.example.mall.entity.Product SELECT * FROM product where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select这段SQL里有两个容易写错的地方。一个是比较运算符和在XML里必须转义成gt;和lt;不然后面解析XML就会出问题。另一个是LIMIT的参数类型offset这个参数更像是在传入时就算好而不是直接在XML里传页码、再在XML里算偏移量那样容易出类型转换问题。为保证分页查询的总条数还需要配一个selectCountByCondition方法SQL条件部分和列表查询保持一致只是查询字段变成COUNT(*)。这也是为什么我用sql标签把公共的条件片段抽出来避免两段SQL里的where写两遍、改一处漏一处。3.3 主键回填与批量插入的细节在购物车和订单场景下有一个开发中一定会遇到的点插入一条记录后立刻要拿到自增主键。比如创建订单后要向订单明细表插入好几条数据每条明细都需要order_id作为外键。MyBatis里用useGeneratedKeys解决insert idinsert parameterTypecom.example.mall.entity.Order useGeneratedKeystrue keyPropertyid INSERT INTO order (order_no, user_id, total_amount, status) VALUES (#{orderNo}, #{userId}, #{totalAmount}, #{status}) /insert加了useGeneratedKeystrue和keyPropertyid之后插入操作执行完MyBatis会把数据库生成的自增ID回填到传入的Order对象的id字段里。后续代码直接order.getId()就能拿到不需要再单独查一次。批量插入订单明细时XML里用foreach遍历集合insert idbatchInsert INSERT INTO order_item (order_id, product_id, product_name, price, quantity) VALUES foreach collectionitems itemitem separator, (#{item.orderId}, #{item.productId}, #{item.productName}, #{item.price}, #{item.quantity}) /foreach /insert这里有个坑要提醒一下foreach的separator,只负责拼接之间的分隔符SQL末尾千万不要自己跟分号。还有就是批量插入时如果列表很大要控制单批的条数MySQL对单条SQL的长度有限制1.6万条左右的明细一批执行就差不多到边界了。实际项目里拆成每500条一批再插保险得多。4. 前端Vue工程与后端接口联调从页面到数据的完整链路Vue端的开发很多人觉得难的不是页面样式而是管理页面的状态和接口交互逻辑。这套网购平台的前端我建议用Vue 3加Vite搭建路由用Vue Router状态用Pinia请求统一走Axios。下面挑几个关键点拆开讲。4.1 工程目录设计与状态管理前端的目录结构建议按页面和模块来分src ├── api │ ├── product.js │ ├── cart.js │ ├── order.js │ └── user.js ├── router │ └── index.js ├── store │ ├── user.js │ └── cart.js ├── views │ ├── Home.vue │ ├── ProductDetail.vue │ ├── Cart.vue │ ├── OrderList.vue │ └── admin │ ├── ProductManage.vue │ └── OrderManage.vue └── utils └── request.jsapi目录下每个文件负责一组接口的调用封装比如product.js里导出getProductList(params)、getProductDetail(id)这些函数。这样做的最大好处是页面组件里不直接出现接口URL改后端地址时只改一处。购物车这种跨页面共享的数据很值得用Pinia管理。用户把商品加入购物车后跳转到结算页购物车数据依然存在刷新页面后又要从后端重新拉取。我的做法是cart store里维护一个items数组和totalCount、totalPrice两个计算属性页面组件通过action方法触发后端同步store里的状态始终保持唯一真相源。4.2 Axios拦截器登录态统一注入与401统一处理前后端分离后登录信息一般通过Token传递。Vue端在登录成功后拿到后端返回的Token存到localStorage里之后每个请求都要在请求头带上Authorization: Bearer token。逐行写在每个请求里会很蠢正确做法是在Axios实例里加拦截器。// utils/request.js import axios from axios import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) 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) { return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )这里有一个不经意就会踩的坑后端返回业务错误时HTTP状态码很可能还是200只有响应体里的code字段才是401或400。所以拦截器里要同时处理两种情况——HTTP层返回的401以及HTTP 200但业务code为401的响应。否则前端只会弹出“请求失败”用户根本不知道自己为什么被踢下线。4.3 商品列表页与后台接口联调的一个完整示例以商品列表页为例完整的前后端联调链路是这样的前端在created生命周期里调用getProductList({ pageNum: 1, pageSize: 10 })Axios发出GET请求后端ProductController的list方法接收参数调用Service层Service调用Mapper的selectByCondition返回Result.success(pageResult)前端拿到数据后渲染到页面上。有一个开发阶段特有的坑是接口地址前缀问题。后端Controller里的映射是/product/list前端Axios的baseURL是/api配套的解决办法是在后端统一加一个server.servlet.context-path/api或者在前端用Vite的代理把/api转发到http://localhost:8080。我比较推荐在开发环境用Vite代理这样前端代码里所有请求都写/api/...上线时再让Nginx把/api路径反向代理到后端服务前后端联调时就不用纠结端口跨域的问题了。5. 权限控制与安全细节登录状态如何贯穿全栈管理系统如果没有权限控制那和公开网站没有区别。用户能访问管理员接口这是致命问题。网购平台管理系统的权限体系分为两层用户登录鉴权和管理员角色授权。5.1 基于JWT的认证流程我在这套系统里用JWT做登录凭证。用户或管理员登录成功后后端生成一个Token返回给前端Token里包含用户ID、用户名和角色信息同时设置一个合理的过期时间比如用户端2小时管理端30分钟。// 登录成功后生成Token String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(username, user.getUsername()) .claim(role, USER) .setExpiration(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact();为什么用JWT而不是简单的Session因为前后端分离后后端不依赖Servlet容器维护会话状态JWT是无状态的后端只需要验证签名和过期时间不需要在服务端保存会话数据。这对系统的横向扩展也友好多实例部署时不用额外做Session共享。JWT也有需要注意的坑如果你把敏感信息放进去别人用Base64解码就能看到所以千万不要把密码放进Token只需要放用户ID和角色。另外SECRET_KEY要足够复杂硬编码在代码里只是教学做法真实项目应该放到配置中心或环境变量里。5.2 后端拦截器与角色校验的落地方式鉴权不能只靠“前端不显示按钮”后端每个接口都必须做校验。SpringBoot里我用HandlerInterceptor实现登录拦截再用一个注解实现角色控制。拦截器里的核心逻辑大致是public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口 String uri request.getRequestURI(); if (uri.contains(/login) || uri.contains(/register)) { return true; } // 从请求头获取Token String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } // 验证Token try { Claims claims Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token.substring(7)).getBody(); request.setAttribute(userId, claims.getSubject()); return true; } catch (Exception e) { response.setStatus(401); return false; } }拦截器注册到WebMvcConfigurer里并排除登录、注册、商品查看等公开接口。角色校验我用了自定义注解RequireRole(ADMIN)标注在管理端的Controller上在拦截器里解析HandlerMethod上的注解如果当前用户角色不匹配直接返回403。管理员接口和用户接口混在同一套代码里有一个隐患管理员登录走的是user表还是admin表为了简单我在系统里单独建了admin表登录接口分开Token里用role字段区分。这样在拦截器层面就能直接控制“这个接口只允许管理员访问”而不是在业务代码里反复判断。5.3 前端路由守卫与过期的处理前端也要做对应的防护用户未登录时不能进入购物车和订单页面管理员未登录时不能进入后台管理页面。Vue Router的beforeEach全局守卫里读取localStorage里的Token和角色信息做路由级别的前置判断。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.requiresAdmin localStorage.getItem(role) ! ADMIN) { next(/) } else { next() } })这个方案的局限是它只挡了页面跳转挡不住恶意的接口调用。所以前端路由守卫只是用户体验层面的优化真正的安全边界永远在后端。我经常提醒刚入门的开发者前端的任何校验都可以被绕过后端校验才是底线。Token过期也是实际使用中一定会遇到的情况。我在响应拦截器里统一处理401当Token过期时清掉本地存储跳转到登录页。这里有个体验细节用户在购物车页面操作半天突然Token过期需要重新登录之前填的数据全丢了。更合理的方案是后端返回401时前端弹窗提示“登录已过期”点击确认后再跳转登录页并且通过redirect参数记录来源路由登录成功后跳回原页面。6. 部署上线与踩坑自查清单代码之外的经验值跑通本地开发环境和真正把系统部署起来给外人访问之间隔着的距离远比你想象的大。一套源码如果只能在本地跑价值会打很大折扣。我这次整理这套网购平台管理系统时把实际部署中踩过的坑也一并梳理了一遍。6.1 本地开发最容易卡住的几个点MySQL 8 的时区问题。JDBC连接串如果没加serverTimezoneAsia/Shanghai驱动会报“The server time zone value”错。很多人搜到这个报错直接把时区删了结果时间字段差8小时坑到后面的订单时间显示。数据库初始化脚本的字符集与排序规则。建库时最好显式指定DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci。如果表默认用了latin1商品标题里出现中文或表情符号直接乱码或插入失败。别问我怎么知道的排查了很久才发现是MySQL配置文件里character_set_server没改。SpringBoot 3 与 MyBatis Starter 版本匹配。SpringBoot 3.x基于Jakarta EEMyBatis的mybatis-spring-boot-starter必须用带3的适配版本如果你从网上找的老教程用的是2.x版本启动会直接报ClassNotFoundException卡住很长时间。建议直接用SpringBoot 2.7.x搭配MyBatis Starter 2.3.x这组组合非常稳定适合管理系统这种场景。Lombok的版本坑。装了Lombok插件但IDEA还是不认识Data注解生成的getter/setter多半是插件和Lombok版本不匹配。我一般直接用IDEA的捆绑插件版本不手动升级实测最稳。6.2 前后端构建与部署的常见问题前端npm run build之后把dist目录部署到Nginx看着一切正常一刷新页面就404——这是Vue Router启用HTML5 History模式的经典问题。解决方案是在Nginx配置里加一个try_filesserver { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }如果不做这一步刷新/product/1这种路径时Nginx会去找/product/1这个真实文件找不到就404。加上try_files后所有找不到的文件都会回退到index.html由Vue Router接管路由解析。后端部署时的另一个问题是端口和前缀不匹配。我一贯的做法是后端打包成Jar运行在8080端口但不建议直接暴露给公网。Nginx里配置location /api/反向代理到本机的8080端口这样外部访问只走Nginx的80端口后端接口不会直接被攻击面扫到。Nginx代理时记得带上proxy_set_header Host $host;否则后端拿到的请求头不对某些场景下会导致请求失败。前后端联调线上环境的跨域问题也经常出现。如果已经用Nginx做了反向代理让前端请求和后端接口通过同一个域名路径区分就不会有跨域问题。如果非要分开部署到不同域名需要在后端配置CrossOrigin或全局CORS同时注意处理OPTIONS预检请求。6.3 我的一些自查建议真正把一套系统跑起来之后有几件事是我强烈建议挨个做一遍的带上错误的Token或者不带Token访问用户端接口和管理端接口确认会被后端拦截而不是等前端页面帮你挡。用一个普通用户账号直接尝试访问/admin/product/list这类接口看后端会不会返回403。如果返回了正常数据说明角色校验有漏洞赶紧修。订单流程完整走一遍注册新用户 → 下单 → 支付这里演示环境一般是模拟支付→ 发货 → 确认收货。并且在每一步中断操作重启后端看看订单状态是否保持在合理状态会不会出现状态回退。清理测试数据和日志。开发阶段用的测试账号、随手下载的订单数据部署前最好通过SQL脚本清一遍避免上线后一堆垃圾数据撑爆页面。最后说一下性能和安全方面的优化空间。这套系统的代码结构是清晰的但直接上线生产还需要做一些加固密码虽然用了BCrypt加密但最好再加一层登录失败次数限制商品上传的图片文件要校验文件类型和后缀防止上传恶意脚本数据库和Redis如果后续引入的账号不要用默认的root和空密码接口层面可以加上简单的限流防止被批量刷单。我在实际调试这套系统时最深的体会是技术栈没有太多花活但把每一个环节老老实实打通比看十篇“快速搭建电商系统”的教程都有用。如果你在跑这套源码建议按我上面说的自查清单过一遍大概率能提前解决掉大部分部署期的问题。