實戰內容
完成用户端歷史訂單模塊、商家端訂單管理模塊相關業務新功能開發和已有功能優化,具體任務列表如下:
新功能開發
用户端歷史訂單模塊:
* 查詢歷史訂單
* 查詢訂單詳情
* 取消訂單
* 再來一單
商家端訂單管理模塊:
* 訂單搜索
* 各個狀態的訂單數量統計
* 查詢訂單詳情
* 接單
* 拒單
* 取消訂單
* 派送訂單
* 完成訂單
我們來深入覆盤一下,這次不只看代碼,更要看代碼背後的思考過程和架構決策。
🚀 核心覆盤:從業務到架構的思考
一、 用户端歷史訂單模塊
1. 查詢歷史訂單
- 產品需求
- 用户需求: 用户想看“我的訂單”。這個列表會很長,所以必須分頁。
- UI需求: 用户不只想看訂單號,他們想看訂單裏的商品(圖片、名稱)。
- 功能需求: 用户希望能按“待付款”、“待派送”等狀態篩選。
- 架構決策:
- DTO vs. VO: 我們不能把數據庫實體
Orders直接丟給前端。前端需要的是Orders數據加上一個List<OrderDetail>(商品列表)。因此,我們設計了OrderVO(View Object),它專門為這個頁面封裝數據。 - 分頁實現: 手動寫
LIMIT和COUNT(*)很麻煩且容易出錯。我們選擇PageHelper庫,它通過AOP自動幫我們完成分頁,業務代碼更簡潔。 - 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$ 很小(如分頁 |
|
JOIN |
1 |
代碼複雜(SQL和 |
優(網絡開銷最小) |
$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. 取消訂單
- 背後的思考:
- 業務規則: 什麼時候用户可以取消?“待付款”和“待接單”時可以。如果“商家已接單”或“派送中”,用户就不能自己隨意取消了。
- 核心邏輯: 如果用户在“待接單”狀態(已付款)取消,系統必須自動退款。
- 架構決策:
- 狀態校驗: 這是業務的核心。在
Service層,第一步就是getById查出訂單,然後用if語句嚴格校驗訂單狀態 (ordersDB.getStatus() > 2)。不符合條件,立刻拋出業務異常 (OrderBusinessException),阻斷流程。 - 事務性: “取消訂單”和“退款”是一個業務上的事務。
userCancelById方法是一個整體。 - 解耦: 退款邏輯被封裝在
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. 再來一單
- 背後的思考:
- 用户需求: “再來一單”不是“再下一個同樣的訂單”,而是把原訂單的商品重新加入購物車,讓用户可以修改或直接結算。
- 架構決策:
- 數據轉換: 這是一個數據流轉的過程。
List<OrderDetail>(訂單明細) ->List<ShoppingCart>(購物車)。 - Stream API: 相比
for循環,使用 Java 8 的stream().map()語法更現代、更簡潔,意圖也更清晰。 - 批量插入: 絕對不能在循環裏
shoppingCartMapper.insert()。這會產生N次數據庫調用。我們必須使用批量插入。 - 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. 訂單搜索(條件查詢)
- 背後的思考:
- 商家需求: 商家需要一個非常強大的搜索功能。能按訂單號、手機號、狀態、時間範圍等任意組合查詢。
- 架構決策:
- DTO (Data Transfer Object):
OrdersPageQueryDTO被創建用來承載所有可能的搜索參數。這比在 Controller 方法上寫 6、7 個@RequestParam要乾淨得多。 - 動態SQL: 這是MyBatis的精髓。在
OrderMapper.xml中,我們使用<where>和<if test="...">標籤。 - Mapper複用:
pageQuery這個方法被設計得非常通用,它同時服務於用户端(userId)和管理端(phone,number,beginTime等)。這是高度複用的典範。 - Admin VO (數據摘要): 商家列表不需要商品圖片,但需要一個“菜品摘要”字符串(如 "宮保雞丁*1;...")。因此
OrderServiceImpl中getOrderDishesStr這個輔助方法被創建,它再次利用了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)。
- 架構決策:
- 單一職責: 每個狀態變更都是一個獨立的 Service 方法(
confirm,rejection,delivery,complete)。 - 嚴格校驗: 每個方法的第一件事,就是校驗前置狀態。
delivery(派送):必須校驗訂單是CONFIRMED(3) 狀態。complete(完成):必須校驗訂單是DELIVERY_IN_PROGRESS(4) 狀態。
- 副作用處理:
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)時進行攔截,而不是等商家“拒單”。
- 架構決策:
- 外部API: 距離計算是複雜功能,我們不自己實現,而是集成百度地圖API。
- 配置解耦: API的
ak(密鑰)和shopAddress(店鋪地址)是配置,必須寫入application.yml,使用@Value注入。嚴禁硬編碼。 - 三步調用: 整個
checkOutOfRange邏輯很清晰:
- Step 1: 地址 -> 座標(店鋪)。 調用 Geocoding API。
- Step 2: 地址 -> 座標(用户)。 再次調用 Geocoding API。
- Step 3: 座標 -> 距離。 調用 路線規劃 API。
- 異常處理:
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!