news 2026/10/7 2:36:06

基于SpringBoot+Vue的网上商城系统开发实战与踩坑记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SpringBoot+Vue的网上商城系统开发实战与踩坑记录

最近刚把爱琴海购物公园的网上商城系统从头到尾做了一轮完整的开发,从需求梳理、技术选型到前后端编码、联调部署,前前后后踩了不少坑,也整理出了一些实打实的经验和踩坑记录。这是一个典型的基于SpringBoot+Vue的前后端分离商城项目,覆盖用户端购物全流程和管理端的商品、订单、用户管理。这篇文章我会把这套系统的设计思路、数据库建模、核心代码实现、联调部署中的实际操作和问题排查,全部捋一遍,希望能给正在做类似项目,尤其是拿商城系统当毕业设计或者练手项目的朋友提供一份能直接抄作业的参考。

1. 项目本质与整体设计思路

1.1 爱琴海购物公园为什么需要一套网上商城

爱琴海购物公园本身是线下实体商业综合体,覆盖零售、餐饮、亲子、影院等多种业态。线下购物公园天然存在几个痛点:营业时间受限于商场开门关门,辐射范围局限在周边几公里,顾客在到店之前无法浏览场内商品和了解实时活动。网上商城的价值在于把线下的商品和服务搬到线上,让用户随时随地浏览商品、查看活动、完成下单,同时为商场沉淀会员数据,做后续的精准运营。

从这个角度来说,这套商城的定位不是要替代线下,而是做线上线下的互补。系统设计的关键点在于:商品信息要能实时同步,订单流转要清晰,用户端体验要流畅,管理端操作要高效。理解了这一层商业逻辑,才能明白为什么要做用户模块、商品模块、购物车、订单、支付这些功能,而不是拍脑袋堆功能。

1.2 技术选型的核心考量:为什么是SpringBoot+Vue

现在的企业级项目里,SpringBoot+Vue基本是事实上的标准组合,选它不只是因为人气高,而是背后有一堆非常实际的考量。

先说后端SpringBoot。Java生态里SpringBoot把过去Spring MVC时代繁琐的XML配置几乎全部干掉,依赖引入、自动配置、内嵌Tomcat,一个mvn spring-boot:run就能把服务跑起来,这对快速搭建后端服务来说太友好了。商城系统涉及用户、商品、订单、支付多个业务模块,SpringBoot的starter机制能快速集成MyBatis、Redis、JWT这些工具,社区里踩坑记录也足够多。

再说前端Vue。Vue最大的优势是渐进式架构和组件化开发。商城页面多是列表、详情、购物车这类交互密集的页面,Vue的数据驱动视图特性让开发者不需要手动操作DOM,状态变了页面自动更新。配合Vue Router做路由管理、Pinia或者Vuex做全局状态管理,整个前端工程的结构会非常清晰。

对比过其他方案:如果纯用JSP+Servlet写,开发效率太低,前后端耦合严重,代码维护性差;用PHP系框架虽然上手快,但复杂业务逻辑的表达能力和工程化程度不如Java系;若依这类开源脚手架可以直接拿来改,但定制成本有时候比从零写还高,因为你要去理解别人封装好的代码逻辑。SpringBoot+Vue的组合在灵活性、开发效率、学习价值之间取了比较平衡的点。

1.3 系统整体功能模块划分

这套商城系统从角色上分为用户端和管理端,从业务流程上覆盖了从商品浏览到订单完成的完整链路。

用户端核心模块:

  • 首页:轮播图、分类入口、热门商品推荐、活动专区
  • 商品模块:商品列表、分类筛选、关键词搜索、商品详情、库存展示
  • 购物车:加购、修改数量、删除、选中结算
  • 订单:提交订单、选择收货地址、订单列表、订单详情、取消订单
  • 支付模块:对接模拟支付流程,生成订单后模拟支付回调更新状态
  • 个人中心:个人信息维护、收货地址管理

管理端核心模块:

  • 商品管理:商品上下架、库存修改、价格调整、商品图片上传、分类维护
  • 订单管理:订单列表、订单状态筛选、发货操作
  • 用户管理:用户列表、账号禁用/启用

