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
29 changes: 29 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 這一輪做完了什麼(開工先看這段)**
Expand Down
26 changes: 22 additions & 4 deletions internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})
Expand Down
37 changes: 37 additions & 0 deletions internal/handler/admin_cancel_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading