1. 项目概述与整体设计思路
做这个“基于Spring Boot的水果购物管理系统”之前,我其实花了不少时间想一个问题:现在电商系统那么多,为什么还要从零写一个购物系统?不是说要重复造轮子,而是对于很多刚接触Java后端开发的朋友来说,一个能够完整走通“商品展示—加入购物车—提交订单—后台管理”全流程的项目,是最能帮助理解业务系统设计逻辑的方式。水果购物这个场景选得也讨巧——它不像服装、数码产品那样需要复杂的SKU规格体系,水果商品属性相对简单(品类、产地、价格、库存),但又有自己的特殊点(新鲜度、上架季节、重量单位等),非常适合作为教学和演示项目。我自己在做这个系统的过程中,最大的体会是:业务功能本身并不复杂,真正考验功夫的地方在于“模块边界怎么划分、表结构怎么设计、订单状态机怎么设计、事务和并发怎么处理”这些看起来不起眼、但决定了系统能不能真正用起来的细节。
这个系统的核心需求很明确。从用户端来说,需要水果商品浏览、按分类筛选、商品详情展示、购物车管理、下单结算、订单状态查询这些基本能力;从管理端来说,需要商品上下架管理、库存修改、分类维护、订单处理、用户列表查看这些功能。如果往细了想,还可以加入水果图片批量上传、搜索功能、销售数据统计等扩展点。但作为项目的第一个版本,我建议还是把主链路走通,先把“买”和“卖”这两个核心闭环跑顺,再去叠加额外能力。
技术栈方面,我当时采用的是Spring Boot + MyBatis-Plus + MySQL + Vue Element UI的组合。Spring Boot负责提供RESTful接口,MyBatis-Plus负责数据访问层的快速实现,MySQL存储业务数据,前端采用Vue 2 + Element UI搭建后台管理页面,用户端则直接使用Thymeleaf模板渲染的方式实现。可能有人会问,为什么不直接全部前后端分离?我的考量是:管理后台全部做成接口+前端渲染的模式,用户端用模板渲染能减少页面跳转的接口请求压力,同时也能让前端逻辑和后端逻辑在一个项目里清晰区分。当然,这只是一个特定的选择,你完全可以采用纯前后端分离方案,思路是相通的。
还有一个设计取舍值得说道说道:项目里有没有必要引入Redis?我的建议是,当前版本可以先不引入。水果商城的商品数量通常不会特别大,商品信息的实时性要求也没那么高,直接用MySQL查询完全够用。如果硬引入Redis做缓存,反而增加了缓存一致性处理的复杂度,对于一个课程设计或者入门级的商业项目来说,性价比不高。等后续果品数量上来了、读压力大了,再考虑加缓存也不迟。
这个项目适合哪些人看?我觉得有三类:第一类,正在做毕业设计或课程设计、需要一个完整系统的同学;第二类,已经学完Spring Boot基础、想通过完整项目把知识串起来的自学者;第三类,准备转行Java开发、需要往简历上放一个有说服力的项目的新手。如果你已经能写出简单的CRUD接口、知道MyBatis的基本用法,那这个项目的代码细节你吃透不会有太大困难。如果你还处于刚接触Spring Boot的阶段,那也不用急,文中涉及的关键知识点我会尽量讲得细一些,你可以对照着把思路理清楚。
2. 核心细节解析与数据模型设计
2.1 功能模块划分:不贪多,但要闭环
我做项目有个习惯:先画功能导图,再写代码。这个系统的功能划分,从一开始我就确定了七个模块,这七个模块基本就是对Spring Boot业务系统最常见的功能拆解方式的映射。
第一个是用户模块。用户注册、登录、个人信息查看与修改、密码更新,这些是基础。登录状态我用的是Session方式,简单直观,适合单体应用。如果你不想用Session,换成JWT也不是不行,但要注意JWT的无状态特性在这类系统中反而会带来token管理和续期的问题,没必要为了“新”而“新”。
第二个是商品模块。前台商品列表、商品详情、按分类筛选、搜索,后台对应商品添加、编辑、上下架、库存管理。水果商品有几个特殊字段值得注意:商品单位(斤/盒/个)、产地、描述信息。这些不是强需求,但加上之后商品页会丰满很多。
第三个是分类模块。水果分类其实不需要太深,两级足够:大类(比如热带水果、时令水果、进口水果)和小类(比如香蕉、芒果、车厘子)。如果只做一级分类,那管理端维护起来最简单,但如果品类稍微多一点,一级分类会让列表显得杂乱。
第四个是购物车模块。这里有个常见的需求点:未登录用户能不能加购物车?我的实现方式比较保守——必须登录才能加入购物车。原因是后续下单时要用到用户ID,如果允许未登录加购,那还需要做Cookie合并逻辑,成本高、收益低,不适合当前体量。
第五个是订单模块。确认订单、生成订单、取消订单、查看订单列表、查看订单详情。订单是整个系统的核心,也是最容易出bug的地方,我在后面会详细讲。
第六个是管理端模块。管理员登录、商品管理、分类管理、订单列表、订单状态处理。管理端我直接用了一套独立的Vue Element UI页面,和后端的Spring Boot接口对接。
第七个是统计模块。这是一个补充功能:在管理端首页展示商品总数、用户总数、待发货订单数量等统计卡片,也可以简单做到过去7天的销售额趋势。这个功能我在写项目规划时其实是排到二期才做的,但在实际开发中发现做起来很简单——几个COUNT语句加上简单的日期分组查询就完成了,却能让人感觉系统无比完整,强烈建议加上。
2.2 数据库表结构:想到一个坑少一个
数据库设计是整个项目的地基。我总共设计了六张核心表:用户表、商品表、分类表、购物车表、订单表、订单明细表。下面把每张表的关键设计点和工作表的关键字段列出来,重点是理解“为什么这样设计”。
用户表(user)的核心字段包括:id、username、password(MD5加密存储)、nickname、phone、address、avatar、create_time。这里有两个点值得展开说。第一,密码需要加密存储,不能明文入库,我在项目中使用的是MD5加盐处理——单纯MD5在当今算力下极容易被暴力破解(实际上应该使用BCrypt等更安全的算法,MD5在这里仅作为演示方案)。第二,address字段设计的是一整个字符串而不是独立地址表,这个取舍是根据用户需求来的——如果要支持多收货地址,就得拆出独立地址表;如果用户只是单个默认地址,存一个字段就够用。水果商城是轻量场景,存一个字符串完全够用。
商品表(product)的关键字段比较多:name、category_id、main_image、price、stock、unit、origin、description、status(1表示上架,0表示下架)、sales(销量)、create_time。price字段有个老生常谈但必须强调的问题——强烈建议用Decimal而不是Float或Double。为什么?Float和Double是浮点数,在精度上天生有误差,0.1加0.2在某些精度下会变成0.30000000000000004。涉及钱的问题,让数据库用decimal(10,2)来存,计算上也使用Java的BigDecimal,这一点我在项目里反复被坑过,先写在这里给各位提个醒。
分类表(category)字段相对简单:id、parent_id、name、sort_order。这里用parent_id做父子关联,实现两级分类。一个商品只能属于一个叶子分类,做一个外键关联或者事务里逻辑判断就可以了。
购物车表(cart)的核心字段是:id、user_id、product_id、quantity、checked(是否勾选,用于批量结算)。这里有个细节:购物车表要不要加update_time?我建议加。因为在购物车列表接口中往往需要按修改时间倒序排列,让最近加购的排前面,用户体验更好。
订单表(orders)我要重点说。很多初学者设计的订单表把商品信息直接存进去,这是不对的。订单表应该存放订单维度信息,商品维度信息放订单明细表。orders表字段包括:order_no(订单编号,唯一)、user_id、total_amount(订单总金额)、status(订单状态)、receiver_name(收货人)、receiver_phone(收货电话)、receiver_address(收货地址)、remark(备注)、create_time、pay_time、deliver_time、finish_time。这里有一个非常重要的设计原则:收货信息必须快照到订单表里,不能下单时去关联用户表的地址。原因很简单——用户下单后改了地址,历史订单上显示的应该是下单时候的地址,而不是最新的地址。这种“快照”思想在电商系统中极其重要。
订单明细表(order_item)字段:id、order_id、product_id、product_name、product_image、product_price、quantity、subtotal。明细表也要把商品的名称、图片、价格都冗余存储下来,原因和收货地址一样——商品可能会改名、换图、调价,但历史订单必须保留下单那一刻的商品快照。如果明细表只存product_id,然后去关联商品表查名字,那商品下架后订单明细就全乱了。这是所有订单系统通用套路。
2.3 订单状态流转:别小看这张状态图
订单状态管理看似简单,实际上每个状态之间能做什么、不能做什么,要在业务逻辑上限制清楚。我的订单状态定义如下:
| 状态值 | 含义 | 用户可操作 | 管理员可操作 |
|---|---|---|---|
| 0 | 待付款 | 取消订单 | 关闭订单 |
| 1 | 待发货 | 无 | 发货 |
| 2 | 待收货 | 确认收货 | 无 |
| 3 | 已完成 | 删除订单(逻辑删) | 无 |
| 4 | 已取消 | 无 | 无 |
这里需要说明几点。第一,用户提交订单时初始状态是待付款,那支付功能做不做?我做了个简化的“模拟支付”——用户在订单详情页点击“模拟支付”按钮,调用后端接口把状态从待付款改成待发货。真实场景肯定要对接支付网关,但对于这个项目来说,模拟支付已经能完整走通流程,同时避免引入第三方支付平台的重重限制。
第二,取消订单的时机。待付款状态用户可以取消,一旦付款完成进入待发货,用户就不能直接取消了。如果确实想取消,需要联系管理员后台处理。这个规则一开始我没加校验,后来测试时发现用户可以反复提交取消请求直接把状态退回,逻辑变得很混乱。后来统一在Service层做状态判断,如果当前状态不符就直接抛异常,这才稳了。
第三,确认收货后状态变为已完成。这里还有一个可以扩展的点:已完成订单是否可以申请售后/退货?这又是一个独立模块,我在当前版本先不做,但表结构上预留了remark字段,后续要扩展退款记录也有空间。
2.4 后端工程结构:分层清晰是第一原则
Spring Boot项目的包结构对后续维护的重要性常被低估。我采用的工程结构如下:
com.example.fruitmall ├── config // 配置类:WebMvcConfig、跨域配置 ├── controller // Controller层:接收请求,返回结果 ├── service // Service层:业务逻辑 │ └── impl // Service实现类 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 接收前端参数的DTO对象 ├── vo // 返回前端数据的VO对象 ├── common // 通用类:返回结果封装、异常处理、常量类 ├── interceptor // 拦截器:登录拦截、管理员拦截 └── utils // 工具类:JWT或Session处理、文件上传等这样分层的意义在于:Controller只负责接收和返回数据,不写业务逻辑,Service里只写业务逻辑,不掺SQL语句。很多初学者写代码是把SQL写在Controller里,开发时一时爽,后面维护想死的心都有。这个项目的代码量不大,但如果你养成了良好的分层习惯,以后做任何项目都能受益。
3. 实操过程与核心环节实现
3.1 项目初始化与基础配置
项目初始化我选择通过Spring Initializr生成,Spring Boot版本用的2.7.x系列。有人可能会问为什么不用3.x,原因主要考虑稳定性和教程资源的匹配度,2.7.x这个版本无论在社区还是个人使用经历中都比较成熟,相关依赖的兼容问题少很多。建好项目后,第一件事是在application.yml里把数据源配好:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fruit_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto这几个配置有几个点要特别提醒。第一,url里的characterEncoding=utf8必须要加,否则数据库中文会出现乱码。第二,serverTimezone=Asia/Shanghai不能少,这跟MySQL驱动版本有关,不设置的话你往库里插时间很可能比预期少8小时。第三,map-underscore-to-camel-case=true这个配置让数据库的下划线字段自动映射到Java的驼峰属性,比如create_time自动映射为createTime,省去一堆@TableField注解。
然后还需要配置MyBatis-Plus的分页插件,这个在写后台商品列表时就要用到,所以干脆在项目初始化的时候一起加上:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }登录拦截器的实现也比较关键,我放到下面这个类里面统一处理。用户登录后把用户信息放入Session,拦截器负责放行白名单路径,拦截需要登录的路径:
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断用户是否登录,未登录则重定向到登录页或返回401 HttpSession session = request.getSession(); Object user = session.getAttribute("user"); if (user == null) { // 这里要处理前端是Ajax请求还是页面跳转请求 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); } else { response.sendRedirect("/login"); } return false; } return true; } }这个拦截器里我特别处理了Ajax请求和普通页面跳转的区别——如果是Ajax请求,直接返回401状态码,让前端收到后自行跳转登录页;如果是普通页面跳转,直接重定向。如果不对这两者做区分,很容易出现页面拿到一段JSON却不知道该怎么处理的情况,算是一个实战小教训。
3.2 商品模块与图片上传实现
商品模块的主体是CRUD,但有两个点值得认真讲。第一个是图片上传和回显,第二个是商品列表查询时的分类关联。
图片上传我用的方案是文件存入本地磁盘,然后在数据库里存URL相对路径。Spring Boot中做一个全局配置,映射静态资源路径:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /images/** 映射到服务器本地的 /upload/images/ 目录 registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadPath + "/images/"); }这样做的优势是简单直接,不依赖第三方对象存储。但有一个弊端:如果应用部署在服务器上,图片是存在本地的,一旦服务器磁盘坏了或应用迁移,图片就丢了。所以如果这个系统未来要真正跑起来,建议换成云存储方案,比如MinIO或者阿里云OSS。本地存储作为教学和演示够了,原理是通的,替换存储方案只需要改写上传接口和访问路径。
上传接口实现也不复杂,利用MultipartFile接收前端传的文件,判断文件类型和后缀是否合法,生成唯一文件名后保存:
@PostMapping("/admin/product/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = StringUtils.substringAfterLast(originalFilename, "."); if (!Arrays.asList("jpg", "jpeg", "png", "gif", "webp").contains(ext.toLowerCase())) { return Result.error("图片格式不支持"); } String filename = UUID.randomUUID().toString().replace("-", "") + "." + ext; File dir = new File(uploadPath + "/images"); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(dir, filename)); } catch (IOException e) { return Result.error("上传失败:" + e.getMessage()); } return Result.success("/images/" + filename); }商品列表查询这块,前端需要展示商品名称和分类名称。分类名称在category表,商品表里存的是category_id。两种做法:一种是连表查询,SQL里LEFT JOIN分类表,把name字段查出来;另一种是查询后再回表补全。这里推荐直接用MyBatis-Plus的自定义SQL写连表查询,因为分类名需要展示的场景多,每次回表查一次分类信息既低效又显得代码啰嗦。配合Page分页,查询代码如下:
public Page<ProductVO> queryProductPage(ProductQuery query) { Page<ProductVO> page = new Page<>(query.getPageNum(), query.getPageSize()); return productMapper.selectProductPage(page, query); }对应的Mapper XML:
<select id="selectProductPage" resultType="com.example.fruitmall.vo.ProductVO"> SELECT p.id, p.name, p.price, p.stock, p.unit, p.origin, p.main_image, p.status, p.sales, p.create_time, c.name AS category_name FROM product p LEFT JOIN category c ON p.category_id = c.id <where> <if test="query.keyword != null and query.keyword != ''"> AND p.name LIKE CONCAT('%', #{query.keyword}, '%') </if> <if test="query.categoryId != null"> AND p.category_id = #{query.categoryId} </if> <if test="query.status != null"> AND p.status = #{query.status} </if> </where> ORDER BY p.create_time DESC </select>3.3 购物车与订单的并发和事务处理
购物车模块逻辑不复杂,但有一个点要注意:同一个用户把同一个商品多次加入购物车,不要生成多条记录,应该累加数量。并且在增加购物车数量时,最好同步校验商品的库存和上下架状态。库存不足时直接给用户提示,避免下了单才发现没货那种糟糕体验。
购物车的接口列表我设计成这样:
| 接口路径 | 作用 | 说明 |
|---|---|---|
| GET /cart/list | 查询购物车列表 | 关联商品表查询最新价格和图片 |
| POST /cart/add | 加入购物车 | 传productId和quantity |
| PUT /cart/update | 修改数量 | 同时校验库存 |
| DELETE /cart/{id} | 删除购物车项 | 支持批量 |
| POST /cart/clear | 清空购物车 | 下单成功后调用 |
订单模块是整个系统最核心的部分,也是最容易出并发问题的地方。用户提交订单这个动作包含多个数据库操作:读取购物车选中的商品、检查库存、扣减库存、生成订单主表记录、生成订单明细记录、清空购物车。这些操作如果不放在同一个事务里,一旦中间某一步出错,就很容易出现“订单生成了但库存没扣”这类数据不一致的情况。
订单提交接口的核心代码逻辑如下:
@Transactional(rollbackFor = Exception.class) public OrderVO submitOrder(SubmitOrderDTO dto, Long userId) { // 1. 查询购物车中选中的商品项 List<Cart> cartList = cartMapper.selectCheckedItems(userId); if (cartList == null || cartList.isEmpty()) { throw new BusinessException("请选择要结算的商品"); } // 2. 构造订单主表 Orders order = new Orders(); String orderNo = generateOrderNo(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(0); // 待付款 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); // 3. 遍历购物车,校验并扣减库存 for (Cart cart : cartList) { Product product = productMapper.selectById(cart.getProductId()); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品已下架:" + cart.getProductId()); } if (product.getStock() < cart.getQuantity()) { throw new BusinessException("商品库存不足:" + product.getName()); } // 扣减库存(使用乐观锁或UPDATE条件) int rows = productMapper.deductStock(product.getId(), cart.getQuantity(), product.getVersion()); if (rows == 0) { throw new BusinessException("商品库存不足或已被修改"); } // 4. 构造订单明细快照 OrderItem item = new OrderItem(); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductImage(product.getMainImage()); item.setProductPrice(product.getPrice()); item.setQuantity(cart.getQuantity()); item.setSubtotal(product.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); orderItems.add(item); totalAmount = totalAmount.add(item.getSubtotal()); } order.setTotalAmount(totalAmount); // 5. 保存订单 ordersMapper.insert(order); // 6. 保存订单明细(这里用批量插入) orderItemMapper.batchInsert(order.getId(), orderItems); // 7. 清空已结算的购物车项 cartMapper.deleteCheckedItems(userId); // 8. 返回订单信息 OrderVO vo = new OrderVO(); vo.setOrderId(order.getId()); vo.setOrderNo(orderNo); vo.setTotalAmount(totalAmount); return vo; }上面这段代码里,我特别用了rollbackFor = Exception.class,让任何RuntimeException或Checked Exception都能触发事务回滚,避免数据不完整。另一个关键操作是productMapper.deductStock,这里我推荐使用带乐观锁的扣减或者带条件的UPDATE:
UPDATE product SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{id} AND stock >= #{quantity} AND version = #{version}这样即使多个人同时抢着购买同一款商品,数据库层面也能保证库存不会扣成负数。如果不加这个条件,两个请求同时读到库存为1,都执行stock = stock - 1,最后库存可能变成-1。这种并发场景在真实电商项目中很常见,在这个教学项目里体现出来,价值很大。
3.4 管理端实现要点
管理端的核心页面大概分这几块:控制台统计页、商品管理页、分类管理页、订单管理页、用户列表页、管理员登录页。我用Vue + Element UI搭建的后台,通过Axios调用后端RESTful接口。
管理端的接口设计上,需要统一加一层管理员权限校验。我在拦截器中区分了用户端和管理端路径:/admin/**路径除了检查登录外,还要求Session中登录的是管理员角色。这里有一个很隐蔽的坑——管理员和普通用户如果用的是同一套Session存储结构,一定要区分好字段名,比如用户用session.setAttribute("user", ...),管理员用session.setAttribute("admin", ...),防止用户登录后通过管理端路径越权操作。
订单管理页中,管理员可以对订单执行发货操作。这里的实现逻辑同样需要带着状态条件去更新:
int rows = ordersMapper.updateStatusByIdAndStatus(orderId, 1, 2, new Date());含义是“只有当当前状态为1(待发货)时,才能更新为2(待收货)”。如果返回0说明状态不对,可能是订单被用户取消了或者已经被别人处理了,直接提示管理员刷新页面重试。这种“乐观锁式”的更新方式,比先查再更新要安全很多,也能减少一次数据库往返。
商品管理页中,需要注意图片上传成功后再提交表单。我的做法是:上传接口返回图片URL后,前端把它暂存在表单数据里,点击提交时随商品信息一起发给后端。如果先提交商品信息再上传图片,可能出现商品已经保存但图片上传失败,导致商品没有图片的问题。
统计页的SQL也顺手贴出来,方便参考:
@GetMapping("/admin/dashboard/stats") public Result getStats() { Map<String, Object> data = new HashMap<>(); // 商品总数 data.put("productCount", productMapper.selectCount(null)); // 用户总数 data.put("userCount", userMapper.selectCount(null)); // 待发货订单数 LambdaQueryWrapper<Orders> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Orders::getStatus, 1); data.put("pendingShipCount", ordersMapper.selectCount(wrapper)); // 近7天销售额 data.put("lastWeekSales", ordersMapper.sumLastWeekSales()); return Result.success(data); }3.5 前端页面的关键交互
用户端的Thymeleaf页面虽然是服务端渲染,但加入购物车和提交订单这类操作还是得走Ajax。我开发的时候发现一个体验问题:直接用form表单提交的话,页面会刷新,用户选好的商品在购物车里,刷新一次又回到列表页,体验很断裂。后来统一改成Ajax请求,前端用jQuery的$.post或原生fetch发请求,后端返回JSON,页面局部刷新,这才顺畅了。
购物车页面有一个挺关键的交互逻辑:商品勾选框和数量输入框都变化时,都要重新计算合计金额。前端维护一个已选商品的数组,勾选状态或数量变化时重新计算。结算按钮把选中的购物车ID传给后端,后端只处理这些ID对应的商品。如果用户一个都没勾选就点结算,前端要拦截并提示;后端接口也要二次校验,防止绕过前端直接调接口。
前端还有一个细节:商品价格显示。数据库里price是decimal(10,2),到前端展示时如果直接渲染24.5没问题,但如果出现24这样的整数值,用户看起来会觉得奇怪。我在前端封装了一个过滤函数,统一格式化保留两位小数,顺便在后面加上单位(元/斤、元/盒这种),比较贴近日常使用习惯。
4. 常见问题与排查心得实录
4.1 图片上传成功但页面显示不出来
这个Bug是最常见的。排查思路一般是:先看浏览器请求的完整地址,然后去确认服务器磁盘上文件是否真的存在,再看Spring Boot静态资源映射路径是否配置正确。我在这个项目里踩了一个比较隐蔽的坑:本地开发时用的是Windows路径,部署到Linux服务器时路径分隔符不一样,导致上传的文件路径在数据库里存的是反斜杠,网页访问时解析成乱码。后来把路径统一改成使用/分隔,问题就解决了。
处理方案是在保存路径时做一次统一的规范处理:
String savePath = uploadPath.replace("\\", "/") + "/images/"; String url = "/images/" + filename;数据库存的是相对路径/images/xxx.jpg,前端拼接域名访问,这样无论在哪个环境部署,只要静态资源映射配置没问题,就都能正常访问。
4.2 订单金额对不上账
有一次测试时发现,一个订单明细中某项商品的金额和商品单价乘以数量差了0.01元。排查下来是数据库里price字段虽然定义成decimal,但MyBatis-Plus从数据库映射到Java时如果实体用Double接收,在多个商品合计时就会出现浮点误差累积。我最初没注意,后来统一把价格相关的实体字段全部改成BigDecimal,所有涉及金额的计算都用BigDecimal的multiply和add,并且在数据库层面把所有money类型字段都限定为decimal(10,2),再没出现过对不上账的情况。
这里再提一个计算规范:BigDecimal构造时建议使用字符串构造器,new BigDecimal("24.5"),不要用new BigDecimal(24.5)。后者在二进制浮点数的转换过程中依然会有精度问题,虽然日常不明显,但在累加运算中可能制造难以察觉的差异,能避免就避免。
4.3 并发下单时库存变负数
有同学测试时用两个浏览器同时登录一个账号,对同一商品同时下单,结果库存变成了-1。原因就是我在前面“订单与并发”部分讲到的:两个线程同时读到库存为1,各自扣减,没有条件约束。解决方案就是用带stock >= #{quantity}和乐观锁版本的UPDATE语句,让数据库在原子操作层面拒绝超卖。
这个场景我建议项目演示的时候当作一个亮点来讲,面试官或者答辩老师听到超卖问题的处理方式,一般都会比较认可,说明你是真的考虑过真实场景的,而不是只会写CRUD。
4.4 订单状态被人为跳过
我在开发时曾遇到过一个问题:用户可以绕过正常的业务流程,通过直接调用接口把订单从“待付款”强制改成“已完成”,账单状态乱七八糟。后来我在Service层强制校验状态流转的合法性,写了一个状态变更的方法,统一处理:
public void changeOrderStatus(Long orderId, Integer expectedStatus, Integer targetStatus) { Orders order = ordersMapper.selectById(orderId); if (order == null) { throw new BusinessException("订单不存在"); } if (!order.getStatus().equals(expectedStatus)) { throw new BusinessException("订单状态异常,当前状态:" + order.getStatus()); } order.setStatus(targetStatus); ordersMapper.updateById(order); }这种方式虽然简单,但保证每次状态转换都走统一点,不比引入状态机框架差多少。如果后续业务复杂,建议考虑引入状态机框架进行统一管理。
4.5 页面中文乱码和数据库乱码的根源
如果你用的是MySQL 5.7之前的版本,建表时没指定utf8mb4字符集,插入中文后查询显示乱码几乎是必然的。在创建数据库时就指定:
CREATE DATABASE fruit_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;同时在application.yml里已经配置了characterEncoding=utf8,双保险。这里特别注意,不要在连接池配置里漏掉useUnicode参数。另外MySQL 8.x推荐直接使用utf8mb4,表情符号也支持,兼容性比utf8更好。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 图片上传后无法访问 | 静态资源配置未生效、路径含反斜杠 | 检查addResourceHandlers配置,统一使用/分隔路径 |
| 中文乱码 | 数据库连接未设置编码、建表字符集不对 | url加characterEncoding,表设为utf8mb4 |
| 时间少了8小时 | 时区未设置 | url加serverTimezone=Asia/Shanghai |
| 库存变负数 | 并发扣减未加条件 | 使用带stock >= #{quantity}条件的UPDATE |
| 订单金额精度错误 | 使用浮点类型存金额 | 字段用decimal(10,2),Java用BigDecimal |
| 接口被越权访问 | 缺少管理员权限拦截 | /admin/**路径单独校验管理员角色 |
| 表单提交后页面刷新导致体验差 | 使用form submit | 改成Ajax请求,局部刷新 |
| MyBatis-Plus映射字段失败 | 驼峰转下划线配置缺失 | 开启map-underscore-to-camel-case |
5. 写在最后的体会与后续优化方向
整个项目从搭建骨架到全部功能跑通,我前后大概用了一周多的业余时间,大头花在订单模块和前后端联调上。搭建过程里有个很深的体会:写完一个功能一定要自己先点一遍,角色扮演一下普通用户的操作习惯——比如把商品数量改成0、连点两次提交订单、下单后立刻刷新页面,这些“手贱操作”往往就是出bug的重灾区。
如果这个项目你打算继续往深度走,我提供几个方向。第一,登录方式升级,把Session改为JWT方案,这样方便后续做小程序或者App端接口复用。第二,引入Redis缓存热点商品信息和登录状态,减轻数据库压力。第三,对接真实支付或模拟支付平台,让整个交易链路更完整。第四,增加电商后台常见的订单导出功能,把订单列表导出成Excel,方便老板对账。第五,商品搜索接入全文检索引擎(如ElasticSearch),不过这属于数据量上来之后的优化了,当前阶段用LIKE查询完全够。
最后就用一个踩坑的小例子收尾吧。我测试时发现,如果管理员修改商品的当前价格,但购物车里已经有用户加入了这个商品,那么用户结算的时候用的是最新价格还是加入时的价格?我一开始没想清楚,导致购物车里的价格和订单里的价格对不上。后来明确了规则:购物车列表展示时实时读取商品当前价格,下单时以购物车实时读取到的当前价格为准,同时生成订单明细快照。这样用户在购物车页看到的价格和下单金额永远一致,不会出现“购物车明明显示25元,点结算变成30元”的尴尬。如果购物车中也存了价格字段,那每次查询购物车时要同步刷新价格再返回给前端,否则也会有类似问题。这种细节整理得越多,你的系统就越稳,这也是靠谱后端工程师比普通CRUD程序员值钱的地方。
如果你正打算动手做一个类似的项目,我建议你别急着抄现成代码,先把表结构画出来、把状态流转理清楚、把接口清单列出来,写代码反而可以往后放。地基稳了,上面的房子才能真的住人。