2026/10/9 9:06:38

电商后台管理系统实战:Vue3+Spring Boot前后端分离架构与核心模块实现

电商后台管理系统实战:Vue3+Spring Boot前后端分离架构与核心模块实现 电商后台管理系统这个题目几乎每个做企业级开发的人都会碰到。我前前后后参与过三个不同规模的电商后台项目从最早的JSPServlet一路做到现在的VueSpring Boot前后端分离踩过的坑可以说能写一本小册子。这篇文章不讲虚的直接把我自己搭建这套系统的完整思路、技术选型理由、核心模块实现、以及那些只有真正动过手才知道的细节问题全部摊开来讲。适合有一定Java和前端基础、想独立完成一个后台管理系统的开发者也适合正在做技术选型、纠结要不要上前后端分离的团队参考。1. 为什么电商后台一定要走前后端分离1.1 从一次接口联调事故说起早些年我做过一个单体架构的电商后台前端页面用Thymeleaf模板渲染后端Controller直接返回ModelAndView。项目初期确实快一个人三天就能把商品列表页跑通。但到了中期问题开始集中爆发。运营那边要改一个订单列表的筛选条件前端需要加一个下拉框。按理说这是个很小的需求但因为页面是后端渲染的改一个筛选条件意味着要动Controller、改模板、重新打包部署整个应用。更麻烦的是前端同事想本地调试样式必须把整个Spring Boot项目跑起来还得连上数据库。一个CSS的微调等环境启动就要两分钟。真正让我下定决心重构的是一次接口联调。后端改了订单查询的返回结构把原来的orderStatus字段从数字改成了枚举字符串但没有及时通知前端。前端页面上所有订单状态全部显示为空白运营以为订单丢了差点引发事故。这种强耦合带来的沟通成本和风险在单体架构里几乎无解。前后端分离之后前后端之间只通过一份约定好的API文档沟通。后端改字段先改文档前端照着文档调整。双方可以并行开发前端用Mock数据先把页面跑起来后端专注写业务逻辑。部署也独立了前端打包成静态资源扔到Nginx后端打成Jar包独立运行互不影响。1.2 电商后台的业务特点决定了架构选择电商后台和普通的管理系统不太一样它的业务复杂度集中在几个方面数据量大且关联复杂商品、SKU、订单、用户、库存、优惠券这些实体之间的关联关系非常密集。一个订单详情页可能要同时查订单主表、订单明细、商品信息、用户信息、物流信息。操作频率高运营人员每天要处理大量订单、上下架商品、调整库存页面的响应速度直接影响工作效率。权限粒度细不同角色的运营人员能看到的菜单和能执行的操作完全不同超级管理员、商品运营、订单客服、财务每个角色的权限边界都要精确控制。实时性要求库存扣减、订单状态变更这些操作需要及时反映到前端。这些特点决定了后端必须是一个结构清晰、分层合理的RESTful API服务前端必须是一个组件化、状态管理完善的单页应用。Vue.js的响应式数据和组件化能力配合Spring Boot的快速开发和生态完善是目前比较成熟且上手成本可控的组合。1.3 技术选型的几个关键决策点在正式动手之前有几个选型问题需要想清楚前端框架选Vue 2还是Vue 3我选的是Vue 3 Composition API。原因很简单Vue 3的Composition API在组织复杂业务逻辑时比Options API清晰太多。电商后台一个商品编辑页面可能涉及基本信息的表单、SKU表格的动态增删、图片上传、富文本编辑用Options API写出来data、methods、computed全部散落在不同位置维护起来很痛苦。Composition API可以把相关的逻辑聚合在一起按功能模块组织代码。UI组件库选哪个Element Plus是Vue 3生态里最成熟的后台管理组件库表格、表单、弹窗、树形控件这些电商后台高频使用的组件都很完善。Ant Design Vue也不错但Element Plus的中文文档和社区案例更多遇到问题更容易找到解决方案。后端用Spring Boot 2还是3如果项目不需要JDK 17的新特性Spring Boot 2.7.x是更稳妥的选择生态兼容性更好。但如果是从零开始的新项目直接上Spring Boot 3 JDK 17也没问题长期来看更有优势。我这边为了兼容一些老的依赖选的是Spring Boot 2.7。数据库和ORM怎么选MySQL是电商系统的标配这个没什么好纠结的。ORM框架我选的是MyBatis-Plus而不是JPA。原因在于电商后台的查询条件非常灵活商品列表可能按名称模糊查、按分类查、按价格区间查、按上架时间查这些动态SQL用MyBatis-Plus的QueryWrapper写起来非常顺手而JPA在这种场景下要么写一堆Specification要么就得用原生SQL。认证方案用什么JWT Spring Security是主流方案。但这里有个细节JWT的token过期时间设置很关键。设太短运营人员操作到一半被踢出去体验很差设太长安全性又不够。我的做法是access token设2小时同时用refresh token做无感刷新前端在axios拦截器里统一处理token过期后的刷新逻辑。2. 项目骨架搭建从零到能跑起来2.1 后端工程结构设计Spring Boot项目的包结构我见过很多种组织方式有的按层分controller、service、dao有的按业务模块分。对于电商后台这种业务模块清晰的系统我更推荐按业务模块划分每个模块内部再分层。com.example.mall ├── common // 公共模块统一返回结果、异常处理、工具类 ├── config // 配置类Security、MyBatis-Plus、Redis、Swagger ├── module │ ├── product // 商品模块 │ │ ├── controller │ │ ├── service │ │ ├── mapper │ │ ├── entity │ │ └── dto │ ├── order // 订单模块 │ ├── user // 用户模块 │ └── system // 系统管理菜单、角色、权限 └── MallApplication.java这样分的好处是当你要找商品相关的代码时直接进module/product目录所有相关文件一目了然。按层分的话controller目录下堆了几十个文件找起来很费劲。统一返回结果是必须的。我定义了一个ResultT类包含code、message、data三个字段。所有Controller的方法都返回Result类型前端只需要判断code是否为200就能知道请求成功与否。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.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理也是必须的。用RestControllerAdvice捕获所有未处理的异常统一转换成Result格式返回。这样前端永远拿到的是结构一致的JSON不会因为后端抛了异常就收到一个HTML错误页。2.2 前端工程初始化与目录规划前端用Vite创建Vue 3项目比Webpack快太多了。npm create vitelatest mall-admin -- --template vue几秒钟就创建好了。目录结构我这样规划src ├── api // 所有接口请求按模块分文件 ├── assets // 静态资源 ├── components // 公共组件 ├── layout // 布局组件侧边栏、顶栏、标签页 ├── router // 路由配置 ├── store // Pinia状态管理 ├── utils // 工具函数request封装、权限校验 ├── views // 页面组件 │ ├── product │ ├── order │ ├── user │ └── system └── main.js这里重点说一下utils/request.js的封装。axios的拦截器是整个前端请求体系的核心需要处理几件事请求拦截自动在header里带上token响应拦截统一处理业务错误码比如401跳转登录页500弹出错误提示token无感刷新当接口返回token过期时自动用refresh token换新token并重发原请求import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /store/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { const userStore useUserStore() userStore.logout() router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default service2.3 跨域问题的处理方式开发阶段前端跑在5173端口后端跑在8080端口跨域是绕不开的。有两种处理方式方式一后端配置CORS。在Spring Boot里加一个配置类允许来自http://localhost:5173的请求。这种方式简单直接但生产环境如果前后端部署在不同域名下也需要配置。方式二前端配置代理。在vite.config.js里配置proxy把/api开头的请求转发到后端。这种方式的好处是开发阶段前端请求的都是同源地址不存在跨域问题。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })我两种都配了。开发阶段用前端代理生产环境用Nginx反向代理后端同时也配了CORS作为兜底。3. 权限系统电商后台的命脉3.1 RBAC模型在电商场景下的落地电商后台的权限系统核心就是RBAC基于角色的访问控制。但实际落地的时候纯粹的RBAC往往不够用因为电商后台的权限粒度需要控制到按钮级别。我设计的权限模型包含五张表表名说明关键字段sys_user用户表id, username, password, statussys_role角色表id, role_name, role_keysys_menu菜单/权限表id, parent_id, menu_name, perms, menu_typesys_user_role用户角色关联user_id, role_idsys_role_menu角色菜单关联role_id, menu_idsys_menu表是整个权限系统的核心。menu_type字段区分三种类型目录M、菜单C、按钮F。按钮类型的记录perms字段存的是权限标识比如product:sku:edit前端用这个标识来控制按钮的显示隐藏。后端接口的权限控制用Spring Security的PreAuthorize注解PreAuthorize(ss.hasPermi(product:sku:edit)) PutMapping(/sku) public Result updateSku(RequestBody SkuDTO skuDTO) { // ... }这里的ss是一个自定义的权限校验BeanhasPermi方法会从当前登录用户的权限集合中查找是否包含指定权限。3.2 前端动态路由与按钮级权限前端权限控制分两个层面路由层面和按钮层面。路由层面用户登录后后端返回该用户能访问的菜单树。前端根据菜单树动态生成路由用router.addRoute()添加到路由表中。这样用户根本访问不到没有权限的页面比在路由守卫里判断更彻底。// 根据后端返回的菜单生成路由 function generateRoutes(menus) { const routes [] menus.forEach(menu { if (menu.menuType C) { const route { path: menu.path, name: menu.routeName, component: () import(/views/${menu.component}.vue), meta: { title: menu.menuName, icon: menu.icon } } if (menu.children menu.children.length 0) { route.children generateRoutes(menu.children) } routes.push(route) } }) return routes }按钮层面我封装了一个v-permission指令。在按钮上加上v-permission[product:sku:edit]如果当前用户没有这个权限按钮就会被移除。// 权限指令 export default { mounted(el, binding) { const { value } binding const userStore useUserStore() const permissions userStore.permissions if (value value.length 0) { const hasPermission permissions.some(perm value.includes(perm)) if (!hasPermission) { el.parentNode el.parentNode.removeChild(el) } } } }这里有个细节要注意用removeChild移除按钮而不是用v-if。因为v-if需要每个按钮都写一遍判断逻辑太啰嗦。指令的方式更简洁而且可以在全局注册所有页面都能用。3.3 登录流程与Token刷新机制登录流程是这样的前端提交用户名密码到/auth/login后端验证通过后生成access token和refresh token一起返回前端把token存到Pinia和localStorage后续请求在header里带上access tokenaccess token过期后后端返回401前端拦截器捕获401用refresh token调用/auth/refresh换新token换到新token后重发刚才失败的请求这里有个坑如果多个请求同时返回401会触发多次刷新token的请求。解决方案是在拦截器里加一个标志位第一个401触发刷新后续的401等待刷新完成后再重发。let isRefreshing false let failedQueue [] service.interceptors.response.use( response response, async error { const { config, response } error if (response response.status 401) { if (!isRefreshing) { isRefreshing true try { const newToken await refreshToken() failedQueue.forEach(cb cb(newToken)) failedQueue [] config.headers.Authorization Bearer ${newToken} return service(config) } finally { isRefreshing false } } else { return new Promise(resolve { failedQueue.push(token { config.headers.Authorization Bearer ${token} resolve(service(config)) }) }) } } return Promise.reject(error) } )4. 核心业务模块的实现细节4.1 商品管理SPU与SKU的拆分逻辑商品管理是电商后台最复杂的模块核心难点在于SPU和SKU的拆分。SPUStandard Product Unit是标准产品单位比如某品牌运动鞋就是一个SPU。SKUStock Keeping Unit是库存量单位比如某品牌运动鞋 黑色 42码就是一个SKU。数据库设计上product_spu表存商品基本信息名称、分类、品牌、描述product_sku表存具体规格颜色、尺码、价格、库存、图片。product_sku表通过spu_id关联到product_spu。前端商品编辑页面上半部分是SPU信息表单下半部分是SKU表格。SKU表格支持动态添加行每行选择规格值、填写价格和库存。这里有个性能问题如果SKU数量很多比如服装类商品颜色10种、尺码8种就是80个SKU一次性渲染80行表格会很卡。解决方案是用虚拟滚动或者分页展示SKU。我选的是虚拟滚动Element Plus的el-table-v2支持虚拟滚动但API和普通表格不太一样需要额外适配。商品列表查询是另一个性能瓶颈。商品表数据量大了之后模糊查询LIKE %关键词%会导致全表扫描。我的优化方案是商品名称字段加全文索引用MATCH AGAINST代替LIKE列表查询只查必要字段不查商品详情这种大字段分页查询用游标分页代替LIMIT offset, size避免深分页性能问题4.2 订单管理状态机与并发处理订单状态流转是电商后台的核心逻辑。我定义了一个订单状态枚举public enum OrderStatus { PENDING_PAYMENT(0, 待付款), PENDING_SHIPMENT(1, 待发货), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消), REFUNDING(5, 退款中), REFUNDED(6, 已退款); }状态流转不是随便转的必须按照业务规则来。比如待付款只能转到待发货或已取消已发货只能转到已完成或退款中。我用状态机模式来管理这些流转规则每个状态定义允许的下一步操作。public class OrderStateMachine { private static final MapOrderStatus, ListOrderStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING_PAYMENT, Arrays.asList(PENDING_SHIPMENT, CANCELLED)); TRANSITIONS.put(PENDING_SHIPMENT, Arrays.asList(SHIPPED, REFUNDING)); TRANSITIONS.put(SHIPPED, Arrays.asList(COMPLETED, REFUNDING)); TRANSITIONS.put(REFUNDING, Arrays.asList(REFUNDED, SHIPPED)); } public static boolean canTransition(OrderStatus from, OrderStatus to) { return TRANSITIONS.getOrDefault(from, Collections.emptyList()).contains(to); } }并发问题是订单模块必须面对的。最典型的是库存扣减两个订单同时购买同一件商品如果不用锁可能出现超卖。我的方案是数据库层面库存字段用stock stock - #{quantity} WHERE stock #{quantity}的乐观锁方式更新影响行数为0就说明库存不足。Redis层面用分布式锁防止同一商品的并发扣减。锁的key是lock:stock:{skuId}过期时间设10秒。消息队列层面订单创建后发消息到MQ异步处理库存扣减和积分计算避免主流程阻塞。4.3 数据统计ECharts图表的按需加载后台首页的数据看板需要展示销售额趋势、订单量统计、商品销量排行等图表。ECharts是首选但完整引入ECharts会让打包体积增加很多。我的做法是按需引入import * as echarts from echarts/core import { LineChart, BarChart, PieChart } from echarts/charts import { GridComponent, TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])这样只引入用到的图表类型和组件打包体积能减少60%以上。数据统计接口的查询我用了MyBatis-Plus的QueryWrapper配合自定义SQL。比如销售额趋势按天分组统计SELECT DATE(create_time) as date, SUM(pay_amount) as amount FROM order WHERE create_time BETWEEN #{startTime} AND #{endTime} AND status IN (1, 2, 3) GROUP BY DATE(create_time) ORDER BY date这里有个细节DATE(create_time)会导致索引失效。如果数据量大建议在表里冗余一个order_date字段直接存日期然后在这个字段上建索引。5. 部署与性能优化上线前必须做的事5.1 Nginx配置与前端打包优化前端打包用npm run buildVite会生成dist目录。部署到Nginx时有几个关键配置server { listen 80; server_name admin.example.com; root /var/www/mall-admin/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 30d; add_header Cache-Control public, immutable; } }try_files那行是必须的否则刷新页面会404。因为Vue Router用的是history模式刷新时浏览器会向服务器请求当前路径Nginx找不到对应的文件就返回404。try_files会把所有找不到的路径都指向index.html由前端路由处理。静态资源缓存设30天因为Vite打包后的文件名带hash内容变了文件名也会变不用担心缓存问题。5.2 后端JVM调优与数据库连接池Spring Boot应用打包成Jar后启动参数需要调优java -jar mall-admin.jar \ -Xms512m -Xmx1024m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -Dspring.profiles.activeprod-Xms和-Xmx设成一样大避免堆动态扩容带来的性能波动。G1GC在大多数场景下比CMS更稳定MaxGCPauseMillis设200毫秒平衡吞吐量和延迟。数据库连接池用HikariCPSpring Boot默认就是它。关键配置spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 30 idle-timeout: 600000 connection-timeout: 30000 max-lifetime: 1800000maximum-pool-size不是越大越好。一般公式是连接数 ((核心数 * 2) 有效磁盘数)。假设服务器是4核那就是(4*2)19但实际电商后台并发不会太高设20-30足够了。设太大反而会因为线程上下文切换导致性能下降。5.3 接口性能监控与慢查询治理上线之后必须要有监控。我用的是Spring Boot Actuator Prometheus Grafana的组合。Actuator暴露/actuator/metrics和/actuator/health端点Prometheus定时抓取Grafana做可视化。慢查询治理是另一个重点。MyBatis-Plus可以配置SQL执行时间超过阈值的日志输出mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl但生产环境不能一直开SQL日志性能影响太大。我的做法是用P6Spy做SQL拦截只在慢查询时记录日志。P6Spy可以配置executionThreshold1000只记录执行时间超过1秒的SQL。另外MySQL的慢查询日志也要开SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;定期分析慢查询日志找出需要优化的SQL加索引或者改写查询逻辑。6. 那些只有踩过才知道的坑6.1 时间格式的时区问题前后端分离项目里时间格式是最容易出问题的地方。后端用LocalDateTime序列化成JSON时默认是2024-01-15T10:30:00这种ISO格式。前端Element Plus的日期选择器需要的是2024-01-15 10:30:00格式。解决方案是在Spring Boot里配置Jackson的日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8但这样配置后LocalDateTime还是不会按这个格式序列化因为LocalDateTime不受date-format影响。需要额外配置Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; }还有一个更隐蔽的坑数据库连接URL里如果不指定时区MySQL驱动可能会用服务器时区导致存进去的时间和取出来的时间差8小时。连接URL一定要加serverTimezoneAsia/Shanghai。6.2 文件上传的大小限制商品图片上传是电商后台的刚需。Spring Boot默认的文件上传大小限制是1MB超过就会报错。需要在配置文件里调整spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MBNginx也有上传大小限制默认是1MB需要改client_max_body_sizeclient_max_body_size 50m;这两个地方都要改只改一个还是会报错。我当初就是只改了Spring Boot的配置结果Nginx那边一直返回413排查了半天。6.3 前端路由刷新404的完整解决方案前面说了Nginx的try_files配置但还有一种情况如果前端部署在子路径下比如/admin/那Vite的base配置和Vue Router的base都要改。// vite.config.js export default defineConfig({ base: /admin/ }) // router/index.js const router createRouter({ history: createWebHistory(/admin/), routes })Nginx的try_files也要相应调整location /admin/ { alias /var/www/mall-admin/dist/; try_files $uri $uri/ /admin/index.html; }这三个地方必须一致否则就会出现白屏或者资源加载404的问题。6.4 跨域携带Cookie的配置细节如果认证方案用的是Cookie而不是JWT跨域请求需要携带Cookie那配置会更复杂。后端CORS配置必须设置allowCredentials(true)同时allowedOrigins不能是*必须是具体的前端域名。前端axios也要设置withCredentials: true。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173, https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }maxAge设3600秒意思是预检请求的结果缓存1小时减少OPTIONS请求的次数。7. 项目扩展方向与个人建议这套系统跑通之后可以往几个方向扩展。第一个方向是接入工作流引擎把订单审核、退款审批这些流程做成可配置的工作流用Flowable或者Activiti。第二个方向是接入实时通讯用WebSocket做订单状态的实时推送运营人员不用刷新页面就能看到新订单。第三个方向是数据大屏用DataV或者ECharts GL做一个可视化的数据看板放在办公室大屏幕上。我个人在实际操作中的体会是前后端分离项目最怕的不是技术难度而是前后端之间的沟通成本。API文档一定要用Swagger或者Knife4j自动生成后端改接口必须同步更新文档。前端在开发阶段用Mock数据不要等后端接口好了才开始写页面。另外接口的返回结构一定要统一不要有的接口返回{code, data}有的返回{success, result}前端处理起来会疯掉。还有一个建议项目初期不要过度设计。我见过一些团队一上来就搞微服务、搞DDD、搞CQRS结果三个月过去了连商品列表都没跑通。电商后台的核心是业务逻辑先把CRUD跑通把权限控制做好把订单流程理顺再考虑架构升级的事。技术是为业务服务的不是反过来。