在这个项目里,用户角色通过JWT中的角色字段区分,前端根据角色动态渲染管理端路由和菜单,这是前后端分离项目里很常见的权限控制方案。

2. 数据库设计与核心表结构

2.1 表结构设计原则与整体规划

商城系统的数据库设计是整个项目的基石,表建得不合理,后面写代码会处处难受。我在设计时遵循几个原则:一是核心业务表独立,避免字段冗余;二是金额字段统一用高精度类型,不用浮点;三是状态字段用int并且注释清晰,便于扩展;四是逻辑删除代替物理删除,保留数据可追溯性。

整个系统规划了核心业务表、用户相关表、商品相关表、订单相关表四类。其中核心业务表包括用户表、商品分类表、商品表、购物车表、订单表、订单明细表,一共六张主表。管理端的操作日志和轮播图表根据需求可以再加,但核心链路先保证这六张表设计到位。

2.2 各表核心字段与设计说明

用户表user:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `role` tinyint(4) DEFAULT '0' COMMENT '角色 0-用户 1-管理员', `status` tinyint(4) DEFAULT '1' COMMENT '状态 0-禁用 1-启用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint(4) DEFAULT '0' COMMENT '逻辑删除标记', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

密码字段长度给100,是因为BCrypt加密后密文长度固定60位左右,留点余量。role字段用tinyint而不是字符串,是为了后续扩展更多角色类型时不需要改表结构。这里特别注意username要加唯一索引,不然注册接口并发时会插入重复数据。

商品分类表category:

CREATE TABLE `category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `parent_id` bigint(20) DEFAULT '0' COMMENT '父分类ID,0表示顶级', `sort_order` int(11) DEFAULT '0' COMMENT '排序值,越小越靠前', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

parent_id字段支持两级甚至多级分类,爱琴海这种线下商场会涉及服饰、餐饮、亲子、影院、超市等多个品类,二级分类的灵活性很有必要。

商品表product:

CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` bigint(20) NOT NULL COMMENT '所属分类ID', `name` varchar(100) NOT NULL COMMENT '商品名称', `subtitle` varchar(200) DEFAULT NULL COMMENT '副标题', `main_image` varchar(500) DEFAULT NULL COMMENT '主图地址', `detail` text COMMENT '商品详情,支持富文本HTML', `price` decimal(10,2) NOT NULL COMMENT '商品价格', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存', `status` tinyint(4) DEFAULT '1' COMMENT '状态 1-上架 0-下架', `sales` int(11) DEFAULT '0' COMMENT '销量', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

price用decimal(10,2),这里不展开说为什么不用double,整个项目里涉及金额计算的字段全部统一用decimal,避免浮点精度丢失导致对账出问题。detail字段用text类型存储富文本,管理端可以直接用富文本编辑器提交HTML内容,前端v-html渲染。

购物车表cart:

