Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 裡,
Expand All @@ -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` **只送給接到的那位**——
其他人的全螢幕接單卡沒有任何東西收得掉,他得自己按下去拿到「手慢了」才會消失,
Expand Down
11 changes: 9 additions & 2 deletions internal/handler/line_webhook.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"errors"
"net/http"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 10 additions & 1 deletion internal/handler/ride.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion internal/service/customer_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"context"
"fmt"
"strconv"
"sync"
"testing"
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions internal/service/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ func (s *DispatchService) cancelActiveRide(
note string,
) (string, error) {
if ride.Status == constants.RideStatusPickedUp {
return "行程已開始,無法取消", nil
return "", ErrRideStarted
}

from := ride.Status
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions internal/service/dispatch_cancel_by_customer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions internal/service/dispatch_conflict_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions internal/service/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("訂單狀態已變更,無法取消")
)
Loading