實戰內容

完成用户端歷史訂單模塊、商家端訂單管理模塊相關業務新功能開發和已有功能優化,具體任務列表如下:

 新功能開發

用户端歷史訂單模塊:

* 查詢歷史訂單
* 查詢訂單詳情
* 取消訂單
* 再來一單

商家端訂單管理模塊:

* 訂單搜索
* 各個狀態的訂單數量統計
* 查詢訂單詳情

* 接單
* 拒單
* 取消訂單
* 派送訂單
* 完成訂單

我們來深入覆盤一下,這次不只看代碼,更要看代碼背後的思考過程架構決策


🚀 核心覆盤:從業務到架構的思考

一、 用户端歷史訂單模塊

1. 查詢歷史訂單
  • 產品需求
  • 用户需求: 用户想看“我的訂單”。這個列表會很長,所以必須分頁
  • UI需求: 用户不只想看訂單號,他們想看訂單裏的商品(圖片、名稱)。
  • 功能需求: 用户希望能按“待付款”、“待派送”等狀態篩選。
  • 架構決策:
  1. DTO vs. VO: 我們不能把數據庫實體 Orders 直接丟給前端。前端需要的是 Orders 數據加上一個 List<OrderDetail>(商品列表)。因此,我們設計了 OrderVO (View Object),它專門為這個頁面封裝數據。
  2. 分頁實現: 手動寫 LIMITCOUNT(*) 很麻煩且容易出錯。我們選擇 PageHelper 庫,它通過AOP自動幫我們完成分頁,業務代碼更簡潔。
  3. N+1 查詢問題:OrderServiceImpl 中,我們先用 1 次SQL查出當頁的訂單列表(Page<Orders>)。然後,我們遍歷這個列表,在循環中為每個訂單再去查詢它的商品詳情(orderDetailMapper.getByOrderId(orderId))。
  • 為什麼這麼做? 這就是經典的 "N+1 查詢"。如果一頁有10個訂單,就需要 1 + 10 = 11 次SQL。
  • 權衡: 在分頁場景下,N的值(即pageSize)很小(比如10),這對數據庫的壓力完全可以接受。相比之下,寫一個複雜的JOIN查詢並處理MyBatis的collection一對多映射要複雜得多。因此,為了代碼簡潔和可讀性,N+1是此處一個合理的“妥協”。

方案

SQL 次數

複雜性

性能(N值大時)

適用場景

N+1

$1 + N$

代碼簡單(Service層循環)

(大量網絡/DB開銷)

$N$ 很小(如分頁 pageSize <= 15),追求開發速度。

JOIN

1

代碼複雜(SQL和ResultMap複雜)

(網絡開銷最小)

$N$ 較大,或對性能有嚴格要求。

  • 核心代碼 (OrderServiceImpl.pageQuery4User):

這段代碼清晰地展示了“N+1”模式。Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO); 是第1次查詢。for 循環中的 orderDetailMapper.getByOrderId(orderId) 是N次查詢。

public PageResult pageQuery4User(int pageNum, int pageSize, Integer status) {
    // 1. 設置分頁(由PageHelper實現)
    PageHelper.startPage(pageNum, pageSize);

    OrdersPageQueryDTO ordersPageQueryDTO = new OrdersPageQueryDTO();
    ordersPageQueryDTO.setUserId(BaseContext.getCurrentId());
    ordersPageQueryDTO.setStatus(status);

    // 2. (第1次查詢): 分頁條件查詢
    Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);

    List<OrderVO> list = new ArrayList();

    // 3. (N次查詢): 遍歷訂單,封裝VO
    if (page != null && page.getTotal() > 0) {
        for (Orders orders : page) {
            Long orderId = orders.getId();// 訂單id

            // 4. (第N次查詢): 根據訂單id查詢訂單明細
            List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId);

            // 5. 組裝VO
            OrderVO orderVO = new OrderVO();
            BeanUtils.copyProperties(orders, orderVO);
            orderVO.setOrderDetailList(orderDetails);

            list.add(orderVO);
        }
    }
    return new PageResult(page.getTotal(), list);
}
2. 取消訂單
  • 背後的思考:
  • 業務規則: 什麼時候用户可以取消?“待付款”和“待接單”時可以。如果“商家已接單”或“派送中”,用户就不能自己隨意取消了。
  • 核心邏輯: 如果用户在“待接單”狀態(已付款)取消,系統必須自動退款
  • 架構決策:
  1. 狀態校驗: 這是業務的核心。在Service層,第一步就是 getById 查出訂單,然後用 if 語句嚴格校驗訂單狀態 (ordersDB.getStatus() > 2)。不符合條件,立刻拋出業務異常 (OrderBusinessException),阻斷流程。
  2. 事務性: “取消訂單”和“退款”是一個業務上的事務。userCancelById 方法是一個整體。
  3. 解耦: 退款邏輯被封裝在 weChatPayUtil.refund() 中。OrderService 不關心退款的具體實現,只關心調用
  • 核心代碼 (OrderServiceImpl.userCancelById):