CREATE TABLE `cart` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `product_id` bigint(20) NOT NULL COMMENT '商品ID', `quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量', `checked` tinyint(4) DEFAULT '1' COMMENT '是否选中 1-选中 0-未选中', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

购物车表设计上做了一步很关键的处理:user_id和product_id加了联合唯一索引。这个设计避免同一用户反复添加同一个商品时产生多条记录,后端在加购接口里先查询是否存在记录,存在就累加数量,不存在就插入新记录。

订单表order和订单明细表order_item:

CREATE TABLE `order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `total_price` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态 0-待支付 1-待发货 2-待收货 3-已完成 4-已取消', `receiver_name` varchar(50) NOT NULL COMMENT '收货人姓名', `receiver_phone` varchar(20) NOT NULL COMMENT '收货人电话', `receiver_address` varchar(200) NOT NULL COMMENT '收货地址', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `delivery_time` datetime DEFAULT NULL COMMENT '发货时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL COMMENT '订单ID', `product_id` bigint(20) NOT NULL COMMENT '商品ID', `product_name` varchar(100) NOT NULL COMMENT '商品名称快照', `product_image` varchar(500) DEFAULT NULL COMMENT '商品图片快照', `current_price` decimal(10,2) NOT NULL COMMENT '下单时价格快照', `quantity` int(11) NOT NULL COMMENT '购买数量', `total_price` decimal(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单表和订单明细表为什么要拆开,这是电商系统里的核心设计。一个订单对应多个商品,把订单的公共信息(收货人、总金额、状态)放主表,把每个商品的详细信息放明细表,避免一条订单记录里存多个商品导致的数据冗余和查询困难。

订单明细表里把商品名称、图片、价格都做了一次快照存储,这是非常关键的设计。因为商品的价格和名称后续可能修改,但对某笔订单来说,用户下单时看到的价格和商品信息应该是不可变的,所有订单历史必须和当时的商品信息保持一致。如果下单时不做快照,等用户查询历史订单时商品已经改价或者下架,订单展示就乱套了。

2.3 表关系梳理与理解

通过外键逻辑关系梳理:

  • 用户和购物车:一对多,一个用户可有多条购物车记录
  • 用户和订单:一对多,一个用户可有多个订单
  • 订单和订单明细:一对多,一个订单包含多个明细
  • 分类和商品:一对多,一个分类下有多个商品

这套关系不算复杂,但已经覆盖电商主链路。用Navicat或者MySQL Workbench生成E-R图后,整个项目的数据流会非常清晰。实际开发时可以不用物理外键,靠逻辑关联维护关系,因为物理外键在数据量大、并发高时会带来额外的索引开销和锁竞争,但目前这个体量的项目加不加物理外键影响不大。

3. SpringBoot后端核心模块实现

3.1 后端项目结构与分层架构

后端项目我习惯按功能模块建包,而不是按技术层建包。按技术层分包会出现controller包下面有几十个文件的情况,而按模块分包则是每个业务模块把Controller、Service、Mapper放在一起,阅读代码时上下文是聚合的。

com.aegis.mall ├── config # 配置类,包含跨域配置、拦截器配置 ├── controller # 接口层 │ ├── UserController │ ├── ProductController │ ├── CartController │ ├── OrderController │ └── AdminController ├── service # 业务逻辑层 │ ├── impl ├── mapper # MyBatis数据访问层 ├── entity # 实体类 ├── dto # 请求/响应对象 ├── common # 通用类:统一返回结果、异常处理、常量 ├── util # 工具类:JWT工具等

Dao层使用了MyBatis-Plus,这是MyBatis的增强工具,内置了常用的增删改查方法,像selectById、selectPage这些不用写SQL,针对复杂查询再写自定义SQL。对于商城这种CRUD居多的系统,开发效率提升很明显。

分层架构的核心是Service层要负责业务逻辑的判断,Controller只做参数接收和结果返回。比如下单这个操作,减库存、生成订单号、创建订单明细这些耦合的操作都在Service的同一个事务方法里完成,而不是拆到Controller里分散处理。

统一返回结果封装是前后端分离项目里必须做的一件事。我定义了一个Result类,包含code、message、data三个字段,接口执行成功返回code=200,业务异常返回自定义业务错误码,系统异常返回500。前端axios拦截器统一处理code,遇到特定码跳转登录,遇到业务错误码弹出提示,这样的好处是前后端约定清晰,不会出现接口一会儿返回对象一会儿返回string的情况。

3.2 用户登录与JWT鉴权

登录是商城系统的入口。密码存储使用BCrypt加密,不使用MD5,因为MD5加盐也要自己实现,而且彩虹表攻击风险大。Spring Security的BCryptPasswordEncoder是专门做密码哈希的,同一密码每次加密结果不同,因为内部自动生成了随机盐,校验时用matches方法即可。

public class JwtUtil { private static final String SECRET = "your-secret-key"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000L; public static String generateToken(Long userId, String username, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }

登录成功后在Controller返回token,前端存储到localStorage,后续所有请求在axios拦截器中携带Authorization请求头。

后端用拦截器统一处理token校验:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", Long.valueOf(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // token无效或过期 } } // 返回401状态码,前端跳转登录页 response.setStatus(401); return false; } }

写JWT拦截器时踩过一个坑:从请求头拿token时,前端习惯上是带"Bearer "前缀,后端解析时必须先把前缀去掉再解析,否则会抛JWT解析异常。这个细节在和前端联调时经常会导致后端日志一堆报错,排查半天才发现是前缀没处理。

管理端的接口权限校验再加一层角色判断,拦截器里检查claims中的role字段,管理员接口只允许role=1访问。

3.3 商品模块与购物车模块实现

商品列表接口的分页和搜索是商城系统的常客。使用MyBatis-Plus的分页插件,只需要在配置类里注册PaginationInnerInterceptor,然后调用selectPage方法即可。

搜索商品用LambdaQueryWrapper拼接like条件:

public Page<Product> getProductPage(int pageNum, int pageSize, String keyword, Long categoryId) { Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1); // 只查上架商品 if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword).or().like(Product::getSubtitle, keyword); } if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } wrapper.orderByDesc(Product::getSales); return productMapper.selectPage(page, wrapper); }

有一点要注意,keyword判断必须用StringUtils.hasText,不能直接用!= null,因为前端不传搜索词的时候keyword是空字符串,直接用isNotEmpty之类的判断会在分页查询时组装出一个错误的search条件。

购物车接口相对简单,加购逻辑是核心:

@Transactional public void addToCart(Long userId, Long productId, Integer quantity) { // 1. 校验商品是否存在且上架 Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } // 2. 查询购物车是否已有该商品记录 LambdaQueryWrapper<Cart> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Cart::getUserId, userId).eq(Cart::getProductId, productId); Cart cart = cartMapper.selectOne(wrapper); if (cart == null) { // 3. 没有则新建购物车记录 cart = new Cart(); cart.setUserId(userId); cart.setProductId(productId); cart.setQuantity(quantity); cartMapper.insert(cart); } else { // 4. 已有则累加数量 cart.setQuantity(cart.getQuantity() + quantity); cartMapper.updateById(cart); } }

这里的@Transactional注解保证了整个加购操作的原子性,查询、判断、插入或更新任何一个环节报错都会回滚,不会出现购物车里插入一半数据的情况。但是要注意一点,购物车加购时只做了商品存在性校验,没有校验库存,库存校验留在提交订单时做,这样更合理。

3.4 订单模块与支付流程

下单流程是整个系统最核心、最容易出bug的部分。基本流程是:前端提交购物车中勾选商品的id列表和收货地址信息,后端在同一个事务里完成以下步骤:

  1. 查询购物车中选中的商品记录,组装商品id和数量列表
  2. 校验每个商品的库存是否足够,不足则抛出异常中断下单
  3. 生成唯一订单号
  4. 计算订单总金额
  5. 扣减商品库存
  6. 写入订单主表
  7. 写入订单明细表
  8. 清空购物车中对应的商品记录

生成订单号是这里面的一个技术细节。直接用数据库自增id当订单号暴露给用户不合理,因为会泄漏订单量,格式不美观。我这里采用时间戳加随机数的方式:

public static String generateOrderNo() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timestamp = sdf.format(new Date()); int random = (int) ((Math.random() * 9 + 1) * 1000); return timestamp + String.valueOf(random); }

在并发量不大的场景下这种方式够用,但严格来说如果同一秒内并发下单超过一万笔,随机数可能碰撞。更稳妥的方案是使用Redis的INCR命令按天自增,生成类似202501011200000001的订单号。

库存扣减的SQL需要注意并发超卖问题。直接用update product set stock = stock - #{quantity} where id = #{id} and stock >= #{quantity},通过数据库的行锁和条件判断保证库存不会减成负数。这也是实际开发中常用的乐观锁思路的一种变形,不用专门加version字段,因为库存这个业务本身就适合用条件更新来实现。

支付模块在毕设或练习场景下通常做模拟支付:前端点击支付后请求后端pay接口,后端把订单状态从待支付改为待发货,记录支付时间,模拟支付回调。如果要对接真实支付,微信支付和支付宝都要求企业资质,个人开发者在沙箱环境测试即可,支付宝的沙箱环境可以申请,接口对接逻辑和真实环境几乎一致。

4. Vue前端页面与联调

4.1 Vue项目搭建与目录结构

前端我选择Vite作为构建工具。Vite基于ES Module,开发环境冷启动秒开,热更新速度快,相比Vue CLI的Webpack构建环境体验好非常多。如果还在用Vue CLI,更新Vite后你会明显感觉到开发效率提升。

src ├── api # 接口请求封装 │ ├── product.js │ ├── cart.js │ ├── order.js │ └── user.js ├── assets # 静态资源 ├── components # 通用组件 │ ├── Header.vue │ ├── ProductCard.vue │ └── Pagination.vue ├── router # 路由配置 │ └── index.js ├── store # Pinia状态管理 │ ├── user.js │ └── cart.js ├── views # 页面组件 │ ├── home │ ├── product │ ├── cart │ ├── order │ ├── user │ └── admin ├── utils # 工具函数 │ └── request.js (axios封装) ├── App.vue └── main.js

状态管理这里用了Pinia。Vue 3官方推荐Pinia替代Vuex,它的API更简洁,去除mutations概念,直接在store里定义state、getters、actions,而且天然支持TypeScript。购物车角标数量、用户登录状态这些全局共享数据放store里管理非常合适。

4.2 核心页面与路由实现

前端路由需要配置两个类型:用户端页面和管理端页面。管理端页面必须做权限控制,只有角色为管理员的用户才能访问。Vue Router的导航守卫是很方便的权限控制手段。

router.beforeEach((to, from, next) => { const userStore = useUserStore(); if (to.path.startsWith('/admin') && userStore.role !== 1) { next('/login'); return; } if (to.meta.requiresAuth && !userStore.token) { next('/login'); return; } next(); });

早期的前端权限控制方案是后端返回当前用户的菜单列表,前端动态添加路由,逻辑稍显复杂。对于这个体量的项目,直接在前端判断角色和路由路径就足够可靠。

商品详情页的图片展示用el-carousel轮播组件,商品描述区域直接把后端返回的HTML片段用v-html渲染。这里有一个安全知识点:v-html渲染不受信任的HTML会有XSS风险,但商城场景下商品详情的HTML来自管理端富文本编辑器,内容是管理员录入的,所以风险可控。

购物车页面需要处理一个典型的交互:选中商品、修改数量、实时计算总价。用watch监听购物车列表的变化,重新计算选中商品的小计和总金额。这里要注意,修改数量后要节流提交后端更新,避免每次点击加减都发请求导致后端压力大。

4.3 前后端联调与跨域处理

开发环境下前端运行在5173端口,后端运行在8080端口,跨域问题是绕不开的。

跨域解决方式非常多,后端配置是最省事的方案。SpringBoot里通过实现WebMvcConfigurer的addCorsMappings方法来配置全局跨域:

@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); } }

