1. 项目定位与技术选型:为什么这套组合是2025年依旧稳妥的答案
网购平台管理系统是个非常经典的业务场景,但每次有人问我“想练全栈,应该拿什么项目入手”,我给的答案总是同一类:SpringBoot + Vue + MyBatis + MySQL。这个组合看起来没什么新意,甚至被很多人说成“八股文项目”,但在2025年回过头来看,它反而是最不容易跑偏的一套技术栈。
这套系统说白了就是解决三件事:前台用户能逛商品、加购物车、下单付款;后台管理员能管商品、管订单、管用户;整个系统得有一套清晰的角色权限机制,不能让普通用户偷偷调管理接口。标题里提到的每项技术,正好各管一块:SpringBoot负责把后端的业务接口和事务逻辑组织起来,Vue负责把页面交互和接口数据渲染成用户能看能点的界面,MyBatis负责把Java对象和MySQL表之间的数据操作变成可控的SQL,MySQL则是所有商品、订单、用户数据的最终归宿。
如果你正在准备课程设计、毕业设计,或者想做一个能写进简历的完整全栈项目,这套组合是目前性价比最高的选择。它不像微服务那套动辄拉十几个中间件,也不像纯前端方案那样绕不开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, address |
admin | 管理员账号 | id, username, password, role |
category | 商品分类 | id, name, sort_order |
product | 商品 | id, category_id, name, description, price, stock, image, status |
cart_item | 购物车项 | id, user_id, product_id, quantity, checked |
order | 订单主表 | id, order_no, user_id, total_amount, status, address, create_time |
order_item | 订单明细 | id, order_id, product_id, product_name, price, quantity |
address | 收货地址 | 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包里定义了一个Result<T>类,包含code、message、data三个字段。成功时code=200,业务失败时code=400,未登录时code=401,无权限时code=403。前端根据code值做全局统一处理,而不是每个接口单独判断。
同时配一个全局异常处理器,用@RestControllerAdvice捕获Service层抛出的自定义业务异常,直接转成Result返回。这个做法的好处是代码里不用到处写try-catch,业务校验不通过就throw new BusinessException("库存不足"),由统一的地方兜底处理。
3.2 MyBatis XML 映射:动态SQL比想象中更重要
MyBatis最核心的用法是Mapper接口加XML。接口里定义方法,XML里写SQL,通过namespace绑定。以商品分页查询为例,前端会传分类ID、关键字、价格区间、页码、每页条数,这些条件组合起来是典型的动态SQL场景。
XML里的写法大概是这样的:
<select id="selectByCondition" resultType="com.example.mall.entity.Product"> SELECT * FROM product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>这段SQL里有两个容易写错的地方。一个是比较运算符>和<在XML里必须转义成>和<,不然后面解析XML就会出问题。另一个是LIMIT的参数类型,offset这个参数更像是在传入时就算好,而不是直接在XML里传页码、再在XML里算偏移量,那样容易出类型转换问题。
为保证分页查询的总条数,还需要配一个selectCountByCondition方法,SQL条件部分和列表查询保持一致,只是查询字段变成COUNT(*)。这也是为什么我用<sql>标签把公共的条件片段抽出来,避免两段SQL里的<where>写两遍、改一处漏一处。
3.3 主键回填与批量插入的细节
在购物车和订单场景下,有一个开发中一定会遇到的点:插入一条记录后,立刻要拿到自增主键。比如创建订单后,要向订单明细表插入好几条数据,每条明细都需要order_id作为外键。
MyBatis里用useGeneratedKeys解决:
<insert id="insert" parameterType="com.example.mall.entity.Order" useGeneratedKeys="true" keyProperty="id"> INSERT INTO `order` (order_no, user_id, total_amount, status) VALUES (#{orderNo}, #{userId}, #{totalAmount}, #{status}) </insert>加了useGeneratedKeys="true"和keyProperty="id"之后,插入操作执行完,MyBatis会把数据库生成的自增ID回填到传入的Order对象的id字段里。后续代码直接order.getId()就能拿到,不需要再单独查一次。
批量插入订单明细时,XML里用<foreach>遍历集合:
<insert id="batchInsert"> INSERT INTO order_item (order_id, product_id, product_name, price, quantity) VALUES <foreach collection="items" item="item" 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连接串如果没加serverTimezone=Asia/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 EE,MyBatis的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_files:
server { 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和空密码;接口层面可以加上简单的限流,防止被批量刷单。
我在实际调试这套系统时,最深的体会是:技术栈没有太多花活,但把每一个环节老老实实打通,比看十篇“快速搭建电商系统”的教程都有用。如果你在跑这套源码,建议按我上面说的自查清单过一遍,大概率能提前解决掉大部分部署期的问题。