资讯动态

基于Spring Boot的水果购物管理系统开发实战:从订单到库存全流程

发布时间:2026/10/21 14:10:52 来源:尧图企业网站定制
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、passwordMD5加密存储、nickname、phone、address、avatar、create_time。这里有两个点值得展开说。第一密码需要加密存储不能明文入库我在项目中使用的是MD5加盐处理——单纯MD5在当今算力下极容易被暴力破解实际上应该使用BCrypt等更安全的算法MD5在这里仅作为演示方案。第二address字段设计的是一整个字符串而不是独立地址表这个取舍是根据用户需求来的——如果要支持多收货地址就得拆出独立地址表如果用户只是单个默认地址存一个字段就够用。水果商城是轻量场景存一个字符串完全够用。商品表product的关键字段比较多name、category_id、main_image、price、stock、unit、origin、description、status1表示上架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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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里的characterEncodingutf8必须要加否则数据库中文会出现乱码。第二serverTimezoneAsia/Shanghai不能少这跟MySQL驱动版本有关不设置的话你往库里插时间很可能比预期少8小时。第三map-underscore-to-camel-casetrue这个配置让数据库的下划线字段自动映射到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 PageProductVO queryProductPage(ProductQuery query) { PageProductVO page new Page(query.getPageNum(), query.getPageSize()); return productMapper.selectProductPage(page, query); }对应的Mapper XMLselect idselectProductPage resultTypecom.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 testquery.keyword ! null and query.keyword ! AND p.name LIKE CONCAT(%, #{query.keyword}, %) /if if testquery.categoryId ! null AND p.category_id #{query.categoryId} /if if testquery.status ! null AND p.status #{query.status} /if /where ORDER BY p.create_time DESC /select3.3 购物车与订单的并发和事务处理购物车模块逻辑不复杂但有一个点要注意同一个用户把同一个商品多次加入购物车不要生成多条记录应该累加数量。并且在增加购物车数量时最好同步校验商品的库存和上下架状态。库存不足时直接给用户提示避免下了单才发现没货那种糟糕体验。购物车的接口列表我设计成这样接口路径作用说明GET /cart/list查询购物车列表关联商品表查询最新价格和图片POST /cart/add加入购物车传productId和quantityPUT /cart/update修改数量同时校验库存DELETE /cart/{id}删除购物车项支持批量POST /cart/clear清空购物车下单成功后调用订单模块是整个系统最核心的部分也是最容易出并发问题的地方。用户提交订单这个动作包含多个数据库操作读取购物车选中的商品、检查库存、扣减库存、生成订单主表记录、生成订单明细记录、清空购物车。这些操作如果不放在同一个事务里一旦中间某一步出错就很容易出现“订单生成了但库存没扣”这类数据不一致的情况。订单提交接口的核心代码逻辑如下Transactional(rollbackFor Exception.class) public OrderVO submitOrder(SubmitOrderDTO dto, Long userId) { // 1. 查询购物车中选中的商品项 ListCart 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; ListOrderItem 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这里我推荐使用带乐观锁的扣减或者带条件的UPDATEUPDATE 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() { MapString, Object data new HashMap(); // 商品总数 data.put(productCount, productMapper.selectCount(null)); // 用户总数 data.put(userCount, userMapper.selectCount(null)); // 待发货订单数 LambdaQueryWrapperOrders 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里已经配置了characterEncodingutf8双保险。这里特别注意不要在连接池配置里漏掉useUnicode参数。另外MySQL 8.x推荐直接使用utf8mb4表情符号也支持兼容性比utf8更好。4.6 常见问题速查表现象可能原因解决方案图片上传后无法访问静态资源配置未生效、路径含反斜杠检查addResourceHandlers配置统一使用/分隔路径中文乱码数据库连接未设置编码、建表字符集不对url加characterEncoding表设为utf8mb4时间少了8小时时区未设置url加serverTimezoneAsia/Shanghai库存变负数并发扣减未加条件使用带stock #{quantity}条件的UPDATE订单金额精度错误使用浮点类型存金额字段用decimal(10,2)Java用BigDecimal接口被越权访问缺少管理员权限拦截/admin/**路径单独校验管理员角色表单提交后页面刷新导致体验差使用form submit改成Ajax请求局部刷新MyBatis-Plus映射字段失败驼峰转下划线配置缺失开启map-underscore-to-camel-case5. 写在最后的体会与后续优化方向整个项目从搭建骨架到全部功能跑通我前后大概用了一周多的业余时间大头花在订单模块和前后端联调上。搭建过程里有个很深的体会写完一个功能一定要自己先点一遍角色扮演一下普通用户的操作习惯——比如把商品数量改成0、连点两次提交订单、下单后立刻刷新页面这些“手贱操作”往往就是出bug的重灾区。如果这个项目你打算继续往深度走我提供几个方向。第一登录方式升级把Session改为JWT方案这样方便后续做小程序或者App端接口复用。第二引入Redis缓存热点商品信息和登录状态减轻数据库压力。第三对接真实支付或模拟支付平台让整个交易链路更完整。第四增加电商后台常见的订单导出功能把订单列表导出成Excel方便老板对账。第五商品搜索接入全文检索引擎如ElasticSearch不过这属于数据量上来之后的优化了当前阶段用LIKE查询完全够。最后就用一个踩坑的小例子收尾吧。我测试时发现如果管理员修改商品的当前价格但购物车里已经有用户加入了这个商品那么用户结算的时候用的是最新价格还是加入时的价格我一开始没想清楚导致购物车里的价格和订单里的价格对不上。后来明确了规则购物车列表展示时实时读取商品当前价格下单时以购物车实时读取到的当前价格为准同时生成订单明细快照。这样用户在购物车页看到的价格和下单金额永远一致不会出现“购物车明明显示25元点结算变成30元”的尴尬。如果购物车中也存了价格字段那每次查询购物车时要同步刷新价格再返回给前端否则也会有类似问题。这种细节整理得越多你的系统就越稳这也是靠谱后端工程师比普通CRUD程序员值钱的地方。如果你正打算动手做一个类似的项目我建议你别急着抄现成代码先把表结构画出来、把状态流转理清楚、把接口清单列出来写代码反而可以往后放。地基稳了上面的房子才能真的住人。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