注意allowCredentials(true)和allowedOriginPatterns不能和allowedOrigins("*")同时使用,因为允许携带凭证的跨域请求不能使用通配符来源。如果不加allowCredentials,前端请求就带不上cookie,但这套系统用JWT做token认证,不依赖cookie,所以会把token放在Authorization头里来传。

axios请求封装是联调时沟通成本最低的方案。统一设置baseURL,请求拦截器里加token,响应拦截器里统一处理错误码和401跳转:

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); if (res.code === 401) { router.push('/login'); } return Promise.reject(new Error(res.message)); } return res; }, error => { ElMessage.error('网络异常,请稍后重试'); return Promise.reject(error); } );

联调时最烦的是后端接口写好后,前端拿到数据后发现字段对不上。比如后端返回驼峰命名,前端写了个下划线命名去取,页面一片空白。规范的做法是前端所有接口请求都封装在src/api目录下,每个接口清楚标注请求方式和参数类型,后端每个字段严格遵循统一的命名规范。

5. 常见问题与避坑指南

5.1 LocalDateTime序列化问题

SpringBoot默认使用Jackson序列化LocalDateTime,如果前端不指定格式,默认格式是"2025-01-01T10:30:00",这个格式前端展示时通常不符合要求。最简单的解决方案是在实体类的LocalDateTime字段上加@JsonFormat注解,也可以全局配置Jackson:

