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



