开门见山我做这个基于 Java Vue 的水果蔬菜商城项目前后花了大概三周业余时间。项目包含了完整的前后端源码、数据库初始化脚本和配套文档是一套可以直接运行、也可以作为毕业设计或课程设计参考的全栈电商系统。市面上类似的项目不少但很多要么后端堆砌老旧 JSP要么前端拿 Bootstrap 拼页面真正基于 Vue 3 组件化开发、配合 Spring Boot 标准工程结构的并不多见。今天我把整套东西拆开讲一遍从数据库设计到前后端联调把关键代码逻辑和踩坑记录都放出来希望能给正在做类似选题的同学省点时间。1. 项目定位与整体设计思路1.1 技术选型与架构设计的底层逻辑选型其实就是权衡的结果。水果蔬菜商城属于典型的“中小型电商系统”业务链条包括商品展示、用户注册登录、购物车管理、订单生成与支付状态模拟、后台商品管理复杂度比单纯的 CRUD 高但又没有到需要微服务拆分的程度。这种情况下前后端分离的架构是当前工业界最主流、也最适合教学演示的方案。后端我用的是Spring Boot 2.7.x理由很简单Spring Boot 的自动配置让开发者从繁琐的 XML 配置里解放出来内嵌 Tomcat 让部署变得异常轻量。搭配MyBatis Plus是因为它对单表 CRUD 做了很强度的封装BaseMapper 里默认提供了 insert、updateById、selectPage 等通用方法写业务代码时关注点可以放在业务规则上而不是反复写重复 SQL。前端这边选择Vue 3 Element PlusVue 3 的组合式 API 让逻辑复用变得很自然Element Plus 在表单和表格上的成熟度对后台管理页面极友好。电商前台则用 Vue Router 做路由控制Pinia 做全局状态管理。为什么要坚持前后端分离而不是用 Thymeleaf 服务端渲染核心原因在于开发效率和体验解耦。分离以后前端可以并行开发调用后端接口时只需约定好 JSON 格式后端也不关心页面长什么样只需要保证接口的稳定性和正确性。而且前后端分离后如果以后想加一个小程序端或者移动端后端接口可以直接复用不需要额外改动。1.2 功能模块拆解与业务闭环整个商城系统按照用户角色和业务流程拆成了前台和后台两部分。前台面向普通消费者包含注册登录、轮播图浏览、商品分类筛选、商品搜索、商品详情、加入购物车、购物车批量结算、订单提交与状态查看。后台面向管理员包含商品分类管理、商品上下架、库存调整、订单发货处理、会员列表查看。两条业务线通过统一的权限认证体系隔离普通用户访问后台接口会被拦截这是通过后端的拦截器配合 JWT 实现的。这里有一个很关键的闭环设计购物车内的商品在结算时会经过库存二次校验。很多练手项目在加入购物车时校验一次库存结算时就不再检查导致超卖现象。我这个项目里结算接口submitOrder会对购物车中的每一项商品重新查询数据库中的库存如果任一商品库存不足整单返回失败提示用户先调整购物车。这样做虽然牺牲了一点并发性能但对一个教学演示项目而言保证数据一致性远比追求吞吐量更重要。1.3 目录结构与代码组织拿到源码后第一件事是先看目录结构。整个项目是标准的 Maven 多模块还是单模块我选择的是单模块结构如下fruit-shop ├── pom.xml ├── sql │ └── fruit_shop.sql ├── src │ ├── main │ │ ├── java │ │ │ └── com/fruitshop │ │ │ ├── FruitshopApplication.java │ │ │ ├── common │ │ │ │ ├── Result.java │ │ │ │ └── JwtUtil.java │ │ │ ├── config │ │ │ │ ├── WebMvcConfig.java │ │ │ │ └── MybatisPlusConfig.java │ │ │ ├── controller │ │ │ │ ├── UserController.java │ │ │ │ ├── ProductController.java │ │ │ │ ├── CategoryController.java │ │ │ │ ├── CartController.java │ │ │ │ └── OrderController.java │ │ │ ├── entity │ │ │ │ ├── User.java │ │ │ │ ├── Product.java │ │ │ │ ├── Category.java │ │ │ │ ├── CartItem.java │ │ │ │ └── Order.java │ │ │ ├── mapper │ │ │ ├── service │ │ │ │ ├── impl │ │ │ └── interceptor │ │ │ └── JwtInterceptor.java │ │ └── resources │ │ ├── application.yml │ │ └── mapper └── frontend ├── package.json ├── vite.config.js └── src ├── api ├── router ├── stores ├── views └── components这种组织方式的好处是职责边界清晰。common目录存放统一返回结果和工具类config放配置类controller只负责接收请求和返回结果不写业务逻辑service放业务实现mapper是 MyBatis 的接口层。前端api目录统一封装请求函数views按页面维度组织组件。照着这个结构去读代码基本不会有“找不到某个功能代码在哪”的问题。2. 数据库设计电商业务的地基2.1 核心表结构设计数据库是一个电商系统的地基我刚开始做的时候也走过弯路表结构设计得太简单后来业务开发阶段频繁加字段改代码。这次直接按照电商业务的最小完备集设计一共五张核心表用户表、分类表、商品表、购物车表、订单表。用户表重点关注的是密码的存储方式绝对不能明文存储。我用的是 Spring Security 的 BCryptPasswordEncoder也可以单独引入 jbcrypt 依赖对用户密码做加盐哈希后再入库。BCrypt 会自动生成随机盐并拼接在哈希结果中因此同一个密码两次加密的结果不同但matches方法能正确校验安全性和易用性都能兼顾。用户表设计中另外一个实用技巧是增加status字段用于账号禁用功能后台管理员可以对恶意用户实施封禁。分类表和商品表是父子关系。商品表通过category_id外键关联分类表并且冗余存储了category_name字段目的是减少联表查询。因为商品列表页和详情页高频使用分类名称每次 join 分类表虽然性能影响不大但会多写不少 SQL。适度冗余高频字段在中小型项目里性价比很高。商品表还有一个容易忽略的字段是sales_count用于前台“销量排序”以及后台的销售统计初版就加上比后续数据量大了再加迁移字段省事得多。购物车表的设计稍微有些讲究。我采用user_id product_id联合唯一索引这是为了防止同一用户重复添加同一件商品。前端添加购物车时如果检测到该商品已存在后端就不会新增记录而是直接在原记录上增加数量。这里实际测试时发现一个细节MyBatis Plus 的selectOne在查询到多条记录时会直接抛异常因此执行加了锁的“先查后插”逻辑前联合唯一索引是最可靠的安全网。2.2 订单状态与流程数据建模订单相关的两张表是订单主表和订单明细表构成典型的一对多关系。订单主表存储收货人信息、订单总金额、订单状态、下单时间等概要数据订单明细表存储每个商品的快照信息包括当时的下单价、商品名称、商品图片、购买数量、小计金额。为什么订单明细里非要冗余一份商品名称和图片而不是直接外键关联商品表原因在于商品的价格和名称会变动。如果用户下单后管理员修改了商品价格用户查看历史订单时应该看到的是下单那一刻的价格而不是当前价格。这就是数据建模里的“快照”概念跟超市购物小票打出来的商品名和单价在结账后不再随货架价格变动是一个道理。订单状态我用了status整数字段表示0 待付款、1 待发货、2 已发货、3 已完成、4 已取消。状态流转的控制放在 Service 层而不是 Controller 层并且所有状态的变更都必须是单向可控的比如从待发货直接改成已完成这种跳过发货的操作在代码里会直接抛出业务异常。这样做虽然多写几个 if 判断但能避免接口被人直接调用时绕过业务规则。2.3 初始化数据与测试数据准备的实用技巧sql目录下的fruit_shop.sql不仅有建表语句还包含了完整的初始化数据。我强烈建议用于练手的电商项目初始化数据千万不能只塞三条记录至少每个分类要有 6 到 8 个商品商品图片链接也要用真实可访问的图片地址。否则前端分页列表、轮播图、商品详情全是空的调试前端样式时非常痛苦。另一个值得说的技巧是为管理员账号单独建了一个admin用户并设置role字段为1普通用户为0。这样后端拦截器在鉴权时可以同时完成登录校验和权限校验一次解析 JWT就能区分用户身份。初始化数据的密码我统一设置为123456的 BCrypt 哈希值文档里也做了特别说明这是为了方便演示生产项目绝对不能用这么弱的密码。测试数据的填充可以用一个简单的循环脚本我是在测试阶段写了一个临时接口批量生成商品模拟数据。这种临时接口记得在项目交付前删除或加上权限校验否则接口暴露出去了别人可以直接往你数据库灌数据。3. 后端核心实现Spring Boot 业务代码拆解3.1 统一返回结果与全局异常处理前后端分离项目里接口返回格式的统一性比很多人想象中更重要。如果每个接口返回的 JSON 结构都不一样前端处理起来就是一场灾难。我设计了一个通用的ResultT类结构如下public class ResultT implements Serializable { 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(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }code 为 200 表示成功非 200 表示失败。前端 axios 拦截器拿到响应后统一判断 code如果非 200直接弹出 message 给用户。这套机制配合全局异常处理器RestControllerAdvice可以做到业务代码里只抛异常、不写 try-catch统一异常处理保证用户永远收到的是友好提示而不是一个 500 的堆栈信息页面。全局异常接收器里我还专门处理了参数校验异常MethodArgumentNotValidException如果前端传来的参数不满足NotBlank等校验注解后端会返回校验失败的第一条错误信息。因为多字段校验的默认行为是全部校验完再统一返回对前端来说非常不友好所以我在处理器里做了定制只提取第一个错误信息返回避免前端弹窗显示一堆错误。3.2 JWT 鉴权与登录状态管理用户登录成功后后端会生成一个 JWT 令牌返回给前端。JWT 之所以适合这种场景是因为它天然无状态——服务端不需要存储会话只要客户端把令牌带回来服务端通过签名校验就能确定用户身份。public class JwtUtil { private static final String SECRET_KEY fruit-shop-secret-key; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 1天 public static String generateToken(Integer userId, String username, Integer role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody(); } }JWT 的密钥不能明文写在代码里实际项目中要放到配置文件中用环境变量注入。我这样写是为了源码演示方便但文档里专门提醒了一句——生产环境一定要把 SECRET_KEY 换掉否则别人可以用同样的密钥伪造任意用户的令牌。后端有一个JwtInterceptor拦截器并注册到 WebMvcConfig 中对/api/admin/**进行拦截校验 token 是否有效以及角色是否为管理员。拦截器通过判断请求头Authorization字段是否以Bearer开头来识别携带了 token 的请求有效则放行并把 userId 放入 request 属性中Controller 里通过RequestAttribute获取。这里踩过一个坑JWT 的过期时间不能设太长否则用户永久有效也不能太短否则前端频繁跳登录页。根据大多数商城的使用习惯24 小时是一个比较合理的折中方案。3.3 购物车到订单的完整事务处理从购物车提交订单是整站业务最复杂的一个链路涉及购物车读取、库存二次校验、商品信息快照、订单主表插入、订单明细批量插入、购物车清空、库存扣减七个操作。只要其中任意一个环节失败数据就会不一致。这里我直接用了 Spring Boot 的Transactional注解并指定了rollbackFor Exception.class。Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, ListInteger cartItemIds, AddressVO address) { // 1. 查询购物车中选中的条目 ListCartItem cartItems cartMapper.selectBatchIds(cartItemIds); // 2. 遍历校验库存与计算总价 for (CartItem item : cartItems) { Product product productMapper.selectById(item.getProductId()); if (product.getStock() item.getQuantity()) { throw new BusinessException(商品[ product.getName() ]库存不足); } } // 3. 生成订单主表记录 Order order buildOrder(cartItems, address); orderMapper.insert(order); // 4. 生成订单明细 ListOrderItem orderItems buildOrderItems(order.getId(), cartItems); orderItemMapper.insertBatch(orderItems); // 5. 扣减库存 // 6. 删除购物车记录 }事务中有一个实用的优化点库存扣减不要用“先查再改”的模式而是使用带条件的更新 SQL。UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}如果更新影响行数为 0说明库存不足直接抛异常回滚事务。SQL 层面加条件的方式天然具备原子性相比“先 select 再 update”能有效避免并发场景下的超卖问题。这一步做完后即使两个用户同时下单数据库行锁也会保证只有一个请求能成功扣减库存另一个影响行数为 0 从而抛出业务异常。3.4 图片上传与静态资源映射商品图片的管理很直接影响开发体验。前端上传图片的接口我的实现方案是后端接收 MultipartFile保存到服务器本地磁盘的指定目录如/upload/并把文件的访问 URL 返回给前端前端把这个 URL 直接存到商品表里。这样做的优点是实现简单不需要额外引入第三方存储服务缺点是应用重启后如果临时目录被清理图片就丢了。所以我在配置里指定了一个绝对路径比如D:/fruit-shop-upload/并把这个路径配置到 application.yml 里。file: upload-path: D:/fruit-shop-upload/同时为了前端能通过 URL 访问到这些图片需要在 WebMvcConfig 中注册静态资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: D:/fruit-shop-upload/); }这里有几个细节。第一file:前缀不能少否则会被当成 classpath 下的资源去解析。第二生产环境部署时不要用 Windows 绝对路径最好用相对路径或者对象存储OSS我代码里写 Windows 路径只是因为本地开发方便文档中已做说明。3.5 搜索与分页接口的 SQL 编写要点商品列表页的搜索和分页是前端高频调用的接口。这个项目的搜索支持两种方式输入关键词模糊匹配商品名称以及点击分类标签筛选。分页是依赖 MyBatis Plus 的分页插件 PaginationInnerInterceptor 实现在实际使用中需要确保在配置类中注册好分页插件否则调用 selectPage 的时候不会生效全表数据都会被加载出来。public IPageProduct getProductPage(int pageNum, int pageSize, Integer categoryId, String keyword) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); if (categoryId ! null) { wrapper.eq(Product::getCategoryId, categoryId); } if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword); } wrapper.orderByDesc(Product::getSalesCount); return productMapper.selectPage(new Page(pageNum, pageSize), wrapper); }模糊搜索的性能问题在小数据量下完全不用处理但如果你后续想把这个项目扩展到几千条商品数据LIKE %keyword%这种写法会导致索引失效全表扫描。进阶方案是引入 Elasticsearch 或者用 MySQL 全文索引但对这个项目来说目前这个方案的响应时间和代码可读性是最好的性价比最高。4. 前端 Vue 实现从页面到交互的组件化实践4.1 Vue Router 路由设计与管理角色分离前端路由设计直接反映了用户的使用路径。前台部分我设计了首页、商品列表页、商品详情页、购物车页、订单确认页、用户中心等路由后台部分则是登录页、仪表盘、商品管理、分类管理、订单管理、用户管理等。在路由配置中后台相关的路由通过添加meta: { requiresAdmin: true }标记在前置路由守卫中做权限判断。router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAdmin) { const role localStorage.getItem(role); if (!token || role ! 1) { next(/login); return; } } if (to.meta.requiresAuth !token) { next(/login); return; } next(); });很多人问为什么前端要做路由守卫后端不是已经拦截了吗前端的守卫不是为了安全而是为了用户体验。后端拦截只能保证接口安全但如果前端没有做路由控制用户手动修改 URL 就能看到后台布局的空白框架体验很差。安全靠后端体验靠前端两者互补。4.2 Axios 请求封装与拦截器前端请求封装是一个很容易被忽视但后期收益巨大的环节。所有请求函数放在src/api目录按模块拆分文件例如product.js、cart.js、order.js。每个文件导出函数时直接返回 axios 实例的 Promise组件中通过await调用。统一封装的核心逻辑在 axios 拦截器里service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); ElMessage.error(登录已过期请重新登录); } return Promise.reject(error); } );这里有一个设计上的小决策为什么后端返回Result对象而拦截器把res.data直接解包返回因为组件里只关心业务数据本身如果每次都多写一层.data.data代码会很冗长。把包装层的解包操作集中在拦截器里组件的代码就简洁很多。如果遇到分页对象解包后仍然是一个包含 records、total 的对象。4.3 商品列表页与购物车状态联动商品列表页是前台的流量入口我用的布局是左侧分类侧边栏加右侧商品卡片网格。点击分类时前端通过route.query改变 URL 参数并触发商品列表重新请求。这么做的好处是用户刷新页面后分类状态不会丢失URL 可以直接分享给别人别人打开看到的是同一个分类下的商品列表。商品卡片上的“加入购物车”按钮是高频交互点。点击后前端要先判断登录状态。如果没登录直接引导到登录页已登录就把商品 ID 和数量传给后端。这里我特地把购物车数量设计为后端数据库存储而不是前端 localStorage因为只有后端存储才能实现在用户换设备登录后购物车数据仍然完整保留这才是电商系统的常规做法。加入购物车成功后的交互反馈我用的是 ElMessage 加右上角导航栏购物车徽章数字更新。徽章数字通过 Pinia 中的 cartStore 管理初始值在应用加载时从后端getCartCount接口获取。当加入购物车成功后调用 store 的refreshCartCount方法所有引用该 store 状态的组件会同步刷新无需手动操作 DOM。4.4 购物车页面批量选择与结算逻辑购物车页面涉及一个比较复杂的交互多选框的批量选中、单选、全选和总价计算。这一块很适合用 Vue 的计算属性来实现。全选状态是“选中的购物车条目是否等于所有条目”的计算结果选中商品的合计是“过滤出选中状态的条目对每条 quantity 乘 price 求和”。计算属性天然具备响应性只要依赖的状态变了页面自动更新。结算按钮点击后前端要做的第一件事不是跳转而是校验是否选中了商品。一个很常见的问题是多选状态被记录在组件内部的 ref 数组中而改为选中/取消时没有同步数量。我最终的方案是数组存储购物车记录的 ID勾选时加入 ID取消时过滤掉 ID。这样提交时直接把这个 ID 数组传给后端后端批量查询购物车记录并计算订单金额前端不参与任何金额计算只负责展示后端返回的订单总金额。所有涉及钱的逻辑都必须后端为准这是电商开发的基本准则。4.5 后台管理页面复用与权限控制后台管理部分我用 Element Plus 的el-table和el-dialog组合搭建。商品管理的核心表单名称、价格、库存、分类、图片、描述放在一个弹窗表单组件里新增和编辑共用同一个组件。编辑时通过row数据回填表单新增时清空表单。这种复用方式能减少大量重复代码。后台的权限控制除了前端路由守卫以外还有一个容易被忽略的地方接口级别的控制。假如普通用户登录后直接调用后台的商品删除接口后端 JwtInterceptor 会通过解析 token 中的role字段来确定是否为管理员如果角色不是管理员直接返回 403。所以光有前端隐藏按钮是不够的后端的权限拦截才是真正的安全防线。这是很多课程设计项目会漏掉的点用了我的源码的同学可以重点关注一下这一块的实现。5. 环境配置与项目启动全流程详解5.1 本地开发环境准备实际带跑这个项目时最常见的坑大多出在环境准备阶段。JDK 版本不对、Maven 依赖下载慢、Node 版本与 Vite 不兼容都会卡住新手。我建议直接按照文档里的环境版本说明来不要追求最新版本。JDK 1.8 或 JDK 11Spring Boot 2.7 最高兼容到 JDK 17但 JDK 8 最稳Maven 3.6MySQL 5.7 或 MySQL 8.0注意 8.0 的驱动依赖需要配置com.mysql.cj.jdbc.DriverNode.js 16.0Vue 3 和 Vite 5 需要较新版本开发工具推荐 IntelliJ IDEA 和 VS CodeMySQL 8.0 和 5.7 主要在驱动类名和时区配置上有差异。我的application.yml中数据库连接串是这样写的spring: datasource: url: jdbc:mysql://localhost:3306/fruit_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver如果你的 MySQL 是 5.7需要把 driver-class-name 改成com.mysql.jdbc.Driver。MySQL 8.0 的认证插件默认是 caching_sha2_password有些老版本的 JDBC 驱动连接时可能报认证错误升级依赖到 8.0.33 基本能解决。5.2 数据库初始化与后端启动第一步用 Navicat 或者命令行执行sql/fruit_shop.sql脚本创建数据库并导入数据。注意脚本开头有CREATE DATABASE IF NOT EXISTS fruit_shop如果提示权限不足就手动创建一个同名数据库然后执行剩下的建表和插入语句。第二步打开 IDEA用 Maven 方式导入pom.xml等待所有依赖下载完成。如果 Maven 下载速度很慢建议把仓库镜像换成阿里云镜像在settings.xml里配置好可以节省大量时间。第三步修改application.yml中的数据库账号密码然后直接运行FruitshopApplication.java的 main 方法。启动成功的标志是控制台出现 Spring Boot 的启动日志没有报错信息默认端口 8080。后端的接口文档可以用浏览器直接访问http://localhost:8080/swagger-ui.html前提是 pom 中引入了 springfox 或 springdoc 依赖这个项目里也顺便集成好了调试接口非常方便。有一个操作细节容易踩坑如果启动时提示Consider defining a bean of type com.xxx.mapper.XXXMapper in your configuration通常是因为启动类上缺少MapperScan注解或者 mapper 接口没有添加Mapper注解。我的项目里是在启动类上统一加了MapperScan(com.fruitshop.mapper)这样所有 mapper 接口无需逐个添加注解。5.3 前端安装依赖与运行前端目录是frontend打开终端执行命令npm install如果网络不好或者某些依赖安装失败可以换成镜像源npm config set registry https://registry.npmmirror.com安装完成后执行npm run dev启动前端开发服务器Vite 默认监听 5173 端口。浏览器访问http://localhost:5173就能看到商城首页。这里要注意 Vite 的代理配置因为前端地址是 5173后端接口在 8080存在跨域问题。我在vite.config.js里配置了代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/product/list时Vite 会把请求转发到http://localhost:8080/api/product/list浏览器上完全感知不到跨域前端代码里也不需要写完整的后端地址。生产环境部署时再用 Nginx 做反向代理统一处理。6. 常见问题排查与避坑指南6.1 数据库连接失败的定位路径数据库连接失败是所有环境问题里最容易出现的错误信息五花八门。我的排查路径一般按照“驱动类是否存在 - 连接URL是否写对 - 账号密码是否有权限 - 数据库服务是否启动 - 时区配置是否缺失”的顺序走。首先是报ClassNotFoundException说明 pom 坐标缺失或者依赖没有下载完整清理 Maven 本地仓库重新 import。然后是Access denied for user这是账号密码错误检查一下配置文件里的 username 和 password 是否与本地 MySQL 一致。如果是Public Key Retrieval is not allowed需要在 URL 后面补上allowPublicKeyRetrievaltrue。最后是The server time zone value的报错统一在连接串添加serverTimezoneAsia/Shanghai就能解决。这一块我把常见报错整理成了一个速查表放在文档附录里遇到问题照着查绝大多数环境问题五分钟内就能定位。6.2 前端接口 404 或跨域错误开发阶段最典型的问题有两种。第一种是接口 404也就是请求的后端地址根本不存在。检查路径是否以/api开头后端 Controller 的 RequestMapping 是否一致注意区分斜杠结尾的问题。第二种是浏览器控制台报跨域错误No Access-Control-Allow-Origin header is present这就说明请求没有走代理检查是否直接访问了http://localhost:8080改用相对路径/api开头让 Vite 代理生效。这里分享一个实际踩过的坑如果你同时安装了后端接口测试工具比如 Apifox、Postman和前端开发服务器测试工具的请求是直接发到 8080 的不存在跨域问题而前端 5173 发请求如果不走代理就会跨域。所以排查跨域问题时先用 Postman 直接测接口如果 Postman 通、浏览器不通那必然是代理配置问题。6.3 图片上传成功但访问 404图片上传接口返回了文件路径前端也能拿到 URL但浏览器访问时提示 404。99% 的原因是静态资源映射没配置对。检查 WebMvcConfig 中addResourceHandlers方法是否生效以及映射路径和磁盘路径是否匹配。我遇到过一个比较隐蔽的问题file:前缀在 Windows 系统中必须写成file:D:/upload/注意是正斜杠不能写成file:D:\\upload\\。Java 的 URI 解析对反斜杠接受度很差这个细节在很多系统中会导致映射失败。如果你用了 Nginx 部署前端图片请求还要单独配一个 location 指向上传目录这也容易漏。6.4 购物车数量异常与库存扣减问题购物车加购后数量不对经常是前端多次点击导致的重复请求。后端虽然加了唯一索引但两次请求同时进来时第一次请求插入成功、第二次请求如果是更新需要确保逻辑判断正确。我建议在前端按钮点击后立即disabled并加 loading 状态同时后端接口做幂等处理——对于同一用户的同一商品如果已存在购物车记录则执行数量累加。库存扣减问题最严重的场景是在订单取消后忘记回补库存。我实现的逻辑是用户取消订单时后端通过 OrderService 回补订单明细中每个商品的数量到库存表这样才形成完整的闭环。源码里的cancelOrder方法有完整的实现可以对照看。6.5 前后端联调时的账号与数据准备测试登录时文档里明确写了两个初始化账号普通用户user / 123456管理员admin / 123456。后台管理入口不是单独的前端路由而是登录时根据返回的角色字段自动跳转如果登录后跳转到的页面不对检查 localStorage 里的 role 字段是否为字符串1。这里有一个典型的类型比较问题后端返回的 role 是 Number 类型 1但localStorage存的是字符串1用比较必然为 false。我最终在前端统一用String(role)做了一次格式化避免这种隐蔽 bug。数据首发后如果商品图片加载不出来检查图片 URL 是相对路径还是完整路径。如果数据库里存的是/upload/xxx.jpg前端通过代理访问是没问题的如果存的是localhost:8080/upload/xxx.jpg那么其他设备访问时这个地址就失效了。建议统一存储相对路径由部署层决定最终的访问域名。写在最后的两个小建议第一做这类全栈项目时数据库设计要舍得花时间。我见过太多人急着写代码结果表结构设计不合理开发到一半反复改表反而拖慢进度。拿到需求先把实体关系画清楚字段类型、索引、状态流转想明白后面开发会顺畅很多。第二遇到 bug 不要只盯着代码看先分清是前端问题、后端问题还是环境问题。我在联调阶段养成了一个习惯任何接口报错先用 Postman 直接请求后端如果 Postman 正常那问题大概率出在前端请求封装或代理配置如果 Postman 也报错再去看后端日志。用二分法缩小范围排查效率至少翻一倍。这套源码和数据库脚本我希望起到的是一个“完整可运行的参考实现”的作用你们拿到后可以先跑起来再按照自己的需求去改把每个模块吃透收获会比单纯看教程大得多。