前言
Day09 的实战围绕订单模块展开:用户需要能查看和复用历史订单,商家需要按订单状态处理业务,同时系统还要在下单前判断收货地址是否处于配送范围内。
这几个功能共享同一条主线:订单状态必须按照业务规则流转,任何状态修改前都要先完成必要校验。文章结合当前项目代码,梳理用户端订单、商家端订单管理以及 5 公里配送范围校验的实现。
一、先确定订单的状态与流转规则
订单表中的 status 和 payStatus 分别描述业务状态与支付状态。当前项目定义如下:
publicstaticfinalIntegerPENDING_PAYMENT=1;publicstaticfinalIntegerTO_BE_CONFIRMED=2;publicstaticfinalIntegerCONFIRMED=3;publicstaticfinalIntegerDELIVERY_IN_PROGRESS=4;publicstaticfinalIntegerCOMPLETED=5;publicstaticfinalIntegerCANCELLED=6;publicstaticfinalIntegerUN_PAID=0;publicstaticfinalIntegerPAID=1;publicstaticfinalIntegerREFUND=2;正常的状态流转为:待付款 → 待接单 → 已接单 → 派送中 → 已完成。用户或商家取消订单、商家拒单时,订单会进入已取消状态。
这里不能只根据前端按钮判断能否操作。服务层必须重新查询订单,并以数据库中的状态作为判断依据。例如商家接单时,当前订单必须处于待接单状态:
publicvoidconfirm(Longid){OrdersordersDB=requireOrder(id);if(!ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)){thrownewOrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);}Ordersorders=Orders.builder().id(ordersDB.getId()).status(Orders.CONFIRMED).build();orderMapper.update(orders);}requireOrder 负责查询订单并处理订单不存在的情况;通过校验后,再构造只包含待更新字段的 Orders 对象交给 Mapper。这样可以避免前端重复提交或请求过期时把订单写入错误状态。
二、用户端历史订单:列表、详情、取消与再来一单
用户端接口统一位于/user/order下,Day09 新增的核心接口包括:
@GetMapping("/historyOrders")publicResult<PageResult>historyOrders(OrdersPageQueryDTOordersPageQueryDTO){returnResult.success(orderService.historyOrders(ordersPageQueryDTO));}@GetMapping("/orderDetail/{id}")publicResult<OrderVO>orderDetail(@PathVariableLongid){returnResult.success(orderService.orderDetail(id));}@PutMapping("/cancel/{id}")publicResultcancel(@PathVariableLongid){orderService.cancel(id);returnResult.success();}@PostMapping("/repetition/{id}")publicResultrepetition(@PathVariableLongid){orderService.repetition(id);returnResult.success();}1. 历史订单列表为什么还要查询订单明细
订单列表不仅要展示订单号、金额和下单时间,还要展示菜品信息。因此订单分页查询完成后,服务层会遍历当前页订单,根据订单 id 查询订单明细表,并将结果放进 OrderVO。
publicPageResulthistoryOrders(OrdersPageQueryDTOordersPageQueryDTO){ordersPageQueryDTO.setUserId(BaseContext.getCurrentId());PageHelper.startPage(ordersPageQueryDTO.getPage(),ordersPageQueryDTO.getPageSize());Page<OrderVO>page=orderMapper.pageQuery(ordersPageQueryDTO);for(OrderVOorderVO:page.getResult()){List<OrderDetail>details=orderdetailMapper.getByOrderId(orderVO.getId());orderVO.setOrderDetailList(details);tryFillAddress(orderVO);}returnnewPageResult(page.getTotal(),page.getResult());}BaseContext.getCurrentId() 把当前登录用户 id 写入查询条件,保证用户只能看自己的订单。Mapper 中的用户 id、状态、订单号、手机号和下单时间均是可选条件,最后按下单时间倒序排列:
<selectid="pageQuery"resultType="com.sky.vo.OrderVO">select * from orders<where><iftest="number != null and number != ''">and number like concat('%', #{number}, '%')</if><iftest="userId != null">and user_id = #{userId}</if><iftest="status != null">and status = #{status}</if></where>order by order_time desc</select>2. 查询详情时补齐数据,并校验订单归属
用户请求订单详情时,不能直接用订单 id 返回数据。项目先验证订单是否属于当前用户,再将订单、订单明细和地址组装为 OrderVO:
privateOrdersrequireUserOrder(Longid){Ordersorders=requireOrder(id);if(!orders.getUserId().equals(BaseContext.getCurrentId())){thrownewOrderBusinessException(MessageConstant.ORDER_NOT_FOUND);}returnorders;}publicOrderVOorderDetail(Longid){returntoOrderVO(requireUserOrder(id));}这一步既是业务校验,也是接口数据隔离。测试时可用 A 用户的 token 请求 B 用户的订单详情,预期应返回订单不存在或业务异常,而不是暴露订单内容。
3. 取消订单与再来一单
当前项目的取消逻辑会校验订单归属,将状态改为已取消并记录取消时间;当支付状态为已支付时,会进入退款处理入口。当前代码以日志模拟退款申请,文章不把它描述为真实微信退款已经完成。
再来一单不重新创建订单,而是将原订单明细转换为购物车数据,再批量插入购物车表:
publicvoidrepetition(Longid){LonguserId=BaseContext.getCurrentId();List<OrderDetail>details=orderdetailMapper.getByOrderId(id);List<ShoppingCart>carts=details.stream().map(detail->{ShoppingCartcart=newShoppingCart();BeanUtils.copyProperties(detail,cart,"id");cart.setUserId(userId);cart.setCreateTime(LocalDateTime.now());returncart;}).collect(Collectors.toList());shoppingCartMapper.insertBatch(carts);}复制时排除原订单明细 id,避免把旧记录的主键带入购物车。接口返回成功后,前端跳转购物车即可让用户确认数量、口味和地址,再重新提交订单。
三、商家端订单管理:围绕状态处理订单
商家端接口前缀为 /admin/order,包含订单搜索、状态数量统计、详情查询和订单状态处理。
1. 搜索与状态数量统计
订单搜索复用 OrdersPageQueryDTO,支持订单号、手机号、状态、开始时间和结束时间等条件。分页结果中的每条订单还会补充菜品名称和数量,供管理端列表直接展示。
状态统计则按待接单、待派送和派送中三种状态分别查询:
publicOrderStatisticsVOstatistics(){OrderStatisticsVOresult=newOrderStatisticsVO();result.setToBeConfirmed(orderMapper.countByStatus(Orders.TO_BE_CONFIRMED));result.setConfirmed(orderMapper.countByStatus(Orders.CONFIRMED));result.setDeliveryInProgress(orderMapper.countByStatus(Orders.DELIVERY_IN_PROGRESS));returnresult;}对应 SQL 很简单,但状态常量必须和前端标签一致:
<selectid="countByStatus"resultType="java.lang.Integer">select count(*) from orders where status = #{status}</select>2. 拒单、派送和完成订单的校验差异
拒单只允许待接单订单操作;已支付订单需要进入退款入口,并记录拒单原因和取消时间:
publicvoidreject(OrdersRejectionDTOdto){OrdersordersDB=orderMapper.getById(dto.getId());if(ordersDB==null||!ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)){thrownewOrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);}if(ordersDB.getPayStatus().equals(Orders.PAID)){log.info("申请退款:{}",ordersDB.getNumber());}orderMapper.update(Orders.builder().id(ordersDB.getId()).status(Orders.CANCELLED).rejectionReason(dto.getRejectionReason()).cancelTime(LocalDateTime.now()).build());}派送订单必须从已接单状态进入派送中,完成订单必须从派送中进入已完成。把每个动作的前置状态写死在服务层,订单流转才不会被任意接口跳过。
| 操作 | 允许的原状态 | 更新后的状态 | 额外信息 |
|---|---|---|---|
| 接单 | 待接单 | 已接单 | 无 |
| 拒单 | 待接单 | 已取消 | 拒单原因、取消时间;已支付时退款 |
| 派送 | 已接单 | 派送中 | 无 |
| 完成 | 派送中 | 已完成 | 无 |
| 商家取消 | 订单存在 | 已取消 | 取消时间;已支付时退款 |
四、下单前增加 5 公里配送范围校验
实战要求规定:收货地址距离门店超过 5 公里时,下单失败。当前项目将门店地址和百度地图 AK 放入环境配置,再在提交订单过程中调用地图 Web 服务。
公共配置文件只保留结构,真实门店地址和 AK 应放到开发环境配置中,提交博客时必须脱敏:
sky:shop:address:${sky.shop.address}baidu:ak:${sky.baidu.ak}服务层的处理顺序是:
- 调用地理编码接口,把门店地址转换成经纬度;
- 用同一接口解析用户完整收货地址;
- 调用驾车路线规划接口,得到路线距离;
- 当距离大于 5000 米时抛出“超出配送范围”异常;
- 只有校验通过,才插入订单和订单明细。
核心拦截点位于 submitOrder:
orders.setAddress(buildAddress(addressBook));orders.setUserId(userId);checkOutOfRange(orders.getAddress());orderMapper.insert(orders);checkOutOfRange 中先调用地理编码接口解析两个地址,再调用驾车路线规划接口。路线接口的起点和终点坐标格式是“纬度,经度”;项目解析首条路线的 distance 字段,并以米为单位与 5000 比较。
JSONObjectresult=jsonObject.getJSONObject("result");JSONArrayroutes=(JSONArray)result.get("routes");Integerdistance=(Integer)((JSONObject)routes.get(0)).get("distance");if(distance>5000){thrownewOrderBusinessException("超出配送范围");}地址解析或路线规划响应的 status 不为 0 时,当前代码会抛出“店铺地址解析失败”“收货地址解析失败”或“配送路线规划失败”。这样能阻止无效地址继续创建订单,也能让前端给出明确提示。
五、如何测试与排查
可以先通过 Swagger 接口文档逐个验证,再进行前后端联调。建议按以下顺序准备数据:
- 创建一笔已支付、待接单订单,验证商家接单、拒单和取消订单;
- 将订单接单后,验证派送;再将订单改为派送中,验证完成;
- 使用当前用户查询历史订单与详情,再换一个用户 token 验证订单归属校验;
- 调用再来一单,检查购物车中是否新增原订单的菜品和套餐明细;
- 分别使用门店附近和明显超出范围的收货地址提交订单,观察下单成功与“超出配送范围”异常。
排查配送校验时,优先检查门店地址是否配置、AK 是否有效、地址是否能被解析,以及路线响应中是否存在 result.routes。排查订单状态异常时,先查询数据库中的状态和支付状态,再确认调用的接口是否满足对应动作的前置状态。
总结
Day09 完成的是一条完整的订单处理链路:用户端能管理历史订单,商家端能按状态处理订单,系统在创建订单前还会根据实际驾车路线拦截超出范围的地址。
实现订单模块时,每一次状态变化都要有明确的前置条件、需要记录的字段和可验证的结果。