@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); }

这个坑是在做订单列表接口时遇到的,前端拿到时间字段格式不对,排了半天没发现是后端序列化问题,最后加上全局时间格式化才解决。

5.2 金额计算精度问题

这个前面提过,但值得单独强调。商城项目中金额计算贯穿购物车、下单、订单查询多个环节,如果用了double类型,0.1+0.2=0.30000000000000004这种精度问题一定会出现。整个项目所有金额相关字段用BigDecimal,并且购物车结算时把每个商品的单价乘以数量累加,全部在BigDecimal的运算方法里完成,不经过浮点转换。

一个小细节:BigDecimal构造时推荐使用BigDecimal.valueOf(double)或者new BigDecimal(String),不要直接new BigDecimal(double),否则会得到二进制浮点数的精确值,依旧有精度问题。

5.3 库存超卖与并发控制

商城系统的经典问题。下单时不校验库存直接用update减库存,并发场景下会出现库存变负数的情况。解决方案是在扣库存SQL上加上stock >= #{quantity}的条件判断,让数据库层面的行锁来保证安全。

实际项目中更复杂的场景需要引入Redis分布式锁或者乐观锁,但这个商城系统这样处理已经够用。写这段代码时要保证扣减库存和创建订单在同一个事务里,并且事务的隔离级别不会造成脏读。