重點看 if (ordersDB.getStatus() > 2)狀態校驗if (ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED))退款邏輯

public void userCancelById(Long id) throws Exception {
    // 1. 查出訂單
    Orders ordersDB = orderMapper.getById(id);

    // 2. 校驗訂單是否存在
    if (ordersDB == null) {
        throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
    }

    // 3. (核心)狀態校驗:已接單(3)或之後的都不能取消
    if (ordersDB.getStatus() > 2) {
        throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
    }

    Orders orders = new Orders();
    orders.setId(ordersDB.getId());

    // 4. (核心)退款邏輯:如果處於"待接單"(2)狀態,説明已付款,需要退款
    if (ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
        //調用微信支付退款接口
        weChatPayUtil.refund(
                ordersDB.getNumber(), //商户訂單號
                ordersDB.getNumber(), //商户退款單號
                new BigDecimal(0.01),//退款金額,單位 元
                new BigDecimal(0.01));//原訂單金額

        //支付狀態修改為 退款
        orders.setPayStatus(Orders.REFUND);
    }

    // 5. 更新訂單狀態
    orders.setStatus(Orders.CANCELLED);
    orders.setCancelReason("用户取消");
    orders.setCancelTime(LocalDateTime.now());
    orderMapper.update(orders);
}
3. 再來一單
  • 背後的思考:
  • 用户需求: “再來一單”不是“再下一個同樣的訂單”,而是把原訂單的商品重新加入購物車,讓用户可以修改或直接結算。
  • 架構決策:
  1. 數據轉換: 這是一個數據流轉的過程。List<OrderDetail> (訂單明細) -> List<ShoppingCart> (購物車)。
  2. Stream API: 相比 for 循環,使用 Java 8 的 stream().map() 語法更現代、更簡潔,意圖也更清晰。
  3. 批量插入: 絕對不能在循環裏 shoppingCartMapper.insert()。這會產生N次數據庫調用。我們必須使用批量插入
  4. MyBatis <foreach>ShoppingCartMapper.xml 中,使用 <foreach> 標籤動態拼接成一條 INSERT ... VALUES (...), (...), (...) SQL語句。這是最高效的批量插入方式,一次網絡IO,一條SQL,這也是非常常見的sql優化手段。
  • 核心代碼 (OrderServiceImpl.repetition):

重點看 stream().map() 的數據轉換過程。

public void repetition(Long id) {
    // 1. 查詢原訂單詳情
    List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);

    // 2. (核心)數據轉換:OrderDetail -> ShoppingCart
    List<ShoppingCart> shoppingCartList = orderDetailList.stream().map(x -> {
        ShoppingCart shoppingCart = new ShoppingCart();

        // 3. 複製屬性,注意"id"不復制,使用新id
        BeanUtils.copyProperties(x, shoppingCart, "id");
        // 4. 設置當前用户和時間
        shoppingCart.setUserId(BaseContext.getCurrentId());
        shoppingCart.setCreateTime(LocalDateTime.now());

        return shoppingCart;
    }).collect(Collectors.toList());

    // 5. (核心)批量插入購物車
    shoppingCartMapper.insertBatch(shoppingCartList);
}
  • 核心代碼 (ShoppingCartMapper.xml):

