From 349f1c55d32800e68fd89a93d5b3f150a89709c5 Mon Sep 17 00:00:00 2001 From: awei Date: Thu, 30 Jul 2026 15:54:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(dispatch):=20=E5=BE=8C=E5=8F=B0=E5=BC=B7?= =?UTF-8?q?=E5=88=B6=E5=8F=96=E6=B6=88=E7=9A=84=E7=8B=80=E6=85=8B=E8=A1=9D?= =?UTF-8?q?=E7=AA=81=E5=9B=9E=20409=EF=BC=8C=E4=B8=8D=E8=A6=81=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AdminHandler.CancelRide` 只把 ErrNotFound 判 404,其餘全丟 500。 於是兩種後台每天會遇到的正常情況都變成「伺服器錯誤」: 已上車無法取消(確認對話框自己就寫著這句),以及上一次其實已經取消成功 (回應遺失後操作者又按了一次)。 乘客端的 readStatusForErr 從 T1 起就把這一族判 409 並寫明理由, admin 這支是漏網的。 不是只換個數字:500 讓監控把例行操作當成故障,也讓前端無從分辨 「伺服器壞了」與「這件事已經生效了,重讀一次就對了」——後者正是 admin 該自動重新整理的訊號(同批的 admin PR 就是靠 409 做這件事)。 驗收:抽出 adminCancelStatus 純函式+5 案表格測試(含包過一層仍成立); 反向確認改回 500 只有那兩案 FAIL;gofmt/go vet/go build 乾淨。 真後端實跑:已取消再取消 409、已上車 409、不存在仍是 404。 Co-Authored-By: Claude Opus 5 --- docs/TODO.md | 29 +++++++++++++++ internal/handler/admin.go | 26 +++++++++++--- internal/handler/admin_cancel_status_test.go | 37 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 internal/handler/admin_cancel_status_test.go diff --git a/docs/TODO.md b/docs/TODO.md index 02a2022..6b2783f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1221,6 +1221,35 @@ FCM/APNs token 是「這台裝置上的這個 App」的識別,換人登入 --- +## 🚦 V. 後台強制取消的狀態衝突回 500(2026-07-30,admin 盤點時抓到) + +> 起因:admin 那側在盤點「寫入其實成功卻報失敗」的畫面 +> (見 [line-fleet-admin/docs/TODO.md](../../line-fleet-admin/docs/TODO.md)), +> 追到後端這支的錯誤分類上。 + +**問題**:`AdminHandler.CancelRide` 只把 `ErrNotFound` 判 404,其餘一律 **500**。 +於是兩種**後台每天都會遇到的正常情況**都變成「伺服器錯誤」: + +| 情境 | 錯誤 | 原本 | 現在 | +|---|---|---|---| +| 已上車的訂單(確認對話框自己就寫著這句) | `ErrRideStarted` | 500 | **409** | +| 上一次其實已經取消成功(回應遺失後又按一次) | `ErrRideStateChanged` | 500 | **409** | +| 找不到訂單 | `ErrNotFound` | 404 | 404(不變) | + +乘客端的 `readStatusForErr` 從 T1 起就把這一族判 409 並寫明理由,**admin 這支是漏網的**。 + +**為什麼不是只換個數字**:500 有兩個實際後果——監控把例行操作當成伺服器故障; +而前端無從分辨「伺服器壞了」與「這件事已經生效了,重讀一次就對了」, +後者正是 admin 該自動重新整理的訊號(admin PR 那側就是靠 409 做這件事)。 + +**驗收**:抽出 `adminCancelStatus(err)` 純函式 + 5 案表格測試(含「包過一層仍要成立」)。 +反向確認:把 409 改回 500 → 只有那兩案 FAIL。 +`gofmt`/`go vet`/`go build` 乾淨。 +**真後端實跑**:已取消的 #30 再取消 → 409;已上車的 #31 → 409 `行程已開始,無法取消`; +不存在的 #999999 → 404(沒有連帶改壞)。 + +--- + ## 下次任務 > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 8c79d5a..99805e4 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -608,6 +608,25 @@ func (h *AdminHandler) MarkMembershipInvoicePaid(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id": id, "paid": *req.Paid}) } +// adminCancelStatus 後台強制取消的錯誤 → HTTP 狀態碼。 +// +// 「當下做不到」是**狀態衝突(409)**,不是伺服器故障——與乘客端 `readStatusForErr` 同一條規則。 +// 這裡原本一律回 500,而這兩種都是後台每天會遇到的正常情況: +// 已上車無法取消(確認對話框自己就寫著這句),以及**上一次其實已經取消成功** +// (回應在網路上遺失、操作者又按了一次)。回 500 有兩個實際後果: +// 監控把例行操作當成伺服器故障,而前端無從分辨「伺服器壞了」與 +// 「這件事已經生效了,重讀一次就對了」——後者正是它該自動重新整理的訊號。 +func adminCancelStatus(err error) int { + switch { + case errors.Is(err, service.ErrNotFound): + return http.StatusNotFound + case errors.Is(err, service.ErrRideStarted), errors.Is(err, service.ErrRideStateChanged): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + // CancelRide POST /api/admin/rides/:id/cancel — 後台強制取消 func (h *AdminHandler) CancelRide(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -617,12 +636,11 @@ func (h *AdminHandler) CancelRide(c *gin.Context) { } msg, err := h.adminOps.CancelRideByAdmin(c.Request.Context(), id) if err != nil { - switch { - case errors.Is(err, service.ErrNotFound): + if errors.Is(err, service.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "找不到訂單"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return } + c.JSON(adminCancelStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": msg}) diff --git a/internal/handler/admin_cancel_status_test.go b/internal/handler/admin_cancel_status_test.go new file mode 100644 index 0000000..3acc67f --- /dev/null +++ b/internal/handler/admin_cancel_status_test.go @@ -0,0 +1,37 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "line-fleet-dispatch/internal/service" +) + +// 後台強制取消的錯誤分類:狀態衝突要回 409,不能全丟 500。 +// +// 為什麼值得一支測試釘住:這兩個錯誤是後台**每天**會遇到的正常情況 +// (已上車、上一次其實已經取消成功),回 500 會讓監控把例行操作當成故障, +// 也讓 admin 前端無法分辨「伺服器壞了」與「已經生效了,重讀就好」。 +func TestAdminCancelStatus_狀態衝突回409(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"已上車無法取消", service.ErrRideStarted, http.StatusConflict}, + {"狀態已變更(多半是上一次其實成功了)", service.ErrRideStateChanged, http.StatusConflict}, + // 包一層仍要成立——service 端未來若用 %w 加上下文,分類不能跟著失效 + {"包過一層的狀態衝突", fmt.Errorf("取消失敗: %w", service.ErrRideStateChanged), http.StatusConflict}, + {"找不到訂單", service.ErrNotFound, http.StatusNotFound}, + {"其餘真的是伺服器錯誤", errors.New("db connection lost"), http.StatusInternalServerError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := adminCancelStatus(tc.err); got != tc.want { + t.Fatalf("adminCancelStatus(%v) = %d,期望 %d", tc.err, got, tc.want) + } + }) + } +}