5.4 前端部署方案

前后端分离项目部署有两种常见方式。一种是前端打包成dist目录后用Nginx托管,API请求通过Nginx反向代理到后端服务,生产环境用Nginx配置location /api/的代理规则。另一种是直接把前端构建产物拷贝到SpringBoot的src/main/resources/static目录,然后一起打成jar包运行,相当于SpringBoot既提供API又托管静态页面。

第二种方式适合毕设演示和个人项目,因为只需要部署一个jar包,不用单独配Nginx。但要注意,如果前端使用了vue-router的history模式,访问非根路径刷新页面时后端需要配置转发,否则会404。SpringBoot中适配的方法是把所有非API路径转发到index.html:

@Controller public class PageForwardController { @RequestMapping(value = {"/", "/{path:[^\\.]*}"}) public String forward() { return "forward:/index.html"; } }

这个配置的意思是,除了包含点号的文件路径外,所有路径都转发到index.html,让Vue Router接管路由。如果不做这个处理,在商品详情页刷新一下就白屏,是非常影响体验的问题。

5.5 图片上传与访问路径

管理端商品图片上传,开发时我把图片保存到本地磁盘目录,然后通过一个静态资源映射接口对外暴露访问。SpringBoot配置类里加一行:

registry.addResourceHandler("/upload/**").addResourceLocations("file:" + uploadPath);

部署到服务器后要注意上传路径的绝对路径配置,不要用相对路径,因为jar包运行时的工作目录不固定,相对路径容易出错。更合理的方式是把图片存到OSS等对象存储,但本地磁盘方案在课程设计和毕设场景足够用了。

5.6 前端路由刷新404问题

这个坑是部署后最容易踩到的。开发模式下Vue Router的history模式不会有问题,因为Vite的开发服务器自己会处理历史回退。但是部署到Nginx或SpringBoot静态托管后,刷新非首页路径就会404,原因在于服务器不知道前端路由的存在,只会按路径去找文件。

Nginx场景的解决方案是配置try_files指令:

location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }

SpringBoot场景的解决方案就是上面提到的页面转发Controller。这两个方案二选一,根据你的部署方式来决定。

5.7 购物车刷新后丢失