<foreach> 標籤是實現批量插入的關鍵。

<insert id="insertBatch" parameterType="list">
    insert into shopping_cart
    (name, image, user_id, dish_id, setmeal_id, dish_flavor, number, amount, create_time)
    values
    <foreach collection="shoppingCartList" item="sc" separator=",">
        (#{sc.name},#{sc.image},#{sc.userId},#{sc.dishId},#{sc.setmealId},#{sc.dishFlavor},#{sc.number},#{sc.amount},#{sc.createTime})
    </foreach>
</insert>

二、 商家端訂單管理模塊

1. 訂單搜索(條件查詢)
  • 背後的思考:
  • 商家需求: 商家需要一個非常強大的搜索功能。能按訂單號、手機號、狀態、時間範圍等任意組合查詢。
  • 架構決策:
  1. DTO (Data Transfer Object): OrdersPageQueryDTO 被創建用來承載所有可能的搜索參數。這比在 Controller 方法上寫 6、7 個 @RequestParam 要乾淨得多。
  2. 動態SQL: 這是MyBatis的精髓。在 OrderMapper.xml 中,我們使用 <where><if test="..."> 標籤。
  3. Mapper複用: pageQuery 這個方法被設計得非常通用,它同時服務於用户端(userId)和管理端(phone, number, beginTime等)。這是高度複用的典範。
  4. Admin VO (數據摘要): 商家列表不需要商品圖片,但需要一個“菜品摘要”字符串(如 "宮保雞丁*1;...")。因此 OrderServiceImplgetOrderDishesStr 這個輔助方法被創建,它再次利用了N+1查詢(同樣,N很小)來拼接這個摘要字符串,並塞入 OrderVO
  • 核心代碼 (OrderMapper.xml):

動態SQL的魅力在於,它會根據傳入的DTO自動拼接SQL。如果status為null,and status = #{status} 這行SQL就不會被生成

注意xml中的大於小於,和html類似要用轉義字符,因為< > 會被識別為標

<select id="pageQuery" resultType="Orders">
    select * from orders
    <where>
        <if test="number != null and number!=''">
            and number like concat('%',#{number},'%')
        </if>
        <if test="phone != null and phone!=''">
            and phone like concat('%',#{phone},'%')
        </if>
        <if test="userId != null">
            and user_id = #{userId}
        </if>
        <if test="status != null">
            and status = #{status}
        </if>
        <if test="beginTime != null">
            and order_time >= #{beginTime}
        </if>
        <if test="endTime != null">
            and order_time <= #{endTime}
        </if>
    </where>
    order by order_time desc
</select>
2. 商家端訂單生命週期 (接單、拒單、派送...)
  • 背後的思考:
  • 業務核心: 訂單管理的核心就是狀態流轉
  • 狀態機: 整個流程是一個嚴格的“狀態機”。訂單不能從“待接單”(2)直接跳到“派送中”(4),必須經過“已接單”(3)。
  • 架構決策:
  1. 單一職責: 每個狀態變更都是一個獨立的 Service 方法(confirm, rejection, delivery, complete)。
  2. 嚴格校驗: 每個方法的第一件事,就是校驗前置狀態
  • delivery (派送):必須校驗訂單是 CONFIRMED (3) 狀態。
  • complete (完成):必須校驗訂單是 DELIVERY_IN_PROGRESS (4) 狀態。
  1. 副作用處理: rejection (拒單) 和 userCancel 邏輯一樣,必須檢查 payStatus,如果已支付,必須觸發退款
  • 核心代碼 (OrderServiceImpl.delivery):

這個方法是狀態機校驗的典型代表。

public void delivery(Long id) {
    // 1. 查詢訂單
    Orders ordersDB = orderMapper.getById(id);

    // 2. (核心) 狀態校驗:必須是 "已接單"(3) 狀態才能派送
    if (ordersDB == null || !ordersDB.getStatus().equals(Orders.CONFIRMED)) {
        throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
    }

    Orders orders = new Orders();
    orders.setId(ordersDB.getId());

    // 3. 更新狀態
    orders.setStatus(Orders.DELIVERY_IN_PROGRESS);

    orderMapper.update(orders);
}
  • 核心代碼 (OrderServiceImpl.rejection):

拒單邏輯更復雜,它同時包含狀態校驗退款(副作用)

public void rejection(OrdersRejectionDTO ordersRejectionDTO) throws Exception {
    // 1. 查詢訂單
    Orders ordersDB = orderMapper.getById(ordersRejectionDTO.getId());

    // 2. (核心) 狀態校驗:必須是 "待接單"(2) 狀態才能拒單
    if (ordersDB == null || !ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
        throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
    }

    //支付狀態
    Integer payStatus = ordersDB.getPayStatus();
    if (payStatus == Orders.PAID) {
        // 3. (核心) 副作用:用户已支付,需要退款
        String refund = weChatPayUtil.refund(...);
        log.info("申請退款:{}", refund);
    }

    // 4. 更新狀態
    Orders orders = new Orders();
    orders.setId(ordersDB.getId());
    orders.setStatus(Orders.CANCELLED);
    orders.setRejectionReason(ordersRejectionDTO.getRejectionReason());
    orders.setCancelTime(LocalDateTime.now());

    orderMapper.update(orders);
}

三、 校驗收貨地址(集成第三方API)

  • 背後的思考:
  • 業務痛點: 商家不想接到5公里以外的訂單,因為無法配送。
  • 解決時機: 必須在用户提交訂單(submitOrder)時進行攔截,而不是等商家“拒單”。
  • 架構決策:
  1. 外部API: 距離計算是複雜功能,我們不自己實現,而是集成百度地圖API
  2. 配置解耦: API的ak(密鑰)和shopAddress(店鋪地址)是配置,必須寫入 application.yml,使用 @Value 注入。嚴禁硬編碼。
  3. 三步調用: 整個 checkOutOfRange 邏輯很清晰:
  • Step 1: 地址 -> 座標(店鋪)。 調用 Geocoding API。
  • Step 2: 地址 -> 座標(用户)。 再次調用 Geocoding API。
  • Step 3: 座標 -> 距離。 調用 路線規劃 API。
  1. 異常處理: if(distance > 5000),這是業務規則的最終體現。通過拋出OrderBusinessException,可以熔斷 submitOrder 的後續流程,訂單不會被創建,並給用户返回明確提示。
  • 核心代碼 (OrderServiceImpl.checkOutOfRange):

這段代碼是調用第三方API的標準流程:傳參、發請求、解析JSON、執行業務邏輯。

@Value("${sky.shop.address}")
private String shopAddress;
@Value("${sky.baidu.ak}")
private String ak;

private void checkOutOfRange(String address) {
    Map map = new HashMap();
    map.put("address",shopAddress);
    map.put("output","json");
    map.put("ak",ak);

    // 1. 獲取店鋪的經緯度座標
    String shopCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);
    // ... (JSON解析, 狀態"0"校驗)
    String shopLngLat = ...; // (店鋪經緯度)

    map.put("address",address);
    // 2. 獲取用户收貨地址的經緯度座標
    String userCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);
    // ... (JSON解析, 狀態"0"校驗)
    String userLngLat = ...; // (用户經緯度)

    map.put("origin",shopLngLat);
    map.put("destination",userLngLat);
    map.put("steps_info","0");

    // 3. 路線規劃,計算距離
    String json = HttpClientUtil.doGet("https://api.map.baidu.com/directionlite/v1/driving", map);
    // ... (JSON解析, 狀態"0"校驗)

    // 4. 數據解析
    JSONObject result = jsonObject.getJSONObject("result");
    JSONArray jsonArray = (JSONArray) result.get("routes");
    Integer distance = (Integer) ((JSONObject) jsonArray.get(0)).get("distance");

    // 5. (核心) 業務規則校驗
    if(distance > 5000){
        //配送距離超過5000米
        throw new OrderBusinessException("超出配送範圍");
    }

這就是第九天實戰的全部內容了,我們首次從業務拆分角度來實現簡單的crud,更多的是在選技術方面的思考。之後我會把外賣10,11,12天內容集合成兩個部分,然後進入redis應用篇!你的瀏覽和收藏就是我寫下去的最大動力,go head!