ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

外卖订单模块实战:状态机、业务闭环与接口设计

外卖订单模块实战:状态机、业务闭环与接口设计 1. 先弄清楚day9在整条业务线里的位置如果你也是从苍穹外卖day1一路撸到day8的老哥应该已经习惯了天天跟员工、分类、菜品、套餐、购物车这些东西打交道。到了day9恭喜终于熬到这个项目里最硬核、也是最贴近真实外卖业务的一块订单模块。它不仅仅是多写几个增删改查接口而是把前面做的所有东西串起来的关键一环。没有订单前面的菜品、套餐、购物车都是摆设。day9的核心工作说白了就是要完成两条线的订单功能管理端那一套是给商家用的“订单管理”包括按条件搜索订单、查看订单详情、接单、拒单、派送、完成订单用户端那一套是给小程序用户用的“历史订单”包括分页查看自己的历史订单、查看订单详情、取消订单、再来一单。这两套接口表面看着都是围绕order表做文章实际写起来你会发现它们各自的业务边界、状态判定、数据组装方式完全不同踩的坑也完全不一样。先说清楚一件事为什么订单模块必须两端都做。你想想真实的外卖场景用户下了单商家那边要能看到、能处理用户这边要能查自己点过什么、能不能取消、吃完了还能不能再来一单。这是一个完整的业务闭环。只做管理端不做用户端那用户下了单都不知道去哪儿查等于白瞎只做用户端不做管理端商家接不了单订单全堆在待接单状态等着超时。所以day9给你安排的内容本质上是在模拟一条完整的外卖订单生命周期用户提交订单、微信支付、商家接单、骑手派送、用户确认收货、历史订单沉淀。1.1 订单表为什么要拆成主表和明细表苍穹外卖里订单相关的主要是两张表orders订单主表和 order_detail订单明细表。我第一次看到这个设计的时候觉得有点多余后来才发现这是做交易类系统的常识只是平时写CRUD写多了容易忽略它背后的道理。订单主表存的是什么一次订单的“主干信息”订单号、订单状态、下单用户、收货地址、联系电话、订单金额、支付状态、下单时间、支付时间这一堆字段描述的是“这一单是谁、什么时候、花了多少钱、现在什么状态”。订单明细表存的是“这一单里具体点了什么”菜品名称、份数、单价、口味一条明细对应一次下单中的一种菜品。如果用户一次点了三道菜那an order会一条order_detail会有三条记录它们通过order_id关联起来。为什么要拆两张表最核心的原因是避免冗余和保证数据一致性。如果所有东西都塞一张表每个菜名都要重复一遍订单号、用户ID、地址、金额这些字段数据冗余是小事关键是后面如果要统计“哪种菜卖得最好”“某个时间段里某个菜品的销量”你都没法高效地按菜品维度聚合。拆开之后订单主表负责订单整体维度明细表负责菜品维度各司其职。还有一个很关键的点很多人会忽略订单明细里存的是下单那一刻的单价快照而不是去关联菜品表实时查价格。为什么要做快照因为菜品价格是可以改的。你今天点了一份黄焖鸡米饭25块钱明天商家把价格调到了30块你昨天那单的金额不能跟着变。交易数据必须保留历史时刻的真实情况这就是“快照”思想的落地。说白了订单明细里的name、amount、dish_flavor都是下单时从菜品表里“抄”过来的之后菜品表怎么改都跟这笔订单无关。1.2 关键的库表字段和VO设计别等写代码时才发现缺东西day9这种模块动手写Controller之前一定要先把表结构、状态定义、返回给前端的VO结构理清楚。我见过不少同学一上来就写Mapper写到最后才发现返回前端的数据还差字段又回去改SQL来回折腾。orders表里你真正会用到的核心字段基本是这些id主键number订单号前端展示给用户看的一般用时间戳加随机数生成可读性要强status订单状态1待付款、2待接单、3已接单、4派送中、5已完成、6已取消user_id下单用户IDaddress_book_id地址簿ID但注意下单时要把地址信息冗余到orders表order_time下单时间checkout_time支付时间pay_method支付方式1微信支付pay_status支付状态0未支付、1已支付、2退款amount订单总金额remark备注phone、address、consignee收货人信息这里是冗余字段后面细说cancel_reason取消原因rejection_reason拒单原因cancel_time取消时间estimated_delivery_time预计送达时间delivery_status配送状态delivery_time送达时间pack_amount打包费tableware_number餐具数量tableware_status餐具状态order_detail表相对简单一些id、order_id关联订单主表dish_id菜品ID如果是菜品下单就有值setmeal_id套餐ID如果是套餐下单就有值dish_flavor口味存的是JSON格式字符串比如[{甜度:七分甜}]number份数amount下单时的单价快照image菜品图片name菜品名称这里要特别强调一下收货人信息的冗余。用户下单的时候系统把他选中的地址簿记录里的phone、address、consignee都复制到了orders表里。为什么不能直接存一个address_book_id然后去关联查询地址簿因为地址是可以删的、可以改的。假设用户下单后用地址簿改了手机号你这笔订单里的收货电话如果跟着变外卖员就联系不到下单时的那个人了。订单是一次交易凭证它必须定格下单那一刻的所有关键信息这个思路贯穿整个day9。VO设计上管理端要返回给前端的有OrderVO订单详情包含订单明细列表、OrderStatisticsVO订单数量统计待接单、待派送、派送中数量用户端主要返回OrderVO和历史订单的分页结果。DTO一般有OrdersPageQueryDTO分页查询参数页码、每页条数、订单号、手机号、状态、下单时间区间、OrdersSubmitDTO用户提交订单参数、OrdersRejectionDTO拒单原因、OrdersCancelDTO取消原因。这些类写起来不难但提前把字段列全后面就不用反复改。2. 订单状态机整个模块的“交通规则”day9里最考验逻辑能力的不是SQL不是分页而是订单状态的管理。外卖订单不是一张随便改改status字段就能交代的表单它的状态变化是有严格的业务规则的。我把这套规则理解为整个订单模块的“交通规则”你写代码时如果不按这个规则来就会出现各种诡异的线上事故比如订单被商家先接单后又取消或者同一个订单被重复完成。2.1 状态码与合法流转路径先看状态定义苍穹外卖里订单状态码一共六个状态码含义可以做的操作1待付款用户取消、支付支付后进入待接单2待接单用户取消、商家接单、商家拒单3已接单待派送商家派送4派送中商家完成订单5已完成无6已取消无合法流转路径其实就是一条主线加几条分支正常路径1待付款 - 2待接单 - 3已接单 - 4派送中 - 5已完成取消路径1待付款 - 6已取消用户后悔了取消路径22待接单 - 6已取消用户取消或者商家拒单支付路径1待付款 - 2待接单用户支付成功这条路径是我做day9的时候反复对着业务逻辑捋了半天的东西。你可以把它想成一个审批流程提交申请下单- 初审通过支付- 领导审批商家接单- 执行中派送- 办结完成。每一步都必须基于前一步的状态不能越级。2.2 为什么不能在Service里直接setStatus很多新手写订单状态流转的时候习惯性这样写orders.setStatus(3); orderMapper.updateById(orders);逻辑上看起来没问题订单变成已接单了。但问题是什么你没有校验这个订单当前的状态是不是2待接单。假设一个订单已经是4派送中了这时候商家端因为页面卡顿又点了一次“接单”按钮你的接口会把这个订单从4改回3整个流程直接乱套。更夸张的情况是一个已经取消的订单通过某种方式被重新“接单”造成已退款订单又被继续配送。我在做的时候给每个状态流转接口都加了前置校验。以商家接单为例代码逻辑类似Transactional public void confirmOrder(Long orderId) { Orders orders orderMapper.getById(orderId); // 校验订单是否存在 if (orders null) { throw new OrderBusinessException(订单不存在); } // 校验订单状态只有待接单才能接单 if (!Objects.equals(orders.getStatus(), Orders.ORDER_PENDING_ACCEPT)) { throw new OrderBusinessException(订单状态异常无法接单); } orders.setStatus(Orders.ORDER_CONFIRMED); orderMapper.updateById(orders); }注意这里用了一个常量类或者常量定义来替代魔法数Orders.ORDER_PENDING_ACCEPT表示2Orders.ORDER_CONFIRMED表示3。实测下来把状态码抽成常量能让你少犯很多低级错误至少比整个项目到处飘着数字2、3、4要强得多。后面排查问题的时候你看到语义化常量名就知道这个数字代表什么状态不需要满项目去搜。拒单和取消订单的校验更严格。拒单必须判断当前状态是2待接单因为只有还没接的单子才有“拒”的余地而且拒单必须填写拒单原因这个原因最后是要展示给用户看的。取消订单则要区分两种情况如果是待付款订单1用户还没花钱直接取消即可不需要退款如果是待接单且已支付pay_status1的订单取消时就要把支付状态改成退款2在真实项目中这里还会去调用微信支付的退款接口把真金白银退回给用户。day9这个阶段如果接了微信支付退款逻辑要写全不能只改个状态就算完事。3. 管理端订单管理搜索、详情、状态操作管理端订单管理是商家后台最常用的功能之一。页面大概长这样卖家可以看到一个订单列表列表上方是搜索条件订单号、手机号、订单状态、下单时间段列表里每一行显示订单号、下单时间、客户信息、菜品信息、金额、状态右侧有操作按钮比如接单、拒单、派送、完成。后端要为这个页面提供三个核心能力分页多条件搜索、订单详情查询、状态流转接口。另外还有一个订单统计给首页或者订单列表页显示待接单、待派送、派送中的实时数量。3.1 多条件分页搜索动态SQL是核心分页查询本身不难难的是“多条件”这三个字。搜索条件是动态的用户可能只填订单号搜也可能只按状态筛还可能填了时间段再带手机号你的SQL必须能适配任意组合。这种场景用MyBatis的动态SQL最合适。我最终的Mapper写法类似这样select idconditionSearch resultTypecom.sky.entity.Orders parameterTypecom.sky.dto.OrdersPageQueryDTO select * from orders where if testnumber ! null and number ! and number like concat(%, #{number}, %) /if if testphone ! null and phone ! and phone like concat(%, #{phone}, %) /if if teststatus ! null and status #{status} /if if testbeginTime ! null and order_time gt; #{beginTime} /if if testendTime ! null and order_time lt; #{endTime} /if /where order by order_time desc /select这里有几个细节要提醒。第一所有条件都包在 标签里它会在有查询条件时自动拼上WHERE关键字并且自动处理掉第一个AND你不需要担心多条件拼接出语法错误。第二order_time是datetime类型比较时用gt;和如果你直接写和XML文件会报错因为大于号和小于号在XML里是特殊字符必须转义。我在IDEA里第一次写的时候就被这个坑恶心到了报错信息说的是“The content of elements must consist of well-formed character data or markup”翻译过来就是XML格式不合法把箭头符号转义掉就好了。第三个细节也是比较隐晦的就是if条件的写法。status这个字段传进来是Integer类型判断条件写status ! null就没问题不需要也不能写status ! 因为Integer和字符串比较本身就不安全。但是number和phone是String就必须同时判空和判空串否则用户输入一个空字符串条件就变成了and number like %%查出来的结果就是所有数据。3.2 订单详情的组装能一次查完就别循环查点击订单列表里的“查看详情”前端会请求管理端订单详情接口返回的数据包括订单主信息和订单明细列表。这个接口的逻辑看起来很简单先查orders主表记录再根据order_id查order_detail列表组装成OrderVO返回。但这里有一个N1查询的隐患。如果你在“订单列表”接口里为了给每个订单都带上明细先查出20条订单然后循环20次去查明细表那前端列表页一加载数据库就要执行21条SQL其中20条是几乎一模一样的单行查询。数据量小的时候无所谓一旦订单量上来了这个接口直接就把数据库拖垮。day9阶段如果只是为了功能跑通循环查也能接受但我的建议是现在就养成好习惯。列表接口不要先查明细而是先查出订单列表再把订单ID集合作为参数用一次IN查询把所有明细查出来在Java内存里按orderId分组最后组装到对应的订单VO里。这样不管列表有多少条记录SQL始终只有两条。// 先查订单列表 ListOrders ordersList orderMapper.conditionSearch(ordersPageQueryDTO); // 收集订单ID ListLong orderIds ordersList.stream().map(Orders::getId).collect(Collectors.toList()); // 一次查出所有明细 ListOrderDetail allDetails orderDetailMapper.getByOrderIds(orderIds); // 按orderId分组 MapLong, ListOrderDetail detailMap allDetails.stream() .collect(Collectors.groupingBy(OrderDetail::getOrderId)); // 组装VO ListOrderVO orderVOList ordersList.stream().map(order - { OrderVO orderVO new OrderVO(); BeanUtils.copyProperties(order, orderVO); orderVO.setOrderDetailList(detailMap.getOrDefault(order.getId(), Collections.emptyList())); return orderVO; }).collect(Collectors.toList());这个写法在你后面做其他项目时也经常用得到一对多关系的列表组装本质都是“先主后从、批量查询、内存分组”三步走。3.3 状态流转接口接单、拒单、派送、完成管理端的四个状态流转接口套路高度相似但每个都有自己的业务校验。我把它们的核心逻辑放大看一下。接单订单从2待接单变成3已接单。校验只有一个当前状态必须是2。接单后订单进入“商家已确认”的状态等待骑手取餐派送。拒单订单从2待接单变成6已取消。校验当前状态必须是2同时前端必须传拒单原因。如果该订单已经支付还需要把pay_status改成2退款并记录rejection_reason。这里要注意拒单原因不能拼接到cancel_reason里因为业务上取消原因和拒单原因是两个不同角色触发的不同记录展示给用户的地方也不一样。派送订单从3已接单变成4派送中。校验当前状态必须是3。派送中意味着骑手已经在路上了。完成订单从4派送中变成5已完成。校验当前状态必须是4。这个接口一般是商家点击“订单已完成”触发的也可以理解为骑手送达后商家在后台确认收款完成。每写一个接口我都建议复制上面的状态校验模板而不要想着写一个通用方法把所有状态流转都收拢了。因为每个接口的事务边界、业务字段拒单原因、取消原因都不一样强行合并只会让代码更绕。校验状态时建议先查库再改库不要用前端传过来的status作为判断依据前端传的status不可信。还有一个容易被忽略点状态流转接口必须加Transactional。接单操作包含“查订单、改状态、更新数据库”三步中间任何一步失败都不能让数据库留半截数据。拒单和取消还涉及退款状态更新更要保证原子性。3.4 订单数量统计三种状态的实时数字管理端首页或者订单列表页上方通常会显示三个数字待接单、待派送、派送中的订单数量。这个接口叫“订单统计”返回的数据结构是OrderStatisticsVO有三个字段toBeConfirmed待接单、confirmed待派送、deliveryInProgress派送中。最朴素的写法是调三次count查询Integer toBeConfirmed orderMapper.countByStatus(2); Integer confirmed orderMapper.countByStatus(3); Integer deliveryInProgress orderMapper.countByStatus(4);这样写没毛病但如果你比较在意查询效率可以用一条SQL聚合出来select sum(case when status 2 then 1 else 0 end) as toBeConfirmed, sum(case when status 3 then 1 else 0 end) as confirmed, sum(case when status 4 then 1 else 0 end) as deliveryInProgress from orders实测下来数据量在百万以内两种写法差距不大我习惯用三次count因为可读性强也方便单独给某个状态的条件加索引。但你要知道有聚合查询这种优化手段面试或者写复杂统计报表时会用到。4. 用户端历史订单查询、取消、再来一单管理端的订单功能是从商家视角出发的用户端的历史订单则是从用户视角出发的。这个模块有几个接口分页查询历史订单、查询订单详情、取消订单、再来一单。这里要特别注意身份问题用户端拿到的数据都必须带上当前登录用户的ID不能查成别人的订单。苍穹外卖项目的用户身份是从ThreadLocal里取的用户端登录的时候会往ThreadLocal里塞当前用户信息管理端登录也会塞但塞的是另一个key。写代码的时候一定要用对上下文我在day9的时候用错过一次管理端查用户数据查出来全是空排查了半天才发现是ThreadLocal取值的key不对。4.1 历史订单分页查询怎么优雅地避免性能陷阱用户端历史订单列表页面大概是按时间倒序展示每一笔订单每笔订单下面列着这次点的菜从明细表来、金额、状态。这个接口的处理逻辑跟管理端的列表查询很像也是“主表分页 明细批量组装”的思路。先根据当前用户ID和分页参数查orders表得到一页订单列表。然后拿这些订单的ID集合一次性查出对应明细分组组装到每个订单VO里。这里有一个跟管理端列表不太一样的地方用户端要看“订单里的菜”通常可以直接在列表页显示所以明细查询是必须的。但也不要把明细表所有字段都查出来只需要页面要展示的字段就够了减少网络传输和内存占用。分页查询时有一个经典坑必须讲PageHelper的使用位置。startPage()调用后紧跟其后的第一条SQL会被分页中间不能夹杂其他查询。如果你先查了用户信息再查订单列表分页参数就会作用到用户信息那条SQL上订单列表反而没有分页或者分页数据错乱。正确写法是PageHelper.startPage(page, pageSize); ListOrders ordersList orderMapper.pageQueryByUserId(userId, status); PageOrders pageInfo (PageOrders) ordersList;然后从pageInfo里拿total和当前页数据。4.2 取消订单的边界与退款处理用户端取消订单跟管理端取消订单逻辑类似但触发场景更敏感。用户点“取消订单”你要先判断这个订单是不是他的再判断这个订单当前是什么状态。取消订单的合法边界是状态为1待付款或2待接单。如果状态已经变成3已接单、4派送中、5已完成就不能取消了因为商家已经备餐或者骑手已经在路上。这是真实外卖平台都会做的限制不是产品经理拍脑袋定的而是业务上确实不允许。如果订单是待付款1状态说明用户还没支付直接取消把status改成6cancel_time记上cancel_reason填“用户取消”完事。如果订单是待接单2且已支付说明用户付了钱但商家还没接单这时取消订单需要处理退款。在day9的简化实现里你可以把pay_status从1已支付改成2退款并填写取消原因。如果接入了微信支付这里就要调用微信支付退款接口把金额原路退回。退款接口必须做幂等处理防止用户连点两次取消导致退款重复执行。一个简单的幂等方案执行取消前先查订单状态只有状态是2时才允许取消取消后状态变成6第二次再进来就查不到状态是2的订单了自然就不会重复退款。if (orders null || !Objects.equals(orders.getUserId(), userId)) { throw new OrderBusinessException(订单不存在); } if (Objects.equals(orders.getStatus(), Orders.ORDER_WAIT_PAY)) { // 待付款直接取消 orders.setStatus(Orders.ORDER_CANCELLED); orders.setCancelReason(用户取消); orders.setCancelTime(LocalDateTime.now()); } else if (Objects.equals(orders.getStatus(), Orders.ORDER_PENDING_ACCEPT) Objects.equals(orders.getPayStatus(), Orders.PAID)) { // 已支付待接单取消并退款 orders.setStatus(Orders.ORDER_CANCELLED); orders.setCancelReason(用户取消); orders.setCancelTime(LocalDateTime.now()); orders.setPayStatus(Orders.REFUND); // 真实项目这里调用微信退款接口 } else { throw new OrderBusinessException(当前状态无法取消订单); } orderMapper.updateById(orders);这个分支判断非常重要我见过有同学不判断状态直接把所有订单都取消掉结果已完成的订单也能取消用户余额还不退属于重大bug。写这一段的时候心态要稳把每个分支覆盖到。4.3 再来一单把历史明细“转存”到购物车再来一单从用户视角看就是“我上次点了这几个东西现在再点一份一模一样的”。它的实现不是再下一单而是把历史订单的明细数据重新插入购物车表用户在购物车里确认后走正常的下单流程。这里有一个特别容易出错的地方购物车表shopping_cart的字段跟订单明细表order_detail高度重合所以最简单的方式是用BeanUtils.copyProperties直接拷贝字段。但拷贝的时候要注意几个坑。第一copyProperties会把orderDetail里的orderId也拷贝到购物车里如果购物车表恰好没有orderId字段就没影响但如果你的购物车表字段设计得比较全里面有orderId字段时就必须手动把orderId清空。正常情况下购物车表没有orderId它是按user_id来区分的。第二购物车记录里必须有user_id表示这些菜是当前用户要买的。这个user_id从ThreadLocal里取当前登录用户ID再set进去。create_time也要set成当前时间否则后面排序或者展示时间时会出问题。第三也是最大的坑菜品口味dishFlavor是JSON字符串。订单明细里的dish_flavor存储的是下单时的口味快照比如[{甜度:七分甜,温度:温热}]。直接把这个字符串拷到购物车表的dish_flavor字段前端再把这个JSON串解析出来展示给用户看起来一切正常。但如果你在购物车里做了“合并同款菜品”的逻辑比如用户再次点“再来一单”时系统会尝试把相同菜品的数量累加这时候比较菜品是否相同的条件里如果包含dishFlavor两个JSON字符串可能因为键值对的排列顺序不同被判断成不同口味导致本应合并的菜品重复展示。day9里如果你只是无脑插入购物车不做合并反而最安全功能是对的页面也符合预期。Transactional public void repetition(Long orderId) { Long userId BaseContext.getCurrentId(); ListOrderDetail orderDetailList orderDetailMapper.getByOrderId(orderId); for (OrderDetail orderDetail : orderDetailList) { ShoppingCart shoppingCart new ShoppingCart(); BeanUtils.copyProperties(orderDetail, shoppingCart); shoppingCart.setUserId(userId); shoppingCart.setCreateTime(LocalDateTime.now()); shoppingCartMapper.insert(shoppingCart); } }这里再提醒一句BeanUtils.copyProperties是浅拷贝对于String、Long、Integer这些基本类型字段完全够用。如果你后面做复杂项目遇到嵌套对象拷贝就要考虑深拷贝或者手动赋值了。4.4 用户端接口的细节体验问题用户端历史订单查询还有一个容易被忽略的参数status。小程序页面上通常有“全部”“待付款”“待接单”“待派送”“派送中”“已完成”几个Tab点击不同的Tab会带着不同的status参数查询。这个status是可选参数为空时查全部不为空时按状态筛选。Mapper里处理方式跟管理端搜索一样用 动态拼接。唯一要注意的是status这个参数有可能是0前端默认传0表示全部Java里Integer类型0 ! null是true所以判断条件写成status ! null就行不要带上status ! 0这种画蛇添足的判断。再有一个用户体验细节历史订单列表返回时最好带上一个“订单状态文本”字段比如status2时返回“待接单”status6时返回“已取消”。因为前端组件直接绑定数字展示不友好让后端返回语义化字符串能省掉前端不少转换逻辑。虽然这个字段在VO里是冗余的但在实际前后端联调时非常省事。我在OrderVO里加了一个statusText字段用switch根据status映射实测前端同学很高兴。5. 踩坑实录这些问题我day9当天都遇到过day9做完我自己记录了一堆坑。这里挑几个频率最高的给后来的人排排雷。这些坑不一定每个你都会遇到但遇到任何一个都够你查半小时的。5.1 动态SQL不生效先检查参数类型我day9遇到的第一个坑就是多条件搜索时传status进去控制台打印的SQL里居然没有status这个条件。检查了半天发现DTO里的status字段类型写成了String前端传过来的是字符串3而Mapper里if判断原本以为它是Integer写的是status ! null判断通过了但SQL拼接时MyBatis把字符串3和Integer类型的字段比较类型不匹配直接没拼上条件。更隐蔽的是这种错误不会报异常只会让你查出来一堆不符合条件的数据。排雷建议动态SQL里的if条件参数类型一定要跟实体类字段保持一致尤其是状态、数量这些数值型字段不要用String接收。另外如果发现动态SQL某条分支没生效第一时间用控制台打印的prepareStatement日志看实际执行的SQL语句立刻就能定位是哪个条件被吞了。5.2 时间参数解析失败前端白屏订单搜索有条件没有生效之外还遇到过时间范围查询。用户在下单时间段里选了开始时间和结束时间前端传来的是字符串格式yyyy-MM-dd HH:mm:ssController里的DTO如果直接用LocalDateTime接收而没加任何格式化注解就会报反序列化错误接口直接500前端页面一片空白。解决办法是在DTO的beginTime和endTime字段上加DateTimeFormat注解指定格式DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime beginTime; DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime endTime;这个注解是Spring自带的专门用来处理前端传参到LocalDateTime的格式转换。加了之后时间范围查询才能正常工作。还有个小坑如果前端传的时间格式带TISO格式比如2024-06-01T10:00:00而你的pattern写的是空格也一样解析失败联调时最好先确认前端的传参格式两边对齐再写pattern。5.3 PageHelper分页漂移前面提过PageHelper的经典坑我这里再细讲一下。startPage()方法是基于ThreadLocal实现的它会拦截下一条查询SQL自动在末尾拼上LIMIT。但如果你的代码在startPage()和Mapper查询之间执行了其他数据库操作比如先查了一下订单状态统计那么分页信息会被作用到那条统计SQL上订单列表SQL反而没有分页把所有数据一次性查出来了。我的建议是在Service层的分页查询部分把startPage和mapper查询写在一起中间不要插入任何其他逻辑。如果必须要查别的数据就在分页查询结束后再查。另外如果你用了PageHelper的PageInfo拿到总条数注意它的total是会在执行count查询时重新计算的这一步查询也会消耗一次数据库IO对于高频列表接口可以考虑直接用Page对象的getTotal()效果一样少一次转换开销。5.4 再来一单时口味JSON串引发的数据错乱我在4.3里提到过这个坑这里再说一个真实案例。有次用户反馈“再来一单”以后购物车里出现了两个一模一样的黄焖鸡米饭但它们的口味展示不同一个显示“微辣”一个显示“不辣”。查了数据才发现两次“再来一单”时复制过来的dishFlavor虽然内容一样但JSON序列化时键值顺序不同。第一次是[{spicy:微辣,sugar:半糖}]第二次是[{sugar:半糖,spicy:微辣}]。业务上明明是同一个菜品同一个口味但在数据库里就是两条记录合并逻辑直接失效。这个问题的根源在于JSON字符串的键顺序不稳定。解决思路有几个一是不要依赖JSON串做合并判断合并条件只比较dish_id、setmeal_id和dish_flavor这三个字段的精简归一化版本二是下单时统一通过固定顺序序列化口味数据保证同一个口味永远生成同样的JSON串三是简单粗暴不要做合并。day9阶段没有做合并逻辑直接插入是最省事也最不容易出错的方案但你要知道这个坑长什么样以后真做购物车合并功能时会用得上。常见问题典型原因解决办法动态SQL条件没拼上参数类型不匹配或判断逻辑错误打印真实SQL核对if条件时间范围查询报500LocalDateTime反序列化失败DTO字段加DateTimeFormat分页数据不对/全量返回startPage与其他查询交叉startPage紧跟列表查询再来一单出现重复菜品口味JSON键顺序不一致合并条件不要依赖JSON串顺序取消订单退不了款没区分支付状态待接单且已支付才触发退款订单状态乱跳状态流转接口没做前置校验每个接口查库校验当前状态管理端查不到用户数据ThreadLocal上下文用错确认用户端/管理端key区分5.5 还有一个容易忽略的小坑金额用BigDecimal做订单模块金额计算是绕不开的。下单时计算订单总额、退款时计算退款金额、订单明细里的单价这些涉及钱的数据一定不能用double或float。double在二进制存储下会有精度丢失0.1加0.2算出来是0.30000000000000004放到订单金额上就是事故。苍穹外卖的实体类里amount字段用的就是BigDecimal分页查询也好、金额计算也好全程保持一致不要中间为了省事转成double算完再转回来。这个习惯建议保持到所有涉及金额的项目中凡是跟钱相关的计算无脑用BigDecimal并且用setScale(2, RoundingMode.HALF_UP)控制精度。6. 做完day9之后我才真正理解的几件事day9做下来体感最深的不是学会了几个新注解也不是又写了一堆CRUD而是想明白了一个问题订单模块为什么比前面所有模块都难写。前面的员工管理、分类管理、菜品管理本质都是单表单实体的增删改查改一个字段就完事。但订单不一样它牵涉主表、明细表、用户、商家、支付状态、配送状态任何一个环节的状态变化都影响后续一系列操作你必须站在整个业务流程的角度去设计而不是站在某个接口的角度去写代码。我的实际建议是做完day9以后不要着急往下走花半小时把订单状态流转图画出来把每个流转接口的校验条件列出来再把每个接口会改哪些字段写出来。你会发现管理端四个流转接口、用户端取消接口、拒单接口它们之间共享着同一套状态规则。清楚了这套规则后面无论做day10的订单支付回调还是做更复杂的商家端数据统计都会顺手很多。这个“先梳理业务再写代码”的习惯值得你在后续所有项目里沿用。最后再分享一个小技巧。day9里有大量“判断订单状态是否合法”的逻辑我建议把订单状态抽取成常量类别直接用魔法数字。比如OrderStatus.PENDING_PAYMENT、OrderStatus.PENDING_ACCEPT、OrderStatus.CONFIRMED、OrderStatus.DELIVERY_IN_PROGRESS、OrderStatus.COMPLETED、OrderStatus.CANCELLED。这样读代码的人一眼就能看出每个数字背后的业务含义后面接微信支付回调、写订单状态订阅通知时直接复用这套常量既统一又不易出错。我踩过魔法数的亏改一个数字要全局搜索的滋味不好受。
返回列表