如果购物车数量只保存在前端store里,刷新页面state就被清空了,导致购物车数据丢失。解决办法有两个方向:一是登录用户每次进入页面时调用后端接口拉取购物车列表,把store初始化;二是把购物车数据也持久化到localStorage。最合理的做法是用前者,以后端数据为准,前端store只做展示层的缓存。

当时做购物车角标的实时更新时,也遇到了一个问题:用户在不同页面之间切换,购物车数量需要保持一致。最终通过Pinia全局管理购物车数量,每次购物车接口返回新数据时更新数量,页面组件统一从store读取。

最后说几点个人心得

做这类前后端分离的商城系统,最容易陷入的误区是先把所有页面写完,再回头写后端接口,最后对接口时发现前端需要的字段后端根本没返回,改造工作量巨大。正确的方式是先把接口文档定好,字段名、返回结构、错误码全部明确,前后端可以并行开发。哪怕没有正式的Swagger文档,至少要在api目录的注释里把接口和参数写清楚。

另一个值得注意的经验是:商城系统虽然功能看着多,但核心永远是订单流程。购物车、商品、用户模块都是为订单服务的,设计时把订单相关的事务边界、状态流转想清楚,代码写起来会顺畅很多。订单状态机的设计建议在动手编码前先用文字把每个状态的触发条件和流转路径描述清楚。

还有一点,如果这是毕设项目,答辩时老师大概率会问数据库设计为什么这么建、库存扣减怎么防超卖、token过期了怎么办这类问题。把这些问题背后的原理弄明白,比堆功能有意义得多。

这套系统的扩展空间其实也很大,比如接入真实支付、增加秒杀模块、引入消息队列做订单异步处理、用Redis做热点数据缓存,这些都是后续可以继续深入的方向。如果做完了基础版还想提升项目亮点,优先从缓存和性能优化角度入手,性价比最高。

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

Centos清空历史命令

在Linux&#xff08;centos&#xff09;中&#xff0c;在终端中运行的所有命令都会存储在主目录中名为 .bash_history 的文本文件中。这个时候可以通过使用 history 命令来显示系统自您启动会话以来输入的所有命令的列表。出于某种原因&#xff0c;有时候想要从Linux Bash历史记…

作者头像 李华
网站建设 2026/10/7 2:32:05

AI智能体权限管控:从操作系统盲区到四层防御体系

最近AI智能体&#xff08;AI Agent&#xff09;的风越刮越猛&#xff0c;朋友圈里晒的、面试中聊的、技术社区里讨论的&#xff0c;全是让大模型自己动手干活儿的场景。我今天想认真聊一个被很多人忽略、但迟早会爆雷的问题&#xff1a;当AI智能体可以随意删除文件、群发邮件&a…

作者头像 李华
网站建设 2026/10/7 2:31:58

构建 Web 应用之 Go 基础(二):控制语句与函数完全指南

文档教程 【免费下载链接】build-web-application-with-golang A golang ebook intro how to build a web with golang 项目地址&#xff1a; https://gitcode.com/gh_mirrors/bu/build-web-application-with-golang 点击查看 免费下载 本文面向《Build Web Application with …

作者头像 李华
网站建设 2026/10/7 2:30:43

高速产线视觉检测丢图:信号控制器与相机光源时序协同是关键

凌晨一点&#xff0c;产线电话打过来&#xff1a;"检测系统又开始丢图了。"这种电话我接过太多次。赶过去一看&#xff0c;操作工说相机坏了要换&#xff0c;电工说软件卡了要重启&#xff0c;等真把图像序列调出来——第37行图像莫名消失&#xff0c;后一张图上缺陷…

作者头像 李华
网站建设 2026/10/7 2:30:42

TVA智能体技术体系概述(28):高并发实时决策优化五大核心方案解析

前沿技术探索:TVA智能体(简称TVA) TVA智能体(亦称“AI智能体视觉”)是依托Transformer架构与“因式智能体”理论构建的新型工业视觉系统,也是当前最具代表性的具身视觉技术之一。它有机融合深度强化学习(DRL)、卷积神经网络(CNN)与因式分解算法(FRA),构成了具身智…

作者头像 李华