在Java后端开发这个圈子里,SSM(Spring + SpringMVC + MyBatis)几乎是每个初入行的开发人员都绕不过去的组合,而Spring Boot的出现又让这个老组合焕发了第二春。至于前端,Vue早已成为中小型管理系统的事实标准。今天我想聊的,就是基于这三者做的一个美容院美妆化妆品商城管理系统。这类项目在毕业设计和私活里都非常常见,但真正做完整、做规范的人不多,我把自己实操过程中的完整思路、表结构设计、核心代码、以及踩过的坑整理出来,希望对正在做类似项目的朋友有帮助。
这个项目不是简单的增删改查demo,它覆盖了商城系统最核心的闭环:用户注册登录、商品浏览与搜索、购物车、下单支付模拟、后台订单管理、商品库存管理、会员等级与积分。无论你是准备拿它作为毕业设计、面试项目,还是想快速搭建一个带商城的行业管理系统,这篇内容都能给你一套可以直接落地的方案。
1. 画清楚业务边界,再动手写代码
很多人拿到“美容院美妆商城”这种需求,上来就建表、写接口,做到一半发现逻辑混乱,前后端对接时又推倒重来。我自己的经验是,无论项目大小,花半天时间把业务边界和用户角色梳理清楚,比什么都值钱。
1.1 这个系统到底需要几种用户角色
美容院美妆商城和管理系统天然有三类使用者:C端消费者、B端运营管理员、以及门店操作员(美容师)。在技术实现上,这三类角色对应的是两套前端(用户端商城 + 后台管理端)和一套后端接口。
我第一次做的时候图省事,把后台管理和用户商城放在同一个Vue项目里,用路由做区分,结果权限控制写得很痛苦。后来重构改成两个独立的Vue应用,共享同一套后端API,结构清爽很多。你如果是在课程设计或面试场景里展示这个项目,建议采用这种“双前端”结构,这也是目前企业里主流的前后端分离方案。
用户端核心流程是注册登录、浏览商品、加入购物车、提交订单、模拟支付、查看个人订单、积分签到。管理端核心流程是管理员登录、商品分类管理、商品上下架、订单发货与状态更新、用户管理、会员等级配置、数据看板。这里面最容易忽略的是“门店/美容师”这个角色,实际上美业场景里经常需要前台为顾客直接下单,所以后台管理端里可以保留一个“代客下单”的入口,在代码里体现出来会显得业务思考很完整,面试时也是加分项。
1.2 为什么非要用SSM+Spring Boot的组合
可能有人会问,既然用了Spring Boot,为什么还要说SSM?实际上Spring Boot并没有取代SSM,它只是把原本Spring、SpringMVC、MyBatis繁琐的XML配置自动化了。你在Spring Boot项目里加入spring-boot-starter-web和mybatis-spring-boot-starter,本质还是SSM那套逻辑在跑,只是不用再手写一堆配置文件。
在真实的项目选型里,Spring Boot负责自动配置和项目骨架,Spring和SpringMVC处理依赖注入、事务控制和请求分发,MyBatis做持久层映射。这套组合最稳的版本搭配是Spring Boot 2.7.x + MyBatis 3.5.x + MyBatis Spring Boot Starter 2.x,高版本Spring Boot(3.x)因为javax到jakarta的切换问题,反而会给新手带来很多隐性的麻烦。我在项目实战中一直坚持一个原则:能用稳定的老版本,就不要追求最新版本,除非新版本能直接解决你当前的技术痛点。Spring Boot 2.7是2.x系列的最后一个大版本,坑最少,资料最好查。
Vue这边我用的是Vue 2 + Element UI。现在虽然Vue 3 + Element Plus已经普及了,但Vue 2的生态成熟度依然很高,市面上大量企业管理系统还在用Vue 2维护,对快速出活来说,Vue 2 + Element UI的学习成本和组件丰富度是最平衡的。
2. 数据库设计是商城系统的灵魂
商城系统的表结构设计有很强的通用性,我做了一个简单的ER规划:用户表、商品分类表、商品表、购物车表、订单表、订单明细表、积分记录表、会员等级表、轮播图表、管理员表,一共十张核心表。下面我挑几张关键的展开说,这些都是踩过坑之后总结出来的经验。
2.1 用户表不是简单的user表,要预留会员扩展字段
用户表除了常规的id、username、password、phone、avatar外,还要加member_level_id和points两个字段。美妆商城和普通商城不同的地方在于,它非常依赖会员体系和复购带动,所以从一开始就要把会员等级和积分设计进用户表,而不是后面再加。
密码字段我强烈建议用BCrypt加密存储,不要用MD5。MD5可以被彩虹表直接反查,安全隐患很大。Spring Security的BCryptPasswordEncoder可以直接用,即使不引入Spring Security全家桶,单独引入spring-security-crypto这个依赖包也行。在Service层里做加密校验,代码也不复杂。
建表的时候还有一个很容易忽略的点:金额字段不要用double,一定要用decimal(10,2),避免浮点数精度问题。经验之谈,订单金额计算如果用了double,后面统计报表出现几毛钱的乱差,排查起来极耗时。
2.2 订单表的状态字段设计
订单表是整个系统里最核心、逻辑最复杂的部分,它承载了状态的流转。我设计的状态字段是order_status,用tinyint类型,含义如下:
0表示待付款,1表示已付款待发货,2表示已发货,3表示已完成,4表示已取消。另外加一个pay_status字段区分支付状态(0未支付,1已支付,2已退款),很多初学者容易搞混业务状态和支付状态这两个概念,我用了两套状态并存的方式,逻辑清晰很多。
订单表还需要冗余一个total_amount和pay_amount。total_amount是商品原价总额,pay_amount是减去优惠、积分抵扣后的实际应付金额,这两个字段的值会在生成订单时一次性算好写入。这样做的好处是,在订单历史列表中不需要再去关联明细表算价格,性能更好,也不容易被后面的数据变动影响。设计关联关系时还要记得外键的合理设置,例如订单表关联用户表用user_id,订单明细表关联订单表和商品表,但不要加物理外键,只建普通索引,方便后续扩展和避免锁竞争。
2.3 商品表与库存的边界处理
商品表sku_id、product_name、category_id、main_image、detail_images、price、stock、sales、status。对于美妆类商品,detail_images可以存多个图片URL,用JSON数组格式或者逗号分隔字符串都可以,我习惯用JSON数组,前端遍历起来更顺手。
库存是商城系统里最考验逻辑的字段。并发高的情况下直接用update product set stock = stock - 1 where id = ? and stock > 0这种乐观锁式的SQL来做扣减,避免超卖。在这个项目量级下,这种写法够用且性能最佳,没必要上Redis分布式锁。我自己封装了一个reduceStock方法,在事务里先查库存,再更新库存,更新时带上stock > 0条件,影响行数为0就抛出库存不足异常。
商品上下架用status字段标识,1上架、0下架。管理端操作上下架只需要update这一个字段,前端如果做了Redis缓存,下架时还要注意同步清理缓存,不然会出现商品已经下架但前端还能看到的情况。
3. 后端核心实现:Spring Boot整合SSM的实操细节
后端我采用的是经典分层结构:controller、service、mapper、entity、common。这个结构大家都很熟悉,但真正写得规范的不多。我重点讲几个容易出问题的地方。
3.1 application.yml里需要注意的配置项
一个清爽的Spring Boot后端配置大概是这样的:
server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/beauty_mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.beauty.mall.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意几点:数据库连接串里的serverTimezone必须设置,否则会因为时区问题报错或者时间差8个小时。map-underscore-to-camel-case一定要开启,这样数据库的create_time字段能自动映射到Java的createTime属性,省去大量手写resultMap的功夫。log-impl配置为StdOutImpl是开发阶段的常备操作,可以直接在控制台看到SQL语句和参数,排查问题效率极高,但生产环境要去掉这个配置,否则日志量会很惊人。
另外,MyBatis的mapper.xml文件如果放在src/main/java目录下,打包时会被Maven过滤掉,导致启动时报Invalid bound statement (not found)。解决方式有两个:把xml放在src/main/resources/mapper目录下,或者在pom.xml的build标签里加上resources配置,把src/main/java下的xml文件一并打包。我推荐第一种,规范且省心。
3.2 MyBatis分页查询与条件搜索
后台管理端的商品列表、订单列表都逃不掉分页。手写LIMIT虽然简单,但每次都要计算pageNum和pageSize的偏移量,代码非常僵硬。我在项目里引入了PageHelper分页插件,只需要一行PageHelper.startPage(pageNum, pageSize),紧跟其后的查询会自动拼接分页。返回结果用PageInfo包装,能直接拿到总记录数、总页数、当前页等元数据。
条件搜索是这类系统的高频需求。商品列表需要支持按商品名称模糊查询、按分类筛选、按价格区间筛选、按上下架状态筛选。我习惯在Mapper里用动态SQL拼接,核心写法如下:
<select id="selectProductList" resultType="com.beauty.mall.entity.Product"> select * from product <where> <if test="productName != null and productName != ''"> and product_name like concat('%', #{productName}, '%') </if> <if test="categoryId != null"> and category_id = #{categoryId} </if> <if test="minPrice != null"> and price >= #{minPrice} </if> <if test="maxPrice != null"> and price <= #{maxPrice} </if> <if test="status != null"> and status = #{status} </if> </where> order by create_time desc </select>动态SQL的where标签会自动去除第一个条件前面的and,这一点很多新手不知道,写了半天发现SQL拼接多了个and报错。这个写法在SSM时代就一直是这样,Spring Boot整合后完全通用。
3.3 登录鉴权:JWT还是Session?
在我的实践中,前后端分离的项目已经没什么理由再用Session了。Session依赖Cookie,会面临跨域携带Cookie复杂、移动端不支持、分布式部署无法共享等一系列问题。我选择使用JWT做登录鉴权,方案非常成熟。
用户登录成功后,后端生成一个JWT令牌返回给前端,前端存储在localStorage里,每次请求在Authorization头中带上这个令牌。后端通过拦截器统一校验令牌,并解析出用户ID存入请求上下文。
JWT生成我用的是jjwt库,生成和解析代码都不复杂。关键点是设置过期时间,我设置为2小时。管理员令牌和用户令牌可以共用一套生成机制,只需在payload里放入一个role字段区分即可。
拦截器校验逻辑核心如下:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) { return true; } // 从请求头获取token String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); } // 校验token try { Claims claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody(); UserContext.setCurrentUser(claims.get("userId", Integer.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } }这里的UserContext是一个ThreadLocal封装,在请求线程内保存当前登录用户ID,业务逻辑里随时可以取出当前用户操作自己的数据,比如给购物车加商品时确定归属用户。注意请求结束后要在afterCompletion里调用UserContext.clear(),否则线程池复用时会发生数据串号的问题。
3.4 购物车与下单的事务控制
购物车表很简单:id、user_id、product_id、product_count、checked、create_time、update_time。加购物车接口的逻辑是无则新增、有则数量加一。修改数量时校验库存是否充足。
下单是整个系统中最考验事务的环节。我的下单流程是:获取购物车中选中的商品列表,遍历校验库存,计算总金额,插入订单主表,批量插入订单明细表,扣减库存,清空购物车中已选商品。这四步必须保证原子性,任何一个环节失败都要回滚。
Spring Boot里加@Transactional注解即可实现声明式事务,默认遇到RuntimeException就会回滚。有个坑要注意:如果在Service方法里自己try catch吞掉了异常,事务不会回滚,这是新手最容易踩的。正确做法是不在事务方法内catch异常,或者在catch里手动抛出一个RuntimeException,让事务管理器感知到。
下面是我实测有效的下单核心逻辑:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 查询选中的购物车记录 List<CartItem> cartItems = cartMapper.selectCheckedItems(dto.getUserId()); if (cartItems.isEmpty()) { throw new BusinessException("请先勾选要结算的商品"); } // 2. 生成订单号 String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); // 3. 遍历明细,计算金额,校验库存 BigDecimal totalAmount = BigDecimal.ZERO; for (CartItem item : cartItems) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() == 0) { throw new BusinessException("商品不存在或已下架"); } if (product.getStock() < item.getProductCount()) { throw new BusinessException("商品[" + product.getProductName() + "]库存不足"); } // 累计金额 BigDecimal itemAmount = product.getPrice().multiply(new BigDecimal(item.getProductCount())); totalAmount = totalAmount.add(itemAmount); // 插入订单明细 OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getProductName()); orderItem.setProductImage(product.getMainImage()); orderItem.setPrice(product.getPrice()); orderItem.setCount(item.getProductCount()); orderItem.setTotalAmount(itemAmount); orderItemMapper.insert(orderItem); // 扣减库存 int rows = productMapper.reduceStock(product.getId(), item.getProductCount()); if (rows == 0) { throw new BusinessException("商品[" + product.getProductName() + "]库存扣减失败"); } } order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setOrderStatus(0); order.setPayStatus(0); orderMapper.insert(order); // 4. 删除已购物车项 cartMapper.deleteCheckedItems(dto.getUserId()); return convertToVO(order); }3.5 美容院场景的特有逻辑:会员价与积分抵扣
美容院和普通电商最大的不同在于,它是典型的会员制消费场景。我在设计时加入了会员等级表和积分抵扣功能。
会员等级表设计了grade_name、discount(折扣率)、min_points(升级所需积分)三列。比如普通会员折扣1.0,白银会员折扣0.95,黄金会员折扣0.88。下单时根据用户当前会员等级,按照商品原价乘以折扣率计算价格。
积分抵扣是我后补的需求。因为美容院客单价高,积分抵扣是刺激复购的关键。下单时用户可以输入要抵扣的积分数量,我设计的规则是100积分抵扣1元,且抵扣金额不能超过订单金额的20%。代码里需要注意的是积分抵扣金额的计算精度,要使用BigDecimal,不能直接用浮点数取整,不然会多扣或少扣用户的积分。订单完成后,系统再根据实付金额回赠积分,形成完整的积分闭环。
4. Vue前端搭建与前后端联调实战
管理端和用户端我都用的Vue 2 + Element UI,下面说说联调过程中的核心细节。
4.1 项目创建与目录结构
创建项目时我推荐直接使用Vue CLI,Spring Boot前端开发中Vue2的常用版本是@vue/cli 4.x或5.x。命令很简单:vue create beauty-admin,然后选择手动配置,勾选Router和Vuex。等待依赖安装完成后,再把Element UI装进来:npm i element-ui -S。在main.js里全局注册Element UI后,就可以直接使用el-table、el-form这些组件了。
管理端的目录结构我会按模块分目录,比如views下面分product、order、user、dashboard、login几个子目录,每个目录下的vue文件负责一个页面的全部逻辑。这样做的好处是后期维护或者写多页面系统时可以快速定位。
4.2 axios请求封装,token统一处理
前后端联调的第一个问题就是跨域和请求头的处理。我在utils/request.js里封装了一个axios实例,统一设置baseURL、超时时间和请求拦截器。请求拦截器里从localStorage取出token并加到Authorization头中,响应拦截器里处理401跳转登录页和403无权限提示。
核心代码:
import axios from 'axios' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 请求拦截器:自动携带token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => { return Promise.reject(error) }) // 响应拦截器:统一处理错误状态 service.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { if (res.code === 401) { localStorage.removeItem('token') location.href = '/login' } return Promise.reject(new Error(res.message || '请求失败')) } return res }, error => { return Promise.reject(error) }) export default service这样一个封装做完后,页面里的请求代码会非常简洁,比如商品列表请求可能只需要几行调用。一次性把统一的错误提示和登录过期跳转做好,是联调阶段省时间的关键。
4.3 开发环境的跨域代理配置
跨域问题有两种解决方式:后端开启CORS,或者前端通过vue.config.js配置代理。我的经验是开发环境用前端代理最方便,生产环境用Nginx反代统一入口,后端甚至不需要开启CORS,可以减少一层暴露面。
vue.config.js里这样配置:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/product/list,会被代理转发到后端/product/list,绕过了浏览器的跨域限制。我在开发时请求地址直接写/api/xxx,切换环境只需要改环境变量,非常方便。如果你后端坚持开CORS,注意是在Spring Boot的配置类里加CorsFilter或者使用@CrossOrigin注解,但生产环境建议关掉。
4.4 商品列表展示与购物车状态管理
用户商城的商品列表页面,我分成搜索栏、分类筛选栏和商品卡片网格三部分。搜索时支持关键字模糊匹配,分类筛选通过下拉框选择分类ID。商品卡片展示图片、名称、价格和库存状态,点击进入详情页。
购物车页面在Vuex中维护一个购物车列表的state,并引入一个totalPrice的getter用于实时计算总价。添加商品、修改数量、删除商品、勾选商品这四个操作都同步调用后端接口,成功后重新拉取购物车列表,保证数据不是本地假逻辑。这里有一个实际踩过的坑:修改商品数量时,如果连续点击加减按钮,可能会因为接口响应顺序问题传回来旧数据,导致数量显示错乱。我的解决方案是在每次操作前禁用按钮,等接口返回后再恢复,或者在请求里带上一个递增的时间戳参数,取最新一次请求的响应。
后台管理端的商品管理页面更偏重表单和表格:新增商品弹窗里需要实现图片上传、富文本详情编辑、价格和库存录入,列表页用el-table展示数据,操作列放“上架/下架”、“编辑”、“删除”按钮。图片上传我用的Element UI的el-upload组件,action地址指向后端的/upload接口,上传成功后把返回的URL填充到表单隐藏域里。
4.5 路由权限控制
后台管理端必须做路由权限控制,否则任何人都可以通过URL直接访问管理页面。我的实现方式是:在路由配置里给每个管理页面meta增加roles: ['admin']标记,然后在vue-router的全局前置守卫里判断当前用户角色。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path === '/login') { next() } else { if (!token) { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(role)) { next('/404') } else { next() } } })这个实现算是轻量级方案,不需要动态注册路由,适合固定菜单的管理系统。更复杂的场景,比如不同管理员看到不同菜单的按钮级权限,就需要后端返回权限标识数组,前端动态渲染菜单和按钮,当前项目量级暂时用不到。
5. 部署上线与常见性能瓶颈
项目开发完成后,部署上线也是一门学问。尤其是前后端分离的两个项目,要合理利用Nginx做静态资源服务与反向代理。
5.1 前端打包与Nginx配置
前端部署的第一步是构建:npm run build,生成dist目录。将dist目录拷贝到服务器上,比如/app/beauty-web目录下。然后配置Nginx指向这个目录,并将/api开头的请求反向代理到后端Java服务。
我的Nginx配置核心片段:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /app/beauty-web; index index.html; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传的图片访问 location /upload/ { alias /app/beauty-upload/; } }try_files $uri $uri/ /index.html这一行最容易被忽略。如果不配置,用户在前端路由刷新页面时,Nginx会尝试寻找对应的真实文件路径,找不到就返回404,导致刷新后页面空白。加上try_files后,所有未命中的路由都会回退到index.html,由Vue Router接管继续渲染正确页面。
5.2 后端打包与内存优化
后端打包用Maven命令:mvn clean package -DskipTests,生成jar包后上传服务器,用nohup java -jar beauty-mall.jar > log.txt 2>&1 &启动即可。生产环境我建议在启动参数里加上-Xms512m -Xmx1024m限制JVM堆内存,避免占用过多服务器资源。如果做前后端分离部署,Nginx与后端服务的配合就是整条链路的咽喉,先把这一层搞通,后续水平扩展也只是加一台服务器的事。
如果并发量确实上来了,优先加Redis做缓存。商品列表、分类信息、首页轮播图都是热点数据,可以缓存在Redis里,有效降低数据库压力。我在这个项目中给首页数据加了简单缓存,接口响应时间从80ms降到了10ms以内,提升明显。
5.3 数据库连接池配置
Spring Boot 2.x默认使用HikariCP连接池,这也是目前性能最好的Java连接池之一。默认配置在大多数场景下够用,但高并发时需要注意max-pool-size的调整。我的建议值是最小空闲连接数10,最大连接数50,连接超时时间30000ms。这个配置可以在application.yml中覆盖:
spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 50 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000配置连接池的核心思路是:连接数不是越大越好,每个连接都会占用数据库资源,过大的连接池反而会拖垮数据库。50个连接在这个项目量级已非常充裕。
6. 常见问题与排查技巧实录
前后端整合的项目一旦出现问题,排查链路比较长。我把自己开发过程中遇到的几个高频问题整理出来,方便你少走弯路。
6.1 跨域请求CORS错误
前端请求后端时报“Access-Control-Allow-Origin”错误,或者浏览器控制台出现“has been blocked by CORS policy”。这个问题大概率是因为前端没有走代理,直接访问了后端地址。我在前面的Nginx配置里其实已经规避了这个问题。如果开发环境使用的还是直接调用后端地址的办法,那就务必在后端配置CORS。
我推荐的Spring Boot CORS配置是写一个WebMvcConfigurer配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这个配置类加上后,前端的GET、POST、PUT、DELETE等请求都能通过预检。但要注意,allowCredentials和allowedOriginPatterns不能同时用*,否则在部分Spring Boot版本里会报错。实操中我有一个更省事的原则:能用Nginx代理就用代理,别在主项目里开CORS,省得生产环境暴露接口风险。
6.2 MyBatis映射文件被漏打包
启动项目后调用任何Mapper方法都报Invalid bound statement (not found),但是代码看起来又没问题。这个问题十有八九是mapper.xml文件没有打包进classes目录。检查target/classes/mapper目录下有没有对应的xml文件,如果没有,说明打包时被忽略了。
解决方式有两个:把xml文件放在src/main/resources/mapper目录下,这是最推荐的。或者如果你的项目结构要求xml和Mapper接口放同一个包,那就在pom.xml里加上如下配置,把xml文件也纳入打包范围:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>6.3 JWT过期后前端还在停留
用户登录后,过了一个小时再操作,后端返回401,但前端没有跳转登录页。原因是axios响应拦截器里处理401的代码没生效,或者前后端对token过期的判断不一致。
我的排查步骤是先看响应拦截器里是否捕获了HTTP 401状态码,然后看后端返回的响应体结构。如果你后端拦截器统一返回{code:401}但HTTP状态码是200,那么前端就要用res.code来判断,而不是error.response.status。保持前后端约定一致是联调顺畅的前提。我的习惯是:遇到未登录或token过期,后端统一返回HTTP 401,前端一律用error.response.status === 401判断,简单不二义。
6.4 图片上传能访问但404
开发环境图片上传到本机磁盘,通过后端接口可以访问,打包上线后就404了。大概率是因为Spring Boot默认无法直接访问本地磁盘的静态资源目录。我在Nginx里用alias配置了/upload/路径,把上传目录映射为静态访问。
Nginx中alias和root是有区别的:root会把location的路径拼接到root路径后面,alias则直接替换路径。我配置的location /upload/ { alias /app/beauty-upload/; },请求/upload/123.jpg会映射到/app/beauty-upload/123.jpg。如果你用root配置,请求路径会变成/app/beauty-upload/upload/123.jpg,这应该是个经典的配置陷阱了。
6.5 Vue打包后白屏或路径错误
前端打包后直接双击index.html打开是白屏,检查控制台发现js、css的路径是绝对路径/。这是因为Vue CLI默认publicPath是/,打包供服务器根路径访问没问题,但如果你放在子目录或直接本地打开,路径就错了。
解决方式是在vue.config.js里把publicPath改为相对路径:
module.exports = { publicPath: './' }还有另一个情况,打包部署后页面正常,但刷新后404,这个前面Nginx配置里已经提到了,就是try_files没配。这两个问题在部署环节非常高频。
6.6 冲突解决:Maven依赖版本引起的启动异常
有时候引入PageHelper或其他依赖后,项目启动直接报错:Error creating bean with name 'sqlSessionFactory'。原因一般是MyBatis相关依赖版本不兼容。这个项目我最终确定的依赖版本组合是:MyBatis Spring Boot Starter 2.2.2,PageHelper Spring Boot Starter 1.4.2,jjwt 0.9.1。这三个版本搭配Spring Boot 2.7.x跑得很稳,没有冲突。
再次强调,新手一定不要一上来就用最新版依赖,高版本Spring Boot 3.x系列中jakarta的命名空间变化,会让大量老教程里的代码直接编译失败。做项目求稳是第一要务。
写在最后的小心得
做这类系统,我发现最花时间的反而不是业务代码,而是各种环境问题和联调问题。很多新手习惯踩到一个问题就百度一个,解决完继续往下写,没有形成自己的排查套路。我自己常用的排查顺序是:看浏览器控制台Network请求状态码,看后端控制台打印的SQL和异常栈,然后根据错误信息归类找原因。这套思维模式在你做完一个完整项目后会根深蒂固,比任何知识点都值钱。
如果你也在做这个Spring Boot + SSM + Vue的商城系统,建议先根据自己学校或公司的要求把表结构和接口定义好,再来改代码。磨刀不误砍柴工,这个项目本身是一个综合能力放大器,同时把后端、前端、数据库、部署整个链路走通一遍,你对Java开发的认识会上一个台阶。
最后再分享一个小技巧:开发阶段在后端配置了mybatis的日志输出(log-impl: org.apache.ibatis.logging.stdout.StdOutImpl)后,前端一旦出问题,先看后端SQL有没有执行,再看SQL执行结果是否符合预期,就能快速定位是后端逻辑问题还是前端传参问题。这一条经验,能帮你省下大量的联调时间。