news 2026/9/16 12:11:07

基于Spring Boot + Vue 3的水果蔬菜商城全栈项目实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot + Vue 3的水果蔬菜商城全栈项目实战解析

开门见山,我做这个基于 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 Plus,Vue 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 结构都不一样,前端处理起来就是一场灾难。我设计了一个通用的Result<T>类,结构如下:

public class Result<T> implements Serializable { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> 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, List<Integer> cartItemIds, AddressVO address) { // 1. 查询购物车中选中的条目 List<CartItem> 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. 生成订单明细 List<OrderItem> 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 IPage<Product> getProductPage(int pageNum, int pageSize, Integer categoryId, String keyword) { LambdaQueryWrapper<Product> 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.jscart.jsorder.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-tableel-dialog组合搭建。商品管理的核心表单(名称、价格、库存、分类、图片、描述)放在一个弹窗表单组件里,新增和编辑共用同一个组件。编辑时通过row数据回填表单,新增时清空表单。这种复用方式能减少大量重复代码。

后台的权限控制除了前端路由守卫以外,还有一个容易被忽略的地方:接口级别的控制。假如普通用户登录后直接调用后台的商品删除接口,后端 JwtInterceptor 会通过解析 token 中的role字段来确定是否为管理员,如果角色不是管理员,直接返回 403。所以光有前端隐藏按钮是不够的,后端的权限拦截才是真正的安全防线。这是很多课程设计项目会漏掉的点,用了我的源码的同学可以重点关注一下这一块的实现。

5. 环境配置与项目启动全流程详解

5.1 本地开发环境准备

实际带跑这个项目时,最常见的坑大多出在环境准备阶段。JDK 版本不对、Maven 依赖下载慢、Node 版本与 Vite 不兼容都会卡住新手。我建议直接按照文档里的环境版本说明来,不要追求最新版本。

  • JDK 1.8 或 JDK 11(Spring Boot 2.7 最高兼容到 JDK 17,但 JDK 8 最稳)
  • Maven 3.6+
  • MySQL 5.7 或 MySQL 8.0(注意 8.0 的驱动依赖需要配置com.mysql.cj.jdbc.Driver
  • Node.js 16.0+(Vue 3 和 Vite 5 需要较新版本)
  • 开发工具推荐 IntelliJ IDEA 和 VS Code

MySQL 8.0 和 5.7 主要在驱动类名和时区配置上有差异。我的application.yml中数据库连接串是这样写的:

spring: datasource: url: jdbc:mysql://localhost:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 后面补上&allowPublicKeyRetrieval=true。最后是The server time zone value的报错,统一在连接串添加serverTimezone=Asia/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 也报错,再去看后端日志。用二分法缩小范围,排查效率至少翻一倍。这套源码和数据库脚本我希望起到的是一个“完整可运行的参考实现”的作用,你们拿到后可以先跑起来,再按照自己的需求去改,把每个模块吃透,收获会比单纯看教程大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 12:10:10

Flutter Toast在HarmonyOS应用中的优化实践

1. 项目概述在Flutter应用开发中&#xff0c;Toast提示是用户交互的重要组成部分。传统的SnackBar虽然功能完善&#xff0c;但在实际使用中存在一些局限性。最近我在开发一个HarmonyOS平台的天气应用时&#xff0c;发现系统自带的SnackBar在跨平台适配和样式灵活性上存在不足。…

作者头像 李华
网站建设 2026/9/16 12:09:33

FPGA在边缘AI中的核心优势与实战部署指南

1. 为什么说FPGA是边缘AI里“最灵活”的计算芯片&#xff1f;你可能已经听过很多次“边缘AI需要低功耗、低延迟、高能效”&#xff0c;也见过无数张对比图&#xff1a;GPU算力强但功耗高、ASIC性能优但无法改逻辑、MCU便宜但跑不动YOLOv5。但真正动手做过嵌入式AI部署的人&…

作者头像 李华
网站建设 2026/9/16 12:09:24

C语言实现静态顺序栈:从原理到嵌入式实战

1. 项目概述顺序栈是数据结构中最基础也最重要的线性结构之一&#xff0c;它完美体现了"后进先出"(LIFO)的特性。在嵌入式开发、操作系统内核、编译器设计等对内存要求严格的场景中&#xff0c;静态分配的数组实现方式因其确定性和高效性而备受青睐。这个项目将带你从…

作者头像 李华
网站建设 2026/9/16 12:07:46

OpenClaw安全漏洞解析与AI代理防护实践

1. OpenClaw安全现状深度解析&#xff1a;风险与机遇并存2026年&#xff0c;OpenClaw这款开源AI代理工具在全球范围内掀起了一场技术风暴。作为一名长期关注AI安全领域的技术从业者&#xff0c;我亲眼见证了它从默默无闻到GitHub星标数超越React和Linux的惊人历程。但与此同时&…

作者头像 李华
网站建设 2026/9/16 12:06:43

SpringBoot+Vue全栈美食平台开发实战

1. 项目概述&#xff1a;全栈美食交流平台的技术实现这个美食交流宣传系统是一个典型的全栈Web应用&#xff0c;我去年为本地餐饮协会开发过类似项目。系统采用现在企业级开发最流行的前后端分离架构&#xff1a;后端用SpringBoot提供RESTful API&#xff0c;前端用Vue.js构建交…

作者头像 李华