diff --git a/docs/TODO.md b/docs/TODO.md index 688f2be..54a7f0b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -999,7 +999,7 @@ admin 端對應:fleet-frontEnd PR #22(司機管理頁評價欄、訂單詳情評分卡)。 -## 🎫 T. App 端實跑回報的後端缺口(2026-07-29 記錄,**T1/T4 待處理,其餘已修**) +## 🎫 T. App 端實跑回報的後端缺口(2026-07-29 記錄,**只剩 T4 待產品拍板**) > 來源:fleet-app 的搶單/多裝置/弱網三輪 debug(app PR #62–#66)。 > 這些缺口**都是對真後端逐則印 payload 查證出來的**,但先前只寫在 App 的 TODO 裡, @@ -1024,13 +1024,23 @@ admin 端對應:fleet-frontEnd PR #22(司機管理頁評價欄、訂單詳 **待處理**: -- [ ] **T1. 接單/放棄失敗回 HTTP 200 + 文案**(應為 409)。 +- [x] **T1. 接單/取消失敗回 HTTP 200 + 文案 → 已改成 409**(2026-07-30 修)。 `AcceptRide` 對「已被別人接走」「非待命狀態」回的是 200+`{"message":"手慢了…"}`, 放棄不成時同樣。**後果**:App 若只看有沒有丟例外,沒搶到的司機會拿到一張 完整但**假的**行程卡(他會開去接一個不存在的乘客)。 App 端現行防線是「接單後一律重讀 `rides/active`、以後端有沒有這張單判定成敗」—— **多打一支 API 才換得到正確性**,且逾時時仍要再對帳一次。 改成 409 之後 App 那條退路可以留著(無害),但錯誤處理會單純很多。 + **改法**:新增四個 sentinel error(`ErrRideTaken`/`ErrDriverNotIdle`/ + `ErrRideStarted`/`ErrRideStateChanged`),service 不再回「(訊息, nil)」; + `readStatusForErr` 補上這四個 → 409(先前落到 default 會變 500, + 而這些文案是要**原樣顯示給使用者**的)。**文案一字未改**——LINE 與 App 都直接顯示它。 + LINE webhook 的客戶取消也改成回原文案,不再一律「請稍後再試」 + (那會讓人一直重試一件永遠不會成功的事)。 + **驗收**:新增 `dispatch_conflict_test.go` 三案(已被接走/非待命/狀態已變更)+ + 更新既有「已上車不可取消」那案;`go test ./internal/handler ./internal/notify` 綠。 + **實跑驗證**(本機 server,同 U4 的跑法):重複接單 → `409 {"error":"手慢了,這單已被其他司機接走"}`、 + 已上車後乘客取消 → `409 {"error":"行程已開始,無法取消"}`;改之前兩者都是 **200**。 - [x] **T2. 沒搶到的司機收不到任何事件**(PR #59,2026-07-29)。一輪 `dispatchRound` 會把 同一張單同時推給半徑內每一位待命司機,但 `ride.accepted` **只送給接到的那位**—— 其他人的全螢幕接單卡沒有任何東西收得掉,他得自己按下去拿到「手慢了」才會消失, diff --git a/internal/handler/line_webhook.go b/internal/handler/line_webhook.go index f7a5edf..3d1d38d 100644 --- a/internal/handler/line_webhook.go +++ b/internal/handler/line_webhook.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -102,8 +103,14 @@ func (h *LineWebhookHandler) handleMessage(c *gin.Context, event lineEvent) int6 if t := event.Message.Text; t == "取消" || t == "取消叫車" { msg, err := h.dispatch.CancelByCustomer(c.Request.Context(), event.Source.UserID) if err != nil { - msg = "取消失敗,請稍後再試" - log.Error().Err(err).Str("line_user_id", event.Source.UserID).Msg("客戶取消失敗") + // 「當下做不到」(行程已開始/狀態已變更)的文案要原樣回給客戶—— + // 回一句「請稍後再試」會讓他一直重試一件永遠不會成功的事。 + if errors.Is(err, service.ErrRideStarted) || errors.Is(err, service.ErrRideStateChanged) { + msg = err.Error() + } else { + msg = "取消失敗,請稍後再試" + log.Error().Err(err).Str("line_user_id", event.Source.UserID).Msg("客戶取消失敗") + } } _ = h.lineClient.ReplyText(c.Request.Context(), event.ReplyToken, msg) } diff --git a/internal/handler/ride.go b/internal/handler/ride.go index a731a22..712457a 100644 --- a/internal/handler/ride.go +++ b/internal/handler/ride.go @@ -154,13 +154,22 @@ func statusForErr(err error) int { return http.StatusConflict } -// readStatusForErr 乘客查詢/取消類錯誤對應 HTTP 狀態碼:無權限回 403、找不到回 404、其餘回 500 +// readStatusForErr 乘客查詢/取消類錯誤對應 HTTP 狀態碼:無權限回 403、找不到回 404、 +// 「當下做不到」回 409(T1)、其餘回 500。 +// +// 409 這一族的文案是要**原樣顯示給乘客**的(「行程已開始,無法取消」), +// 落到 500 會讓 App 顯示「伺服器發生錯誤」,乘客不知道自己該做什麼。 func readStatusForErr(err error) int { switch { case errors.Is(err, service.ErrForbidden): return http.StatusForbidden case errors.Is(err, service.ErrNotFound): return http.StatusNotFound + case errors.Is(err, service.ErrRideStarted), + errors.Is(err, service.ErrRideStateChanged), + errors.Is(err, service.ErrRideTaken), + errors.Is(err, service.ErrDriverNotIdle): + return http.StatusConflict default: return http.StatusInternalServerError } diff --git a/internal/service/customer_push_test.go b/internal/service/customer_push_test.go index cd3ab4f..84d2560 100644 --- a/internal/service/customer_push_test.go +++ b/internal/service/customer_push_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strconv" "sync" "testing" @@ -305,7 +306,9 @@ func newApprovedDriver( if err := drivers.UpdateStatus(driver.ID, status); err != nil { t.Fatalf("設定司機狀態失敗:%v", err) } - if err := drivers.UpdateVehicle(driver.ID, constants.VehicleTypeSedan, "PUSH-01"); err != nil { + // 車牌**唯一**:同一個測試裡建兩位司機時,寫死的車牌會被「此車牌已被其他司機使用」擋下。 + plate := fmt.Sprintf("T-%d", driver.ID) + if err := drivers.UpdateVehicle(driver.ID, constants.VehicleTypeSedan, plate); err != nil { t.Fatalf("設定車輛失敗:%v", err) } if err := drivers.UpdateVehicleReview(driver.ID, constants.VehicleReviewApproved, ""); err != nil { diff --git a/internal/service/dispatch.go b/internal/service/dispatch.go index ac01159..a4fd77e 100644 --- a/internal/service/dispatch.go +++ b/internal/service/dispatch.go @@ -432,7 +432,7 @@ func (s *DispatchService) cancelActiveRide( note string, ) (string, error) { if ride.Status == constants.RideStatusPickedUp { - return "行程已開始,無法取消", nil + return "", ErrRideStarted } from := ride.Status @@ -442,7 +442,7 @@ func (s *DispatchService) cancelActiveRide( return "", err } if !ok { - return "訂單狀態已變更,無法取消", nil + return "", ErrRideStateChanged } s.audit.record(ride.ID, statusPtr(from), constants.RideStatusCancelled, events.TypeRideCancelled, actorRole, actorID, note) @@ -542,7 +542,7 @@ func (s *DispatchService) AcceptRide(ctx context.Context, rideID, driverID int64 return "", err } if !ok { - return "手慢了,這單已被其他司機接走", nil + return "", ErrRideTaken } ride, err := s.rides.GetByID(rideID) @@ -552,7 +552,7 @@ func (s *DispatchService) AcceptRide(ctx context.Context, rideID, driverID int64 } if ride.Status != constants.RideStatusRequested && ride.Status != constants.RideStatusAssigned { s.redis.ReleaseRideLock(ctx, rideID) - return "手慢了,這單已被其他司機接走", nil + return "", ErrRideTaken } driver, err := s.drivers.FindByID(driverID) @@ -573,7 +573,7 @@ func (s *DispatchService) AcceptRide(ctx context.Context, rideID, driverID int64 } if driver.Status != constants.DriverStatusIdle { s.redis.ReleaseRideLock(ctx, rideID) - return "您目前無法接單(非待命狀態)", nil + return "", ErrDriverNotIdle } pickupLat, pickupLng, _ := s.rides.GetPickupCoords(rideID) diff --git a/internal/service/dispatch_cancel_by_customer_test.go b/internal/service/dispatch_cancel_by_customer_test.go index 9f6e1ef..ea62746 100644 --- a/internal/service/dispatch_cancel_by_customer_test.go +++ b/internal/service/dispatch_cancel_by_customer_test.go @@ -75,12 +75,11 @@ func TestCancelByCustomerID_已上車無法取消(t *testing.T) { } ride := newTestRide(t, rides, cust.ID, constants.RideStatusPickedUp) - msg, err := dispatch.CancelByCustomerID(context.Background(), cust.ID, ride.ID) - if err != nil { - t.Fatalf("預期無錯誤(以文字訊息拒絕),得到 %v", err) - } - if msg != "行程已開始,無法取消" { - t.Fatalf("預期拒絕訊息,得到 %q", msg) + // T1(2026-07-30):先前這裡回的是 (訊息, nil),HTTP 層因此送出 200—— + // 只看有沒有丟例外的客戶端會把「拒絕」當成「取消成功」。改回 sentinel error → 409。 + _, err = dispatch.CancelByCustomerID(context.Background(), cust.ID, ride.ID) + if !errors.Is(err, ErrRideStarted) { + t.Fatalf("預期 ErrRideStarted,得到 %v", err) } got, gerr := rides.GetByID(ride.ID) if gerr != nil { diff --git a/internal/service/dispatch_conflict_test.go b/internal/service/dispatch_conflict_test.go new file mode 100644 index 0000000..0aa8001 --- /dev/null +++ b/internal/service/dispatch_conflict_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "errors" + "testing" + + "line-fleet-dispatch/internal/constants" + lineclient "line-fleet-dispatch/internal/line" + "line-fleet-dispatch/internal/repository" +) + +// T1(2026-07-30):接單/取消「當下做不到」時,先前回的是 (訊息, nil), +// HTTP 層照樣送 200。**任何只看有沒有丟例外的客戶端都會把失敗當成功**—— +// 沒搶到的司機會拿到一張完整但假的行程卡,開去接一個不存在的乘客。 +// +// 這組測試釘住:這些情況一律回 sentinel error(handler 對應 409),文案不變。 + +func TestAcceptRide_已被接走回ErrRideTaken(t *testing.T) { + db := newServiceTestDB(t) + redisStore := newServiceTestRedis(t) + customers := repository.NewCustomerRepository(db) + drivers := repository.NewDriverRepository(db) + rides := repository.NewRideRepository(db) + dispatch := NewDispatchService(drivers, rides, customers, redisStore, + lineclient.NewClient(""), NewETAService(nil), NewDispatchSettings(3000, 5, 20, 1, 5), &fakePublisher{}) + + cust, err := customers.FindOrCreateByLineUserID("U_conflict_cust", "乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + winner := newApprovedDriver(t, drivers, "U_conflict_win", "搶到的", constants.DriverStatusIdle) + loser := newApprovedDriver(t, drivers, "U_conflict_lose", "沒搶到的", constants.DriverStatusIdle) + ride := newTestRide(t, rides, cust.ID, constants.RideStatusAssigned) + + ctx := context.Background() + if _, err := dispatch.AcceptRide(ctx, ride.ID, winner.ID, ""); err != nil { + t.Fatalf("第一位接單應成功:%v", err) + } + + _, err = dispatch.AcceptRide(ctx, ride.ID, loser.ID, "") + if !errors.Is(err, ErrRideTaken) { + t.Fatalf("沒搶到的應得到 ErrRideTaken,得到 %v", err) + } + // 文案不變——LINE 與 App 都直接顯示它。 + if err.Error() != "手慢了,這單已被其他司機接走" { + t.Errorf("文案被改動了:%q", err.Error()) + } +} + +func TestAcceptRide_非待命狀態回ErrDriverNotIdle(t *testing.T) { + db := newServiceTestDB(t) + redisStore := newServiceTestRedis(t) + customers := repository.NewCustomerRepository(db) + drivers := repository.NewDriverRepository(db) + rides := repository.NewRideRepository(db) + dispatch := NewDispatchService(drivers, rides, customers, redisStore, + lineclient.NewClient(""), NewETAService(nil), NewDispatchSettings(3000, 5, 20, 1, 5), &fakePublisher{}) + + cust, err := customers.FindOrCreateByLineUserID("U_conflict_idle_cust", "乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + // 車輛已核准但狀態是 OnTrip(例如手上還有一趟)。 + driver := newApprovedDriver(t, drivers, "U_conflict_ontrip", "忙碌司機", constants.DriverStatusOnTrip) + ride := newTestRide(t, rides, cust.ID, constants.RideStatusAssigned) + + _, err = dispatch.AcceptRide(context.Background(), ride.ID, driver.ID, "") + if !errors.Is(err, ErrDriverNotIdle) { + t.Fatalf("預期 ErrDriverNotIdle,得到 %v", err) + } +} + +// 取消一張「狀態已經變過」的訂單(這裡用已完成)→ ErrRideStateChanged。 +func TestCancel_狀態已變更回ErrRideStateChanged(t *testing.T) { + db := newServiceTestDB(t) + redisStore := newServiceTestRedis(t) + customers := repository.NewCustomerRepository(db) + drivers := repository.NewDriverRepository(db) + rides := repository.NewRideRepository(db) + dispatch := NewDispatchService(drivers, rides, customers, redisStore, + lineclient.NewClient(""), NewETAService(nil), NewDispatchSettings(3000, 5, 20, 1, 5), &fakePublisher{}) + + cust, err := customers.FindOrCreateByLineUserID("U_conflict_done", "乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + ride := newTestRide(t, rides, cust.ID, constants.RideStatusCompleted) + + _, err = dispatch.CancelByCustomerID(context.Background(), cust.ID, ride.ID) + if !errors.Is(err, ErrRideStateChanged) { + t.Fatalf("預期 ErrRideStateChanged,得到 %v", err) + } +} diff --git a/internal/service/errors.go b/internal/service/errors.go index 4ea7c5e..734debc 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -29,4 +29,15 @@ var ( ErrVehicleNotPending = errors.New("該司機車輛不在待審核狀態") // ErrRejectNoteRequired O5:退回必須附原因(司機要知道哪裡不對)。 ErrRejectNoteRequired = errors.New("退回車輛審核必須填寫原因") + + // 以下四個是「請求本身合法,但當下做不到」——一律 HTTP 409(T1,2026-07-30)。 + // + // **先前這些情況回的是 200 + 一句文案**,於是任何「只看有沒有丟例外」的客戶端 + // 都會把失敗當成成功:沒搶到的司機拿到一張完整但假的行程卡,開去接一個不存在的乘客; + // 乘客按了取消卻什麼也沒發生,畫面上那張單還在。 + // 文案原樣沿用(LINE 與 App 都直接顯示它),改變的只有狀態碼。 + ErrRideTaken = errors.New("手慢了,這單已被其他司機接走") + ErrDriverNotIdle = errors.New("您目前無法接單(非待命狀態)") + ErrRideStarted = errors.New("行程已開始,無法取消") + ErrRideStateChanged = errors.New("訂單狀態已變更,無法取消") )