diff --git a/README.md b/README.md index 6704390..35c530b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ docker compose --profile simulator up -d simulator | GET | `/api/rides/:id/lost-items` | 查該行程最新協尋單(本趟乘客/司機/admin) | | GET | `/api/customer/lost-items`、`/api/driver/lost-items` | 乘客/司機的未結案協尋清單 | | POST | `/api/lost-items/:id/found` `pay` `return` `close` | 協尋狀態機:司機尋獲→乘客付處理費→司機歸還;未尋獲可結案 | +| GET/POST | `/api/customer/places`、`PUT/DELETE /api/customer/places/:id` | 常用地點(住家/公司/自訂);`kind=home/work` 每人各限一筆且為**覆蓋語意** | +| GET/POST | `/api/customer/scheduled-rides` | 預約行程:清單(`?upcoming=1` 只回未轉單)/建立;回應帶 `lead_minutes` | +| GET | `/api/customer/scheduled-rides/:id` | 單筆預約(取消撞 409 後用來重讀現況) | +| POST | `/api/customer/scheduled-rides/:id/cancel` | 取消預約;**已轉單回 409+該筆現況**(那張真訂單已在派單池,不能宣稱取消成功) | | GET | `/liff/` | 司機 LIFF 定位頁 | **後台 `/api/admin/*`**(帳密登入,角色 viewer/dispatcher/superadmin): diff --git a/cmd/server/main.go b/cmd/server/main.go index 29758af..f4b68a1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -77,6 +77,8 @@ func main() { rideStopRepo := repository.NewRideStopRepository(db) lostItemRepo := repository.NewLostItemRepository(db) rideRatingRepo := repository.NewRideRatingRepository(db) + savedPlaceRepo := repository.NewSavedPlaceRepository(db) + scheduledRideRepo := repository.NewScheduledRideRepository(db) // 軌跡分區維護:啟動時預建未來月分區 + 每日排程(避免跨月寫入失敗) if err := trackRepo.EnsureTrackPartitions(cfg.TrackPartitionMonthsAhead); err != nil { @@ -177,6 +179,14 @@ func main() { lostItemService := service.NewLostItemService(rideRepo, lostItemRepo, feeSettings, hub) // 協尋推播:這條流程是小時級的,雙方幾乎都不在 App 前景,只靠 WS 等於沒有通知。 lostItemService.SetAppNotifier(appNotify) + savedPlaceService := service.NewSavedPlaceService(savedPlaceRepo) + scheduledRideService := service.NewScheduledRideService(scheduledRideRepo) + + // 預約行程排程器:到點前把預約轉成真訂單,走的是與手動叫車同一支 CreateByCustomer。 + scheduledDispatcher := service.NewScheduledRideDispatcher(scheduledRideRepo, rideService) + scheduledCtx, stopScheduled := context.WithCancel(context.Background()) + defer stopScheduled() + go scheduledDispatcher.Run(scheduledCtx) if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) @@ -202,6 +212,8 @@ func main() { wsHandler := handler.NewWSHandler(hub, cfg.JWTSecret, cfg.WSWriteWaitSec, cfg.WSPongWaitSec, cfg.WSMaxMessageBytes) chatHandler := handler.NewChatHandler(chatService) lostItemHandler := handler.NewLostItemHandler(lostItemService) + savedPlaceHandler := handler.NewSavedPlaceHandler(savedPlaceService) + scheduledRideHandler := handler.NewScheduledRideHandler(scheduledRideService) // 後台:管理員 repo/service/handler,並依環境變數種一個管理員(僅在尚無 admin 時) adminRepo := repository.NewAdminRepository(db) @@ -262,6 +274,16 @@ func main() { customerAuthed.POST("/rides/:id/lost-items", lostItemHandler.CreateByCustomer) customerAuthed.GET("/customer/lost-items", lostItemHandler.ListByCustomer) customerAuthed.POST("/lost-items/:id/pay", lostItemHandler.Pay) + // 常用地點:住家/公司/自訂,供叫車與預約一鍵帶入起訖點。 + customerAuthed.GET("/customer/places", savedPlaceHandler.List) + customerAuthed.POST("/customer/places", savedPlaceHandler.Create) + customerAuthed.PUT("/customer/places/:id", savedPlaceHandler.Update) + customerAuthed.DELETE("/customer/places/:id", savedPlaceHandler.Delete) + // 預約行程:到點前由 ScheduledRideDispatcher 轉成真訂單。 + customerAuthed.GET("/customer/scheduled-rides", scheduledRideHandler.List) + customerAuthed.POST("/customer/scheduled-rides", scheduledRideHandler.Create) + customerAuthed.GET("/customer/scheduled-rides/:id", scheduledRideHandler.Get) + customerAuthed.POST("/customer/scheduled-rides/:id/cancel", scheduledRideHandler.Cancel) } // 受 JWT 保護:司機操作(driver_id 取自 token,不信任 body) diff --git a/db/migrations/000025_create_customer_saved_places.down.sql b/db/migrations/000025_create_customer_saved_places.down.sql new file mode 100644 index 0000000..25fa4fd --- /dev/null +++ b/db/migrations/000025_create_customer_saved_places.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS customer_saved_places; diff --git a/db/migrations/000025_create_customer_saved_places.up.sql b/db/migrations/000025_create_customer_saved_places.up.sql new file mode 100644 index 0000000..bd8d1e3 --- /dev/null +++ b/db/migrations/000025_create_customer_saved_places.up.sql @@ -0,0 +1,26 @@ +-- 常用地點:乘客把住家/公司/其他常去的地方存起來,叫車與預約時一鍵帶入。 +-- 座標與 rides 用同一套 geography(Point,4326),讓帶入後的建單路徑與地圖選點完全一致。 +CREATE TABLE IF NOT EXISTS customer_saved_places ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES customers(id), + -- kind:home/work/custom。前兩者是「語意化插槽」,UI 會給它們固定圖示與排序; + -- custom 則是使用者自訂的其他地點,數量不限。 + kind TEXT NOT NULL DEFAULT 'custom', + label TEXT NOT NULL, + address TEXT NOT NULL, + point geography(Point,4326) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT saved_place_kind_check CHECK (kind IN ('home', 'work', 'custom')), + CONSTRAINT saved_place_label_check CHECK (char_length(label) BETWEEN 1 AND 40), + CONSTRAINT saved_place_address_check CHECK (char_length(address) BETWEEN 1 AND 200) +); + +-- 住家與公司**各只能有一筆**(部分唯一索引,custom 不受限)。 +-- 這是 DB 層的最後防線:服務層改成「同 kind 已存在就覆蓋」,競態下才會撞到這裡。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_saved_place_customer_kind + ON customer_saved_places (customer_id, kind) WHERE kind <> 'custom'; + +-- 乘客端「我的常用地點」清單。 +CREATE INDEX IF NOT EXISTS idx_saved_place_customer + ON customer_saved_places (customer_id, id); diff --git a/db/migrations/000026_create_scheduled_rides.down.sql b/db/migrations/000026_create_scheduled_rides.down.sql new file mode 100644 index 0000000..1a50175 --- /dev/null +++ b/db/migrations/000026_create_scheduled_rides.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS scheduled_rides; diff --git a/db/migrations/000026_create_scheduled_rides.up.sql b/db/migrations/000026_create_scheduled_rides.up.sql new file mode 100644 index 0000000..99b92ee --- /dev/null +++ b/db/migrations/000026_create_scheduled_rides.up.sql @@ -0,0 +1,44 @@ +-- 預約司機:乘客先把「什麼時候、從哪到哪」記下來,到點前由背景排程器轉成真訂單進派單池。 +-- +-- **為什麼是獨立一張表、而不是在 rides 加一個 scheduled 狀態**: +-- rides 的既有查詢散布在派單池(status=requested)、乘客 active、歷史、報表、admin 列表, +-- 多塞一個「還不該被派單」的狀態進去,得逐一稽核每一支查詢有沒有把它排除掉—— +-- 漏一支就會出現「司機看到一張三天後才要出發的單」。獨立表對既有路徑是純新增、零風險。 +CREATE TABLE IF NOT EXISTS scheduled_rides ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES customers(id), + -- 乘客希望「上車」的時間。派單會比它更早發動(見 lead_time_minutes)。 + scheduled_at TIMESTAMPTZ NOT NULL, + pickup_point geography(Point,4326) NOT NULL, + pickup_address TEXT NOT NULL DEFAULT '', + dropoff_point geography(Point,4326), + dropoff_address TEXT NOT NULL DEFAULT '', + -- 與 rides.required_vehicle_type 同一組 code;'' =不指定。 + required_vehicle_type TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + -- pending:等待到點;dispatched:已轉成 ride_id 那張真訂單; + -- cancelled:乘客取消;failed:到點後重試多次仍建不出訂單(last_error 寫原因)。 + status TEXT NOT NULL DEFAULT 'pending', + ride_id BIGINT REFERENCES rides(id), + -- 轉單重試次數:乘客到點時可能還在別的行程上,這種情況要等下一輪再試,不是立刻失敗。 + attempt_count INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + dispatched_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT scheduled_ride_status_check + CHECK (status IN ('pending', 'dispatched', 'cancelled', 'failed')), + CONSTRAINT scheduled_ride_note_check CHECK (char_length(note) <= 200), + CONSTRAINT scheduled_ride_address_check CHECK (char_length(pickup_address) <= 200), + -- dispatched 一定要指得出是哪張訂單,否則乘客點進去看不到任何東西。 + CONSTRAINT scheduled_ride_dispatched_has_ride + CHECK (status <> 'dispatched' OR ride_id IS NOT NULL) +); + +-- 排程器每輪的掃描條件:status='pending' AND scheduled_at <= 界線。 +CREATE INDEX IF NOT EXISTS idx_scheduled_ride_due + ON scheduled_rides (status, scheduled_at); + +-- 乘客端「我的預約」清單(近的在前)。 +CREATE INDEX IF NOT EXISTS idx_scheduled_ride_customer + ON scheduled_rides (customer_id, scheduled_at DESC); diff --git a/docs/TODO.md b/docs/TODO.md index 6b2783f..a6fab23 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1250,8 +1250,142 @@ FCM/APNs token 是「這台裝置上的這個 App」的識別,換人登入 --- +## 🗓️ W. 預約行程+常用地點(2026-07-31,跨端新功能) + +> 需求:乘客可**預約未來的用車**,以及存下住家/公司等常去的地點供叫車與預約一鍵帶入。 +> App 端對應 [line-fleet-app PR #95](https://github.com/thothawei/fleet-app/pull/95)。 + +### W1. 兩張新表(migration 000025/000026) + +- `customer_saved_places`:`kind`(home/work/custom)+`label`/`address`/`point`。 + **home/work 每人各限一筆**(部分唯一索引 `uq_saved_place_customer_kind`),服務層為 + **upsert 語意**——「設定住家」該把住家換成新地址,不是回一句「你已經有住家了」。 +- `scheduled_rides`:`scheduled_at`+起訖點+`status`(pending/dispatched/cancelled/failed) + +`ride_id`(轉單後指向真訂單)+`attempt_count`/`last_error`。 + +**為什麼不是在 `rides` 加一個 `scheduled` 狀態**:`rides` 的查詢散布在派單池 +(`status='requested'`)、乘客 active、歷史、報表、admin 訂單列表——多塞一個「還不該被派單」 +的狀態進去,得逐一稽核每支查詢有沒有排除它,漏一支就是「司機看到一張三天後的單」。 +獨立表對既有路徑是純新增、零風險。 + +### W2. 到點轉單(`ScheduledRideDispatcher`) + +每 30 秒掃一次,把約定時間前 **15 分鐘**(`ScheduledRideLeadMinutes`)內的 pending 轉成真訂單。 +**轉單走的是與手動叫車完全同一支 `RideService.CreateByCustomer`**——派單、審計、車種驗證、 +「同時只能有一張進行中訂單」全部自動沿用。另寫一份建單邏輯的話,兩條路徑遲早長歪, +而且是預約那條先歪(沒人在旁邊看著)。 + +| 機制 | 防的是什麼 | +|---|---| +| 認領帶 `attempt_count` 樂觀鎖 | 多副本同時掃到同一筆 → 建出**兩張**要付錢的訂單 | +| 暫時性失敗維持 pending(上限 10 次) | 乘客當下還在另一趟行程上,那是「等下一輪」不是「永久失敗」 | +| 永久性失敗立刻判死 | 座標/車種無效重試幾次都一樣,佔著額度只會讓乘客到約定時間才發現沒車 | +| 過期 30 分鐘就作廢(`ScheduledRideExpiryGraceMinutes`) | **停機後重啟**會把積壓的過期預約全部派車(見 W4) | + +### W3. 端點(皆掛 `customerAuthed`) + +``` +GET/POST /api/customer/places +PUT/DELETE /api/customer/places/:id +GET/POST /api/customer/scheduled-rides # ?upcoming=1 只回未轉單;回應帶 lead_minutes +GET /api/customer/scheduled-rides/:id +POST /api/customer/scheduled-rides/:id/cancel +``` + +**取消已轉單的回 409+該筆現況**(不是單純的錯誤):那張真訂單已經在派單池裡, +司機可能正在開過來——App 要據此把畫面切成「已為你派車」並引導去取消訂單, +而不是顯示「取消失敗,請稍後再試」(同一個病在 V 那章的 admin 端抓過一次)。 + +### W4. 本批 debug 抓到的:停機後會把積壓的過期預約全部派車 + +`FindDue` 原本只有上界沒有下界。部署/當機/DB 不可用停了一段時間,重啟時 +**昨天早上的預約會今天下午開一台車到乘客家樓下**,而且他還得付那趟錢。 +修法是超過約定時間 30 分鐘就標 `failed` 並寫原因——標 failed 而不是留在 pending, +是因為留著它會永遠掛在乘客的「即將到來」,而那台車永遠不會來。 +先寫 `TestDispatcherSkipsLongExpiredSchedules` 取證看到它真的被轉單,才動手修。 + +### W5. 假資料 + +`scripts/seed_demo_data.sh`(示範乘客 `demo-customer-1`/`demo123456`)。 +**主路徑刻意走真實 HTTP API 而不是直接寫 DB**——塞資料的同時就端到端驗過了那些端點, +不會塞出一份「只有 SQL 造得出來、API 其實不接受」的資料。 +只有 `dispatched`/`failed` 非走 SQL 不可(排程器到點後才會產生,沒有 API 建得出來)。 + +### W6. 驗收 + +`go build`/`go vet` 乾淨;`internal/service` **完整套件 172 支全過、0 失敗** +(`ok ... 2911.952s`,真 PostGIS 容器,本機跑了 48 分鐘),其中 17 支是這批新增的; +`internal/handler` 完整套件也綠(136s)。另加 `scheduled_ride_route_shape_test.go`——新路由與既有 `/customer/rides/:id` 同層混用 +靜態段與參數段,gin 的路由衝突是**註冊當下 panic**:服務起不來但單元測試照樣全綠。 + +**反向驗證做了三次,其中兩次推翻了自己的假設**: +拔掉 `FindDue` 的時間條件 → `TestDispatcherOnlyPicksDueSchedules` 轉紅 ✅; +拔掉樂觀鎖 → `TestDispatcherIsIdempotent` **仍然綠**(它守的是循序重跑,第一輪改了狀態 +第二輪就撈不到,壓根沒走到認領那行);補的併發測試**第一版也是假的**(兩個 goroutine +各跑一次 `Tick`,先跑完的已改狀態,拔掉樂觀鎖連跑三次都綠), +改成餵兩份 `attempt_count=0` 的相同快照直接進 `dispatchOne` 才造得出真重疊 ✅。 + +### W6-1. 到點轉單的真環境實跑(2026-07-31,本機後端+PostGIS) + +不只有測試,整條在跑著的服務上驗過一次。塞一筆 5 分鐘後到點的預約(`id=7`), +排程器每 30 秒一輪,觀察到的軌跡: + +| 輪次 | `attempt_count` | 結果 | +|---|---|---| +| 1–2 | 1 → 2 | 撈到並認領,建單被 `已有進行中的訂單` 擋下 → **維持 pending**、寫 `last_error`、等下一輪 | +| 3(把擋路的示範訂單結掉之後) | 3 | **轉單成功**:`status=dispatched`、`ride_id=39`、`last_error` 清空 | + +交叉驗證訂單 39:`pickup_address`/`dropoff_address` 正是預約上填的那兩個字串, +`status=0`(requested,已進派單池)。 + +**這一跑同時證明了三件本來只有測試層證據的事**:到期條件真的撈得到、 +暫時性失敗真的是「等下一輪」而不是判死、以及成功後 `last_error` 真的會被清掉。 +順帶驗到 gin 路由沒有 panic(服務起得來),那是單元測試照樣全綠的失敗模式。 + +### W7. 這批沒做的(寫明條件) + +- **預約不支援多停靠點**:需要另一張 stops 快照表,而「預約一趟多人共乘」的需求還沒出現。 +- **預約沒有專屬推播**:轉單後走既有 ride 推播鏈路(「司機接單了」照樣會通知), + 缺的是「你的預約已轉為訂單」與「預約失敗」這兩則本身。 + **條件**:等 `failed` 真的在生產環境發生過再決定值不值得。 +- **admin 看不到預約**:表與 API 都在,admin UI 沒做。等營運說得出要對預約做什麼再開。 +- **司機端不知道自己接的是預約單**(自審時發現,本批刻意沒做):轉單後就是一張普通訂單, + 司機看不到「約定上車時間」。提前量 15 分鐘下多半剛好,但司機若 5 分鐘就到, + 乘客可能還沒下樓,而司機不知道該等。要做的話動的是 `rides` + (帶 `scheduled_at` 或來源標記)+司機端顯示,屬功能擴充不是 bug,故沒夾帶進這批。 + **條件**:等實際跑過幾趟預約單、司機真的回報「到太早不知道要不要等」再做。 + ## 下次任務 +> **🎯 2026-07-31 這一輪(W. 預約行程+常用地點)——開工先看這段** +> +> 跨端新功能,不是 debug 輪:兩張新表(migration 000025/000026)、9 支端點、 +> 一支到點轉單的背景排程器。PR [#70](https://github.com/thothawei/fleet-dispatch/pull/70) +> + App 端 [fleet-app #95](https://github.com/thothawei/fleet-app/pull/95)。 +> 決策理由與三次反向驗證的結果見上方 W 章。 +> +> **這一輪學到的兩條**: +> 1. **綠燈的測試不等於守得住的測試**。名字叫 `TestDispatcherIsIdempotent` 的那支, +> 把認領的樂觀鎖整個拔掉照樣綠(它守的是循序重跑,壓根沒走到認領那行); +> 補的併發測試第一版也是假的(兩個 goroutine 沒真重疊)。 +> **判準:把防線拔掉,測試會不會紅**——不會紅的那支,它的名字在說謊。 +> 競態測試還要多問:**重疊是造出來的,還是碰運氣?** +> 2. **本機跑 `go test ./...` 跑不完**(`internal/service` 有 172 支、每支起一個 PostGIS +> 容器,約一小時)。要驗改動請跑**受影響的 package**,別跑全部—— +> 這條先前記在坑卡上,這一輪再次踩到(等了 90 分鐘才發現它只是慢不是卡)。 +> CI 那支 `build-and-unit-test` 只花 1 分鐘,是因為 CI 環境跳過 testcontainers, +> **它綠不代表整合測試綠**。 +> +> **➡️ 下一輪候選**(都寫明了條件,別提前做): +> 1. **預約的通知缺口**——轉單後走既有 ride 推播鏈路(「司機接單了」會通知), +> 但「你的預約已轉為訂單」與「預約失敗」這兩則本身沒有。 +> **條件**:等 `failed` 真的在生產環境發生過。 +> 2. **admin 端看不到預約**——表與 API 都在,UI 沒做。 +> **條件**:等營運說得出要對預約做什麼(強制取消?改時間?)。 +> 3. **司機端不知道自己接的是預約單**(自審發現)——見 W7。 +> **條件**:等司機真的回報「到太早不知道要不要等」。 + + > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** > > 主題是「**推播這條通道從頭到尾打通**」,五支 PR 全部合併進 main: diff --git a/internal/constants/saved_place.go b/internal/constants/saved_place.go new file mode 100644 index 0000000..bf30e83 --- /dev/null +++ b/internal/constants/saved_place.go @@ -0,0 +1,26 @@ +package constants + +// 常用地點種類(customer_saved_places.kind)。 +// +// home/work 是「語意化插槽」:每位乘客各只能有一筆(DB 部分唯一索引把關), +// App 端據此給固定圖示與置頂排序。custom 則是其他自訂地點,數量不限。 +// **App 不該去比對 label 文字**來判斷是不是住家——label 是使用者可改的顯示名稱。 +const ( + SavedPlaceKindHome = "home" + SavedPlaceKindWork = "work" + SavedPlaceKindCustom = "custom" +) + +// IsValidSavedPlaceKind 白名單檢查;DB CHECK 是最後防線,但錯誤要在服務層變成 400。 +func IsValidSavedPlaceKind(kind string) bool { + switch kind { + case SavedPlaceKindHome, SavedPlaceKindWork, SavedPlaceKindCustom: + return true + } + return false +} + +// IsSlotSavedPlaceKind 是否為「每人限一筆」的插槽種類。 +func IsSlotSavedPlaceKind(kind string) bool { + return kind == SavedPlaceKindHome || kind == SavedPlaceKindWork +} diff --git a/internal/constants/scheduled_ride.go b/internal/constants/scheduled_ride.go new file mode 100644 index 0000000..4e37f89 --- /dev/null +++ b/internal/constants/scheduled_ride.go @@ -0,0 +1,48 @@ +package constants + +// 預約行程狀態(scheduled_rides.status) +const ( + // ScheduledRideStatusPending 等待到點;唯一可被排程器認領、也是唯一可取消的狀態。 + ScheduledRideStatusPending = "pending" + // ScheduledRideStatusDispatched 已轉成真訂單(ride_id 指向它),後續走既有派單鏈路。 + ScheduledRideStatusDispatched = "dispatched" + // ScheduledRideStatusCancelled 乘客取消。 + ScheduledRideStatusCancelled = "cancelled" + // ScheduledRideStatusFailed 到點後重試上限內仍建不出訂單,last_error 記原因。 + ScheduledRideStatusFailed = "failed" +) + +const ( + // ScheduledRideLeadMinutes 提前發動派單的分鐘數。 + // + // 預約 08:00 的車,08:00 才開始找司機就注定遲到——派單本身要時間(找車、司機接受、 + // 開過來)。提前這麼多分鐘把它丟進派單池,讓司機有機會在約定時間前抵達。 + ScheduledRideLeadMinutes = 15 + + // ScheduledRideMaxAttempts 轉單重試上限。 + // + // 到點時乘客可能還在另一趟行程上(後端擋「同時只能有一張進行中訂單」), + // 那不是永久失敗,等下一輪再試即可。試滿這個次數仍不成,才標 failed 讓乘客看到原因。 + ScheduledRideMaxAttempts = 10 + + // ScheduledRideMinLeadMinutes 建立預約時,距現在至少要有的分鐘數。 + // 比 lead time 更短的預約一建立就該立刻派單,那使用者要的其實是「現在叫車」。 + ScheduledRideMinLeadMinutes = 20 + + // ScheduledRideMaxDaysAhead 最遠可預約的天數。 + ScheduledRideMaxDaysAhead = 30 + + // ScheduledRideExpiryGraceMinutes 過了約定時間多久之後,這筆預約就不再轉單。 + // + // **正常運作下永遠碰不到這個界線**——提前量是 15 分鐘,早在約定時間前就轉完了。 + // 它防的是排程器停過一段時間(部署、當機、DB 不可用):重啟時如果照樣把積壓的 + // 過期預約全部轉成真訂單,乘客昨天早上預約的車今天下午會突然開到他家樓下。 + // 等了半小時還沒車的乘客,早就自己叫車或改搭別的了。 + ScheduledRideExpiryGraceMinutes = 30 +) + +// IsScheduledRideCancellable 只有還沒轉單的預約可以取消。 +// 已轉單的要取消的是**那張真訂單**(走 cancel-by-customer),不是這筆預約紀錄。 +func IsScheduledRideCancellable(status string) bool { + return status == ScheduledRideStatusPending +} diff --git a/internal/handler/saved_place.go b/internal/handler/saved_place.go new file mode 100644 index 0000000..6b8b670 --- /dev/null +++ b/internal/handler/saved_place.go @@ -0,0 +1,119 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "line-fleet-dispatch/internal/middleware" + "line-fleet-dispatch/internal/service" +) + +// SavedPlaceHandler 乘客常用地點(住家/公司/自訂)。 +type SavedPlaceHandler struct { + places *service.SavedPlaceService +} + +func NewSavedPlaceHandler(places *service.SavedPlaceService) *SavedPlaceHandler { + return &SavedPlaceHandler{places: places} +} + +// savedPlaceBody 新增/更新的請求形狀。 +type savedPlaceBody struct { + Kind string `json:"kind"` + Label string `json:"label"` + Address string `json:"address"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +// List GET /api/customer/places(乘客 JWT):我的常用地點(住家 → 公司 → 其他)。 +func (h *SavedPlaceHandler) List(c *gin.Context) { + places, err := h.places.List(middleware.CustomerIDFromCtx(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"places": places}) +} + +// Create POST /api/customer/places(乘客 JWT):新增常用地點。 +// kind=home/work 為覆蓋語意(已存在就更新那一筆),custom 才是每次新增。 +func (h *SavedPlaceHandler) Create(c *gin.Context) { + var body savedPlaceBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "參數錯誤"}) + return + } + place, err := h.places.Create(middleware.CustomerIDFromCtx(c), service.SavePlaceInput{ + Kind: body.Kind, + Label: body.Label, + Address: body.Address, + Lat: body.Lat, + Lng: body.Lng, + }) + if err != nil { + c.JSON(savedPlaceStatusForErr(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"place": place}) +} + +// Update PUT /api/customer/places/:id(乘客 JWT):改名稱/地址/座標(kind 不可改)。 +func (h *SavedPlaceHandler) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 格式錯誤"}) + return + } + var body savedPlaceBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "參數錯誤"}) + return + } + place, err := h.places.Update(middleware.CustomerIDFromCtx(c), id, service.SavePlaceInput{ + Label: body.Label, + Address: body.Address, + Lat: body.Lat, + Lng: body.Lng, + }) + if err != nil { + c.JSON(savedPlaceStatusForErr(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"place": place}) +} + +// Delete DELETE /api/customer/places/:id(乘客 JWT)。 +func (h *SavedPlaceHandler) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 格式錯誤"}) + return + } + if err := h.places.Delete(middleware.CustomerIDFromCtx(c), id); err != nil { + c.JSON(savedPlaceStatusForErr(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func savedPlaceStatusForErr(err error) int { + switch { + case errors.Is(err, service.ErrInvalidPlaceKind), + errors.Is(err, service.ErrEmptyPlaceLabel), + errors.Is(err, service.ErrPlaceLabelTooLong), + errors.Is(err, service.ErrEmptyPlaceAddress), + errors.Is(err, service.ErrPlaceAddressTooLong), + errors.Is(err, service.ErrInvalidCoords): + return http.StatusBadRequest + case errors.Is(err, service.ErrTooManyPlaces): + return http.StatusConflict + case errors.Is(err, service.ErrNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/internal/handler/scheduled_ride.go b/internal/handler/scheduled_ride.go new file mode 100644 index 0000000..4a524a7 --- /dev/null +++ b/internal/handler/scheduled_ride.go @@ -0,0 +1,143 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/middleware" + "line-fleet-dispatch/internal/service" +) + +// ScheduledRideHandler 預約行程(乘客端)。 +type ScheduledRideHandler struct { + schedules *service.ScheduledRideService +} + +func NewScheduledRideHandler(schedules *service.ScheduledRideService) *ScheduledRideHandler { + return &ScheduledRideHandler{schedules: schedules} +} + +// List GET /api/customer/scheduled-rides?upcoming=1(乘客 JWT)。 +// upcoming=1 只回還沒轉單的預約(首頁那張卡用);預設回全部(預約紀錄頁用)。 +func (h *ScheduledRideHandler) List(c *gin.Context) { + upcoming := c.Query("upcoming") == "1" + rows, err := h.schedules.List(middleware.CustomerIDFromCtx(c), upcoming) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "scheduled_rides": rows, + // 把兩個時間參數一起回給 App。 + // + // lead_minutes:「我們會提前這麼久幫你找車」這句話要跟後端的實際行為一致。 + // min_lead_minutes:App 的時間選擇器要用它擋下太近的預約——**寫死在 App 端的話**, + // 後端把門檻調高之後,App 仍會讓乘客選一個註定被 400 拒絕的時間, + // 而他要填完整張表才會知道。 + "lead_minutes": constants.ScheduledRideLeadMinutes, + "min_lead_minutes": constants.ScheduledRideMinLeadMinutes, + }) +} + +// Get GET /api/customer/scheduled-rides/:id(乘客 JWT)。 +func (h *ScheduledRideHandler) Get(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 格式錯誤"}) + return + } + row, err := h.schedules.Get(middleware.CustomerIDFromCtx(c), id) + if err != nil { + c.JSON(scheduledRideStatusForErr(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"scheduled_ride": row}) +} + +// Create POST /api/customer/scheduled-rides(乘客 JWT):建立預約。 +func (h *ScheduledRideHandler) Create(c *gin.Context) { + var body struct { + ScheduledAt time.Time `json:"scheduled_at"` + PickupLat float64 `json:"pickup_lat"` + PickupLng float64 `json:"pickup_lng"` + PickupAddress string `json:"pickup_address"` + DropoffAddress string `json:"dropoff_address"` + DropoffLat *float64 `json:"dropoff_lat"` + DropoffLng *float64 `json:"dropoff_lng"` + RequiredVehicleType string `json:"required_vehicle_type"` + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "參數錯誤"}) + return + } + row, err := h.schedules.Create(middleware.CustomerIDFromCtx(c), service.ScheduleRideInput{ + ScheduledAt: body.ScheduledAt, + PickupLat: body.PickupLat, + PickupLng: body.PickupLng, + PickupAddress: body.PickupAddress, + DropoffAddress: body.DropoffAddress, + DropoffLat: body.DropoffLat, + DropoffLng: body.DropoffLng, + RequiredVehicleType: body.RequiredVehicleType, + Note: body.Note, + }) + if err != nil { + c.JSON(scheduledRideStatusForErr(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "scheduled_ride": row, + "lead_minutes": constants.ScheduledRideLeadMinutes, + "min_lead_minutes": constants.ScheduledRideMinLeadMinutes, + }) +} + +// Cancel POST /api/customer/scheduled-rides/:id/cancel(乘客 JWT)。 +// +// 已轉單的回 409+該筆預約現況——App 據此把畫面換成「已轉為訂單」並引導去取消訂單, +// 而不是顯示「取消失敗,請稍後再試」(那句話會讓乘客一直按,訂單卻照樣派出去)。 +func (h *ScheduledRideHandler) Cancel(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 格式錯誤"}) + return + } + customerID := middleware.CustomerIDFromCtx(c) + row, err := h.schedules.Cancel(customerID, id) + if err != nil { + status := scheduledRideStatusForErr(err) + payload := gin.H{"error": err.Error()} + if errors.Is(err, service.ErrScheduleNotCancellable) { + if latest, gerr := h.schedules.Get(customerID, id); gerr == nil { + payload["scheduled_ride"] = latest + } + } + c.JSON(status, payload) + return + } + c.JSON(http.StatusOK, gin.H{"scheduled_ride": row}) +} + +func scheduledRideStatusForErr(err error) int { + switch { + case errors.Is(err, service.ErrScheduleTooSoon), + errors.Is(err, service.ErrScheduleTooFar), + errors.Is(err, service.ErrScheduleNoteTooLong), + errors.Is(err, service.ErrInvalidCoords), + errors.Is(err, service.ErrInvalidVehicleType): + return http.StatusBadRequest + case errors.Is(err, service.ErrTooManySchedules), + errors.Is(err, service.ErrScheduleNotCancellable): + return http.StatusConflict + case errors.Is(err, service.ErrNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/internal/handler/scheduled_ride_route_shape_test.go b/internal/handler/scheduled_ride_route_shape_test.go new file mode 100644 index 0000000..e9c9aa9 --- /dev/null +++ b/internal/handler/scheduled_ride_route_shape_test.go @@ -0,0 +1,69 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// 預約與常用地點在同一層同時有靜態段與參數段(/customer/scheduled-rides 與 +// /customer/scheduled-rides/:id、/customer/places 與 /customer/places/:id), +// 而且它們跟既有的 /customer/rides、/customer/lost-items 共用同一個 group。 +// +// gin 的路由衝突是**註冊當下 panic**:服務起不來,但單元測試照樣全綠—— +// 是那種一路撐到部署才爆的失敗。這裡把乘客 group 的完整路由形狀重建一次, +// 讓衝突在測試階段就炸出來。 +// +// **形狀變了要同步改這裡**(與 TestCustomerRideRoutesStaticAndParamCoexist 同一個約定)。 +func TestCustomerPlaceAndScheduleRoutesCoexist(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + g := r.Group("/api") + + // 既有的乘客路由(一起註冊才驗得到跨路由衝突) + g.POST("/rides", func(c *gin.Context) { c.String(200, "create") }) + g.POST("/customer/rides/estimate", func(c *gin.Context) { c.String(200, "estimate") }) + g.GET("/customer/rides", func(c *gin.Context) { c.String(200, "history") }) + g.GET("/customer/rides/active", func(c *gin.Context) { c.String(200, "active") }) + g.GET("/customer/rides/:id", func(c *gin.Context) { c.String(200, "detail") }) + g.GET("/customer/lost-items", func(c *gin.Context) { c.String(200, "lost-items") }) + + // 常用地點 + g.GET("/customer/places", func(c *gin.Context) { c.String(200, "places") }) + g.POST("/customer/places", func(c *gin.Context) { c.String(200, "place-create") }) + g.PUT("/customer/places/:id", func(c *gin.Context) { c.String(200, "place-update:"+c.Param("id")) }) + g.DELETE("/customer/places/:id", func(c *gin.Context) { c.String(200, "place-delete:"+c.Param("id")) }) + + // 預約行程 + g.GET("/customer/scheduled-rides", func(c *gin.Context) { c.String(200, "schedules") }) + g.POST("/customer/scheduled-rides", func(c *gin.Context) { c.String(200, "schedule-create") }) + g.GET("/customer/scheduled-rides/:id", func(c *gin.Context) { c.String(200, "schedule:"+c.Param("id")) }) + g.POST("/customer/scheduled-rides/:id/cancel", func(c *gin.Context) { + c.String(200, "schedule-cancel:"+c.Param("id")) + }) + + for _, tc := range []struct{ method, path, want string }{ + {"GET", "/api/customer/places", "places"}, + {"POST", "/api/customer/places", "place-create"}, + {"PUT", "/api/customer/places/7", "place-update:7"}, + {"DELETE", "/api/customer/places/7", "place-delete:7"}, + {"GET", "/api/customer/scheduled-rides", "schedules"}, + {"POST", "/api/customer/scheduled-rides", "schedule-create"}, + {"GET", "/api/customer/scheduled-rides/12", "schedule:12"}, + {"POST", "/api/customer/scheduled-rides/12/cancel", "schedule-cancel:12"}, + // 既有路由不能被新的蓋掉——/customer/rides 與 /customer/scheduled-rides + // 是兄弟節點,前綴相同的那幾個字元最容易讓人以為可以共用一段。 + {"GET", "/api/customer/rides/active", "active"}, + {"GET", "/api/customer/rides/42", "detail"}, + {"GET", "/api/customer/lost-items", "lost-items"}, + } { + w := httptest.NewRecorder() + req, _ := http.NewRequest(tc.method, tc.path, nil) + r.ServeHTTP(w, req) + if w.Body.String() != tc.want { + t.Fatalf("%s %s → %q,預期 %q(code=%d)", tc.method, tc.path, w.Body.String(), tc.want, w.Code) + } + } +} diff --git a/internal/model/models.go b/internal/model/models.go index 065f3e8..38bea09 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -358,3 +358,53 @@ type RideEvent struct { func (RideEvent) TableName() string { return "ride_events" } + +// CustomerSavedPlace 乘客的常用地點(住家/公司/自訂)。 +// +// Kind 是語意插槽(constants.SavedPlaceKind*),home/work 每人各限一筆; +// Label 只是顯示名稱,使用者可改——判斷「這是不是住家」一律看 Kind,別比對 Label。 +type CustomerSavedPlace struct { + ID int64 `gorm:"primaryKey" json:"id"` + CustomerID int64 `gorm:"column:customer_id;not null" json:"customer_id"` + Kind string `gorm:"not null;default:'custom'" json:"kind"` + Label string `gorm:"not null" json:"label"` + Address string `gorm:"not null" json:"address"` + Point GeoPoint `gorm:"column:point;type:geography(Point,4326);not null" json:"point"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} + +func (CustomerSavedPlace) TableName() string { + return "customer_saved_places" +} + +// ScheduledRide 預約行程:到點前由排程器轉成真訂單(RideID)進派單池。 +// +// 這張表刻意與 rides 分離——它在轉單之前**不是訂單**,不該出現在派單池、乘客 active、 +// 報表或 admin 訂單列表裡(見 migration 000026 的說明)。 +type ScheduledRide struct { + ID int64 `gorm:"primaryKey" json:"id"` + CustomerID int64 `gorm:"column:customer_id;not null" json:"customer_id"` + ScheduledAt time.Time `gorm:"column:scheduled_at;not null" json:"scheduled_at"` + PickupPoint GeoPoint `gorm:"column:pickup_point;type:geography(Point,4326);not null" json:"pickup_point"` + PickupAddress string `gorm:"column:pickup_address;not null;default:''" json:"pickup_address"` + DropoffPoint *GeoPoint `gorm:"column:dropoff_point;type:geography(Point,4326)" json:"dropoff_point"` + DropoffAddress string `gorm:"column:dropoff_address;not null;default:''" json:"dropoff_address"` + // RequiredVehicleType 與 rides 同一組 code;'' =不指定車種。 + RequiredVehicleType string `gorm:"column:required_vehicle_type;not null;default:''" json:"required_vehicle_type"` + Note string `gorm:"not null;default:''" json:"note"` + Status string `gorm:"not null;default:'pending'" json:"status"` + // RideID 轉單後指向那張真訂單;pending/cancelled/failed 時為 nil。 + RideID *int64 `gorm:"column:ride_id" json:"ride_id"` + // AttemptCount/LastError 轉單重試的痕跡:到點時乘客可能還在別的行程上, + // 那是「等下一輪」而不是「永久失敗」,重試上限見 constants.ScheduledRideMaxAttempts。 + AttemptCount int `gorm:"column:attempt_count;not null;default:0" json:"attempt_count"` + LastError string `gorm:"column:last_error;not null;default:''" json:"last_error"` + DispatchedAt *time.Time `gorm:"column:dispatched_at" json:"dispatched_at"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} + +func (ScheduledRide) TableName() string { + return "scheduled_rides" +} diff --git a/internal/repository/saved_place.go b/internal/repository/saved_place.go new file mode 100644 index 0000000..64ebadc --- /dev/null +++ b/internal/repository/saved_place.go @@ -0,0 +1,119 @@ +package repository + +import ( + "errors" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + + "line-fleet-dispatch/internal/model" +) + +// SavedPlaceRepository 乘客常用地點讀寫。 +type SavedPlaceRepository struct { + db *gorm.DB +} + +func NewSavedPlaceRepository(db *gorm.DB) *SavedPlaceRepository { + return &SavedPlaceRepository{db: db} +} + +// ListByCustomer 該乘客的全部常用地點。 +// +// 排序刻意是「住家 → 公司 → 其他(新的在前)」而不是純 id 序:這份清單直接就是 App 上 +// 那排快捷鈕的順序,住家/公司永遠該在最前面,不該因為後來新增了自訂地點就被擠掉。 +func (r *SavedPlaceRepository) ListByCustomer(customerID int64) ([]model.CustomerSavedPlace, error) { + var rows []model.CustomerSavedPlace + err := r.db.Where("customer_id = ?", customerID). + Order(`CASE kind WHEN 'home' THEN 0 WHEN 'work' THEN 1 ELSE 2 END, id DESC`). + Limit(MaxListRows).Find(&rows).Error + return rows, err +} + +// GetOwned 取單筆並確認屬於該乘客;不存在或不是他的都回 (nil, nil)。 +// +// 「不是你的」與「不存在」故意回同一種結果——分開回會讓人用 404/403 的差異 +// 探測出別人有幾筆地點。 +func (r *SavedPlaceRepository) GetOwned(id, customerID int64) (*model.CustomerSavedPlace, error) { + var row model.CustomerSavedPlace + err := r.db.Where("id = ? AND customer_id = ?", id, customerID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// FindByKind 取該乘客某個插槽種類的地點(home/work);沒有則回 (nil, nil)。 +func (r *SavedPlaceRepository) FindByKind(customerID int64, kind string) (*model.CustomerSavedPlace, error) { + var row model.CustomerSavedPlace + err := r.db.Where("customer_id = ? AND kind = ?", customerID, kind).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// Create 新增一筆常用地點。撞 uq_saved_place_customer_kind 時回 ErrDuplicateSlot—— +// 服務層會先查再決定新增或覆蓋,這條是競態下的最後防線。 +func (r *SavedPlaceRepository) Create(place *model.CustomerSavedPlace) error { + now := time.Now() + if place.CreatedAt.IsZero() { + place.CreatedAt = now + } + place.UpdatedAt = now + err := r.db.Create(place).Error + if isSavedPlaceSlotConflict(err) { + return ErrDuplicateSlot + } + return err +} + +// Update 更新既有地點的顯示名稱、地址與座標(kind 不可改—— +// 要把住家改成公司,語意上是刪掉重建,不是改一個欄位)。 +func (r *SavedPlaceRepository) Update(id, customerID int64, label, address string, point model.GeoPoint) (bool, error) { + res := r.db.Model(&model.CustomerSavedPlace{}). + Where("id = ? AND customer_id = ?", id, customerID). + Updates(map[string]interface{}{ + "label": label, + "address": address, + "point": point, + "updated_at": time.Now(), + }) + return res.RowsAffected > 0, res.Error +} + +// Delete 刪除自己的地點;回傳是否真的刪到(不是自己的就刪不到)。 +func (r *SavedPlaceRepository) Delete(id, customerID int64) (bool, error) { + res := r.db.Where("id = ? AND customer_id = ?", id, customerID). + Delete(&model.CustomerSavedPlace{}) + return res.RowsAffected > 0, res.Error +} + +// ErrDuplicateSlot 同一位乘客的 home/work 插槽已被佔用。 +var ErrDuplicateSlot = errors.New("該種類的常用地點已存在") + +// isSavedPlaceSlotConflict 判斷是否撞到 uq_saved_place_customer_kind。 +// 只認這一個約束名——其他唯一鍵衝突不該被翻譯成「插槽已存在」而蓋掉真正的錯誤。 +func isSavedPlaceSlotConflict(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + return pgErr.Code == pgUniqueViolation && + pgErr.ConstraintName == "uq_saved_place_customer_kind" +} + +// CountByCustomer 該乘客已存的地點數(服務層據此擋上限)。 +func (r *SavedPlaceRepository) CountByCustomer(customerID int64) (int64, error) { + var n int64 + err := r.db.Model(&model.CustomerSavedPlace{}). + Where("customer_id = ?", customerID).Count(&n).Error + return n, err +} diff --git a/internal/repository/scheduled_ride.go b/internal/repository/scheduled_ride.go new file mode 100644 index 0000000..5918092 --- /dev/null +++ b/internal/repository/scheduled_ride.go @@ -0,0 +1,170 @@ +package repository + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/model" +) + +// ScheduledRideRepository 預約行程讀寫。 +type ScheduledRideRepository struct { + db *gorm.DB +} + +func NewScheduledRideRepository(db *gorm.DB) *ScheduledRideRepository { + return &ScheduledRideRepository{db: db} +} + +// Create 建立預約。 +func (r *ScheduledRideRepository) Create(s *model.ScheduledRide) error { + now := time.Now() + if s.CreatedAt.IsZero() { + s.CreatedAt = now + } + s.UpdatedAt = now + return r.db.Create(s).Error +} + +// GetOwned 取單筆並確認屬於該乘客;不存在或不是他的都回 (nil, nil)。 +func (r *ScheduledRideRepository) GetOwned(id, customerID int64) (*model.ScheduledRide, error) { + var row model.ScheduledRide + err := r.db.Where("id = ? AND customer_id = ?", id, customerID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// GetByID 取單筆(排程器用,不做擁有者過濾)。 +func (r *ScheduledRideRepository) GetByID(id int64) (*model.ScheduledRide, error) { + var row model.ScheduledRide + if err := r.db.First(&row, id).Error; err != nil { + return nil, err + } + return &row, nil +} + +// ListByCustomer 乘客的預約清單。 +// +// upcomingOnly 為 true 時只回「還會發生的」(pending,且尚未過期太久)—— +// 那是 App 首頁那張卡要的東西;false 則連已轉單/已取消一起回,供「預約紀錄」頁。 +func (r *ScheduledRideRepository) ListByCustomer(customerID int64, upcomingOnly bool) ([]model.ScheduledRide, error) { + q := r.db.Where("customer_id = ?", customerID) + if upcomingOnly { + q = q.Where("status = ?", constants.ScheduledRideStatusPending) + } + var rows []model.ScheduledRide + // 近的在前:預約清單的閱讀順序是「下一趟是什麼時候」,不是「我什麼時候建的」。 + err := q.Order("scheduled_at ASC, id ASC").Limit(MaxListRows).Find(&rows).Error + return rows, err +} + +// CountPendingByCustomer 該乘客還沒轉單的預約數(服務層據此擋上限)。 +func (r *ScheduledRideRepository) CountPendingByCustomer(customerID int64) (int64, error) { + var n int64 + err := r.db.Model(&model.ScheduledRide{}). + Where("customer_id = ? AND status = ?", customerID, constants.ScheduledRideStatusPending). + Count(&n).Error + return n, err +} + +// FindDue 找出「該轉單了」的預約:仍在 pending,且約定時間已進入提前發動的界線內。 +// +// 注意這裡**不做認領**,只是列出候選;認領由 ClaimForDispatch 以單一 UPDATE 完成。 +// 分兩步是為了讓排程器能逐筆處理、逐筆記錯,而不是一個大交易全成全敗。 +func (r *ScheduledRideRepository) FindDue(now time.Time, lead time.Duration, limit int) ([]model.ScheduledRide, error) { + if limit <= 0 || limit > MaxListRows { + limit = MaxListRows + } + var rows []model.ScheduledRide + err := r.db.Where("status = ? AND scheduled_at <= ?", + constants.ScheduledRideStatusPending, now.Add(lead)). + Order("scheduled_at ASC, id ASC").Limit(limit).Find(&rows).Error + return rows, err +} + +// ClaimForDispatch 認領一筆待轉單的預約:把 attempt_count +1,並回傳是否認領成功。 +// +// **這是整條排程唯一的併發防線**:條件帶 status='pending' AND attempt_count = 期望值, +// 兩個排程器(或重啟後重疊的那一輪)同時掃到同一筆時,只有一個的 RowsAffected 會是 1, +// 另一個拿到 0 就跳過。少了這一步,同一筆預約會建出兩張真訂單—— +// 而訂單一旦派出去就收不回來了。 +func (r *ScheduledRideRepository) ClaimForDispatch(id int64, expectAttempt int) (bool, error) { + res := r.db.Model(&model.ScheduledRide{}). + Where("id = ? AND status = ? AND attempt_count = ?", + id, constants.ScheduledRideStatusPending, expectAttempt). + Updates(map[string]interface{}{ + "attempt_count": expectAttempt + 1, + "updated_at": time.Now(), + }) + return res.RowsAffected > 0, res.Error +} + +// MarkDispatched 轉單成功:綁上真訂單 id 並封存狀態。 +// 條件仍帶 status='pending',避免與同時發生的取消互相覆蓋。 +func (r *ScheduledRideRepository) MarkDispatched(id, rideID int64) (bool, error) { + now := time.Now() + res := r.db.Model(&model.ScheduledRide{}). + Where("id = ? AND status = ?", id, constants.ScheduledRideStatusPending). + Updates(map[string]interface{}{ + "status": constants.ScheduledRideStatusDispatched, + "ride_id": rideID, + "dispatched_at": now, + "last_error": "", + "updated_at": now, + }) + return res.RowsAffected > 0, res.Error +} + +// RecordAttemptError 記下這一輪轉單失敗的原因,維持 pending 等下一輪重試。 +func (r *ScheduledRideRepository) RecordAttemptError(id int64, reason string) error { + return r.db.Model(&model.ScheduledRide{}). + Where("id = ? AND status = ?", id, constants.ScheduledRideStatusPending). + Updates(map[string]interface{}{ + "last_error": truncateReason(reason), + "updated_at": time.Now(), + }).Error +} + +// MarkFailed 重試用盡:標記永久失敗並留下原因給乘客看。 +func (r *ScheduledRideRepository) MarkFailed(id int64, reason string) (bool, error) { + res := r.db.Model(&model.ScheduledRide{}). + Where("id = ? AND status = ?", id, constants.ScheduledRideStatusPending). + Updates(map[string]interface{}{ + "status": constants.ScheduledRideStatusFailed, + "last_error": truncateReason(reason), + "updated_at": time.Now(), + }) + return res.RowsAffected > 0, res.Error +} + +// Cancel 乘客取消:只有 pending 可取消,回傳是否真的取消到。 +// 回 false 代表它已經被排程器轉單/已取消過——呼叫端要據此回 409 並讓 App 重讀, +// 不能宣稱取消成功(那張真訂單還在跑,乘客會以為沒車要來)。 +func (r *ScheduledRideRepository) Cancel(id, customerID int64) (bool, error) { + res := r.db.Model(&model.ScheduledRide{}). + Where("id = ? AND customer_id = ? AND status = ?", + id, customerID, constants.ScheduledRideStatusPending). + Updates(map[string]interface{}{ + "status": constants.ScheduledRideStatusCancelled, + "updated_at": time.Now(), + }) + return res.RowsAffected > 0, res.Error +} + +// truncateReason 收斂錯誤訊息長度,避免上游帶回一整串 SQL/堆疊塞爆欄位。 +func truncateReason(s string) string { + const max = 200 + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} diff --git a/internal/service/saved_place.go b/internal/service/saved_place.go new file mode 100644 index 0000000..6334643 --- /dev/null +++ b/internal/service/saved_place.go @@ -0,0 +1,206 @@ +package service + +import ( + "errors" + "strings" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/model" + "line-fleet-dispatch/internal/repository" +) + +var ( + // ErrInvalidPlaceKind kind 不在白名單。 + ErrInvalidPlaceKind = errors.New("地點種類無效") + // ErrEmptyPlaceLabel 名稱為空。 + ErrEmptyPlaceLabel = errors.New("地點名稱不可為空") + // ErrPlaceLabelTooLong 名稱過長。 + ErrPlaceLabelTooLong = errors.New("地點名稱過長") + // ErrEmptyPlaceAddress 地址為空。 + ErrEmptyPlaceAddress = errors.New("地址不可為空") + // ErrPlaceAddressTooLong 地址過長。 + ErrPlaceAddressTooLong = errors.New("地址過長") + // ErrTooManyPlaces 已達常用地點數量上限。 + ErrTooManyPlaces = errors.New("常用地點數量已達上限") +) + +const ( + maxPlaceLabelRunes = 40 + maxPlaceAddressRunes = 200 + // maxPlacesPerCustomer 單一乘客的地點上限。純粹是防呆——沒有人真的有 50 個常用地點, + // 但沒有上限的清單遲早會被腳本灌爆。 + maxPlacesPerCustomer = 50 +) + +// SavedPlaceService 常用地點(住家/公司/自訂)。 +type SavedPlaceService struct { + places *repository.SavedPlaceRepository +} + +func NewSavedPlaceService(places *repository.SavedPlaceRepository) *SavedPlaceService { + return &SavedPlaceService{places: places} +} + +// SavePlaceInput 新增/更新常用地點的輸入。 +type SavePlaceInput struct { + Kind string + Label string + Address string + Lat float64 + Lng float64 +} + +// List 該乘客的常用地點(住家 → 公司 → 其他)。 +func (s *SavedPlaceService) List(customerID int64) ([]model.CustomerSavedPlace, error) { + return s.places.ListByCustomer(customerID) +} + +// Create 新增常用地點。 +// +// **home/work 是 upsert 語意**:那兩個是「插槽」不是「清單項目」——乘客在 UI 上按的是 +// 「設定住家」,他預期的結果是住家變成新地址,而不是收到一個「你已經有住家了」的錯誤, +// 還得先去把舊的刪掉。custom 才是真的每次新增一筆。 +func (s *SavedPlaceService) Create(customerID int64, in SavePlaceInput) (*model.CustomerSavedPlace, error) { + kind, label, address, err := normalizePlaceInput(in) + if err != nil { + return nil, err + } + if err := validatePickupCoords(in.Lat, in.Lng); err != nil { + return nil, err + } + point := model.GeoPoint{Lat: in.Lat, Lng: in.Lng} + + if constants.IsSlotSavedPlaceKind(kind) { + existing, err := s.places.FindByKind(customerID, kind) + if err != nil { + return nil, err + } + if existing != nil { + ok, err := s.places.Update(existing.ID, customerID, label, address, point) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrNotFound + } + return s.places.GetOwned(existing.ID, customerID) + } + } + + n, err := s.places.CountByCustomer(customerID) + if err != nil { + return nil, err + } + if n >= maxPlacesPerCustomer { + return nil, ErrTooManyPlaces + } + + place := &model.CustomerSavedPlace{ + CustomerID: customerID, + Kind: kind, + Label: label, + Address: address, + Point: point, + } + if err := s.places.Create(place); err != nil { + // 競態:兩個請求同時設住家,先查後寫之間對方插進來了。 + // 這裡不回錯給乘客——他要的結果(住家=這個地址)用覆蓋一樣能達成。 + if errors.Is(err, repository.ErrDuplicateSlot) && constants.IsSlotSavedPlaceKind(kind) { + existing, ferr := s.places.FindByKind(customerID, kind) + if ferr != nil || existing == nil { + return nil, err + } + if _, uerr := s.places.Update(existing.ID, customerID, label, address, point); uerr != nil { + return nil, uerr + } + return s.places.GetOwned(existing.ID, customerID) + } + return nil, err + } + return place, nil +} + +// Update 改既有地點的名稱/地址/座標。kind 不可改(見 repository.Update 說明)。 +func (s *SavedPlaceService) Update(customerID, id int64, in SavePlaceInput) (*model.CustomerSavedPlace, error) { + existing, err := s.places.GetOwned(id, customerID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, ErrNotFound + } + // kind 沿用既有值:呼叫端不必重送,送了也不算數。 + in.Kind = existing.Kind + _, label, address, err := normalizePlaceInput(in) + if err != nil { + return nil, err + } + if err := validatePickupCoords(in.Lat, in.Lng); err != nil { + return nil, err + } + ok, err := s.places.Update(id, customerID, label, address, + model.GeoPoint{Lat: in.Lat, Lng: in.Lng}) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrNotFound + } + return s.places.GetOwned(id, customerID) +} + +// Delete 刪除自己的常用地點。 +func (s *SavedPlaceService) Delete(customerID, id int64) error { + ok, err := s.places.Delete(id, customerID) + if err != nil { + return err + } + if !ok { + return ErrNotFound + } + return nil +} + +// normalizePlaceInput 修剪空白並驗證;回傳正規化後的 kind/label/address。 +// +// label 留空時以 kind 的預設名稱補上——乘客在「設定住家」流程裡本來就沒有輸入名稱的欄位, +// 強迫他取一個名字只是多一步。 +func normalizePlaceInput(in SavePlaceInput) (kind, label, address string, err error) { + kind = strings.TrimSpace(in.Kind) + if kind == "" { + kind = constants.SavedPlaceKindCustom + } + if !constants.IsValidSavedPlaceKind(kind) { + return "", "", "", ErrInvalidPlaceKind + } + label = strings.TrimSpace(in.Label) + if label == "" { + label = defaultPlaceLabel(kind) + } + if label == "" { + return "", "", "", ErrEmptyPlaceLabel + } + if len([]rune(label)) > maxPlaceLabelRunes { + return "", "", "", ErrPlaceLabelTooLong + } + address = strings.TrimSpace(in.Address) + if address == "" { + return "", "", "", ErrEmptyPlaceAddress + } + if len([]rune(address)) > maxPlaceAddressRunes { + return "", "", "", ErrPlaceAddressTooLong + } + return kind, label, address, nil +} + +// defaultPlaceLabel 插槽種類的預設顯示名稱;custom 沒有預設(必須自己取名)。 +func defaultPlaceLabel(kind string) string { + switch kind { + case constants.SavedPlaceKindHome: + return "住家" + case constants.SavedPlaceKindWork: + return "公司" + default: + return "" + } +} diff --git a/internal/service/saved_place_test.go b/internal/service/saved_place_test.go new file mode 100644 index 0000000..a3c2efe --- /dev/null +++ b/internal/service/saved_place_test.go @@ -0,0 +1,231 @@ +package service + +import ( + "errors" + "testing" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/repository" +) + +// TestSavedPlaceFlow 常用地點全流程:新增自訂地點、住家/公司是覆蓋語意(不是報錯)、 +// 更新、刪除,以及擁有權隔離。 +func TestSavedPlaceFlow(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewSavedPlaceService(repository.NewSavedPlaceRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_place_me", "地點乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + other, err := customers.FindOrCreateByLineUserID("U_place_other", "另一位乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + // 住家:label 留空應自動補「住家」。 + home, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: "台北市信義區市府路1號", + Lat: 25.0330, Lng: 121.5654, + }) + if err != nil { + t.Fatalf("建立住家失敗:%v", err) + } + if home.Label != "住家" { + t.Fatalf("住家 label 應自動補為「住家」,得到 %q", home.Label) + } + + // **同 kind 再送一次是覆蓋,不是報錯**——UI 上那顆按鈕叫「設定住家」, + // 乘客預期住家換成新地址,而不是被要求先去刪掉舊的。 + home2, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: "新北市板橋區縣民大道二段1號", + Lat: 25.0143, Lng: 121.4675, + }) + if err != nil { + t.Fatalf("覆蓋住家失敗:%v", err) + } + if home2.ID != home.ID { + t.Fatalf("覆蓋住家應沿用同一筆(id %d),卻新增了 id %d", home.ID, home2.ID) + } + if home2.Address != "新北市板橋區縣民大道二段1號" { + t.Fatalf("住家地址未被覆蓋,得到 %q", home2.Address) + } + if home2.Point.Lat != 25.0143 { + t.Fatalf("住家座標未被覆蓋,得到 %v", home2.Point) + } + + // 公司是另一個插槽,不會跟住家互相覆蓋。 + work, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindWork, Address: "台北市內湖區瑞光路513巷", + Lat: 25.0797, Lng: 121.5750, + }) + if err != nil { + t.Fatalf("建立公司失敗:%v", err) + } + if work.ID == home2.ID { + t.Fatalf("公司不該覆蓋住家") + } + + // custom 每次都是新增一筆,不覆蓋。 + gymA, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, Label: "健身房", Address: "台北市大安區忠孝東路四段", + Lat: 25.0418, Lng: 121.5490, + }) + if err != nil { + t.Fatalf("建立自訂地點失敗:%v", err) + } + gymB, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, Label: "媽媽家", Address: "桃園市中壢區中大路300號", + Lat: 24.9680, Lng: 121.1950, + }) + if err != nil { + t.Fatalf("建立第二個自訂地點失敗:%v", err) + } + if gymA.ID == gymB.ID { + t.Fatalf("自訂地點應各自獨立") + } + + // 清單排序:住家 → 公司 → 其他(新的在前)。 + list, err := svc.List(me.ID) + if err != nil { + t.Fatalf("讀清單失敗:%v", err) + } + if len(list) != 4 { + t.Fatalf("應有 4 筆地點,得到 %d", len(list)) + } + if list[0].Kind != constants.SavedPlaceKindHome || list[1].Kind != constants.SavedPlaceKindWork { + t.Fatalf("清單應以住家、公司開頭,得到 %s, %s", list[0].Kind, list[1].Kind) + } + if list[2].ID != gymB.ID { + t.Fatalf("自訂地點應新的在前,得到 id %d", list[2].ID) + } + + // 更新:改名稱與座標,kind 不動。 + updated, err := svc.Update(me.ID, gymA.ID, SavePlaceInput{ + Label: "健身房(新店)", Address: "新北市新店區北新路三段", + Lat: 24.9700, Lng: 121.5400, + }) + if err != nil { + t.Fatalf("更新地點失敗:%v", err) + } + if updated.Label != "健身房(新店)" || updated.Kind != constants.SavedPlaceKindCustom { + t.Fatalf("更新後 label/kind 不符:%+v", updated) + } + + // 擁有權:別人的地點既讀不到也改不到、刪不掉。 + if _, err := svc.Update(other.ID, gymA.ID, SavePlaceInput{ + Label: "偷改", Address: "任意地址", Lat: 25.0, Lng: 121.5, + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("改別人的地點應回 ErrNotFound,得到 %v", err) + } + if err := svc.Delete(other.ID, gymA.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("刪別人的地點應回 ErrNotFound,得到 %v", err) + } + otherList, err := svc.List(other.ID) + if err != nil { + t.Fatalf("讀他人清單失敗:%v", err) + } + if len(otherList) != 0 { + t.Fatalf("另一位乘客不該看到任何地點,得到 %d 筆", len(otherList)) + } + + // 刪除自己的。 + if err := svc.Delete(me.ID, gymA.ID); err != nil { + t.Fatalf("刪除地點失敗:%v", err) + } + after, _ := svc.List(me.ID) + if len(after) != 3 { + t.Fatalf("刪除後應剩 3 筆,得到 %d", len(after)) + } +} + +// TestSavedPlaceValidation 輸入驗證:kind 白名單、必填、長度、座標。 +func TestSavedPlaceValidation(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewSavedPlaceService(repository.NewSavedPlaceRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_place_valid", "驗證乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + cases := []struct { + name string + in SavePlaceInput + want error + }{ + {"kind 不在白名單", SavePlaceInput{ + Kind: "castle", Label: "城堡", Address: "某處", Lat: 25.0, Lng: 121.5, + }, ErrInvalidPlaceKind}, + {"custom 沒給名稱", SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, Address: "某處", Lat: 25.0, Lng: 121.5, + }, ErrEmptyPlaceLabel}, + {"地址空白", SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: " ", Lat: 25.0, Lng: 121.5, + }, ErrEmptyPlaceAddress}, + {"名稱過長", SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, Label: string(make([]rune, 41)), Address: "某處", + Lat: 25.0, Lng: 121.5, + }, ErrPlaceLabelTooLong}, + {"座標無效", SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: "某處", Lat: 999, Lng: 121.5, + }, ErrInvalidCoords}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := svc.Create(me.ID, tc.in); !errors.Is(err, tc.want) { + t.Fatalf("預期 %v,得到 %v", tc.want, err) + } + }) + } +} + +// TestSavedPlaceLimit 常用地點數達上限後不能再新增,但**插槽仍可覆蓋**。 +// +// 覆蓋不會增加筆數,所以「已達上限就連住家都不能改」會是個很怪的死結。 +func TestSavedPlaceLimit(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewSavedPlaceService(repository.NewSavedPlaceRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_place_limit", "上限乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + // 先塞一個住家,其餘用 custom 填滿。 + if _, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: "舊家", Lat: 25.0, Lng: 121.5, + }); err != nil { + t.Fatalf("建立住家失敗:%v", err) + } + for i := 1; i < maxPlacesPerCustomer; i++ { + if _, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, + Label: "地點" + string(rune('A'+i%26)) + string(rune('0'+i/26)), + Address: "某地址", Lat: 25.0, Lng: 121.5, + }); err != nil { + t.Fatalf("第 %d 筆就失敗了:%v", i+1, err) + } + } + + if _, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindCustom, Label: "再一個", Address: "某地址", + Lat: 25.0, Lng: 121.5, + }); !errors.Is(err, ErrTooManyPlaces) { + t.Fatalf("超過上限應回 ErrTooManyPlaces,得到 %v", err) + } + + // 插槽覆蓋不增加筆數,所以即使已達上限也該成功。 + updated, err := svc.Create(me.ID, SavePlaceInput{ + Kind: constants.SavedPlaceKindHome, Address: "新家", Lat: 25.1, Lng: 121.6, + }) + if err != nil { + t.Fatalf("已達上限時覆蓋住家仍應成功,得到 %v", err) + } + if updated.Address != "新家" { + t.Fatalf("住家沒被覆蓋:%+v", updated) + } +} diff --git a/internal/service/scheduled_ride.go b/internal/service/scheduled_ride.go new file mode 100644 index 0000000..3a6586d --- /dev/null +++ b/internal/service/scheduled_ride.go @@ -0,0 +1,157 @@ +package service + +import ( + "errors" + "strings" + "time" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/model" + "line-fleet-dispatch/internal/repository" +) + +var ( + // ErrScheduleTooSoon 預約時間太近,該直接叫車而不是預約。 + ErrScheduleTooSoon = errors.New("預約時間太接近,請直接叫車") + // ErrScheduleTooFar 預約時間超過可預約範圍。 + ErrScheduleTooFar = errors.New("預約時間超過可預約範圍") + // ErrTooManySchedules 未完成的預約數已達上限。 + ErrTooManySchedules = errors.New("待執行的預約數量已達上限") + // ErrScheduleNotCancellable 預約已轉單/已取消,不能再取消。 + ErrScheduleNotCancellable = errors.New("此預約已無法取消") + // ErrScheduleNoteTooLong 備註過長。 + ErrScheduleNoteTooLong = errors.New("備註過長") +) + +const ( + maxScheduleNoteRunes = 200 + // maxPendingSchedules 單一乘客同時可存在的 pending 預約數。 + maxPendingSchedules = 20 + // scheduledDispatchBatch 每輪最多處理幾筆,避免單輪長時間佔住 DB 連線。 + scheduledDispatchBatch = 100 +) + +// ScheduledRideService 預約行程的建立/查詢/取消。 +// +// 「到點轉單」不在這裡,在 ScheduledRideDispatcher——那是背景排程器的職責, +// 與乘客觸發的同步請求分開,才不會有人因為別人的預約到點而被拖慢。 +type ScheduledRideService struct { + repo *repository.ScheduledRideRepository +} + +func NewScheduledRideService(repo *repository.ScheduledRideRepository) *ScheduledRideService { + return &ScheduledRideService{repo: repo} +} + +// ScheduleRideInput 建立預約的輸入。 +type ScheduleRideInput struct { + ScheduledAt time.Time + PickupLat, PickupLng float64 + PickupAddress string + DropoffAddress string + DropoffLat, DropoffLng *float64 + RequiredVehicleType string + Note string +} + +// Create 建立預約。 +// +// 刻意**不擋**「已有進行中訂單」——預約的是未來,現在正在搭車跟三小時後要用車沒有衝突。 +// 那道檢查留在到點轉單那一刻(走 RideService.CreateByCustomer 的既有規則)。 +func (s *ScheduledRideService) Create(customerID int64, in ScheduleRideInput) (*model.ScheduledRide, error) { + return s.createAt(customerID, in, time.Now()) +} + +// createAt 是 Create 的可測版本(把「現在」變成參數,時間邊界才驗得了)。 +func (s *ScheduledRideService) createAt(customerID int64, in ScheduleRideInput, now time.Time) (*model.ScheduledRide, error) { + if in.ScheduledAt.IsZero() { + return nil, ErrScheduleTooSoon + } + minAt := now.Add(constants.ScheduledRideMinLeadMinutes * time.Minute) + if in.ScheduledAt.Before(minAt) { + return nil, ErrScheduleTooSoon + } + maxAt := now.AddDate(0, 0, constants.ScheduledRideMaxDaysAhead) + if in.ScheduledAt.After(maxAt) { + return nil, ErrScheduleTooFar + } + if err := validatePickupCoords(in.PickupLat, in.PickupLng); err != nil { + return nil, err + } + if err := validateOptionalDropoffCoords(in.DropoffLat, in.DropoffLng); err != nil { + return nil, err + } + if in.RequiredVehicleType != "" && !constants.IsValidVehicleType(in.RequiredVehicleType) { + return nil, ErrInvalidVehicleType + } + note := strings.TrimSpace(in.Note) + if len([]rune(note)) > maxScheduleNoteRunes { + return nil, ErrScheduleNoteTooLong + } + + n, err := s.repo.CountPendingByCustomer(customerID) + if err != nil { + return nil, err + } + if n >= maxPendingSchedules { + return nil, ErrTooManySchedules + } + + row := &model.ScheduledRide{ + CustomerID: customerID, + ScheduledAt: in.ScheduledAt, + PickupPoint: model.GeoPoint{Lat: in.PickupLat, Lng: in.PickupLng}, + PickupAddress: strings.TrimSpace(in.PickupAddress), + DropoffAddress: strings.TrimSpace(in.DropoffAddress), + RequiredVehicleType: in.RequiredVehicleType, + Note: note, + Status: constants.ScheduledRideStatusPending, + } + if in.DropoffLat != nil && in.DropoffLng != nil { + row.DropoffPoint = &model.GeoPoint{Lat: *in.DropoffLat, Lng: *in.DropoffLng} + } + if err := s.repo.Create(row); err != nil { + return nil, err + } + return row, nil +} + +// List 乘客的預約清單;upcomingOnly 只回還沒轉單的。 +func (s *ScheduledRideService) List(customerID int64, upcomingOnly bool) ([]model.ScheduledRide, error) { + return s.repo.ListByCustomer(customerID, upcomingOnly) +} + +// Get 取自己的單筆預約。 +func (s *ScheduledRideService) Get(customerID, id int64) (*model.ScheduledRide, error) { + row, err := s.repo.GetOwned(id, customerID) + if err != nil { + return nil, err + } + if row == nil { + return nil, ErrNotFound + } + return row, nil +} + +// Cancel 取消預約。 +// +// 已被排程器轉單的回 ErrScheduleNotCancellable(handler 對映 409)—— +// **不能宣稱取消成功**:那張真訂單已經在派單池裡,司機可能正在開過來。 +// App 收到 409 要重讀這筆預約,讓乘客看到「已轉為訂單」並引導他去取消那張訂單。 +func (s *ScheduledRideService) Cancel(customerID, id int64) (*model.ScheduledRide, error) { + row, err := s.repo.GetOwned(id, customerID) + if err != nil { + return nil, err + } + if row == nil { + return nil, ErrNotFound + } + ok, err := s.repo.Cancel(id, customerID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrScheduleNotCancellable + } + return s.repo.GetOwned(id, customerID) +} diff --git a/internal/service/scheduled_ride_dispatcher.go b/internal/service/scheduled_ride_dispatcher.go new file mode 100644 index 0000000..65eea96 --- /dev/null +++ b/internal/service/scheduled_ride_dispatcher.go @@ -0,0 +1,211 @@ +package service + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog/log" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/model" + "line-fleet-dispatch/internal/repository" +) + +// scheduledRideCreator 是 dispatcher 需要的建單能力(由 *RideService 滿足)。 +// 抽成介面純粹是為了讓 Tick 測得起來——不然每個測試都得備妥 redis 與派單服務。 +type scheduledRideCreator interface { + CreateByCustomer(ctx context.Context, customerID int64, req CustomerCreateRequest) (*model.Ride, error) +} + +// ScheduledRideDispatcher 背景排程器:把到點的預約轉成真訂單。 +// +// 轉單走的是**與乘客手動叫車完全同一支** RideService.CreateByCustomer—— +// 派單、審計、車種驗證、「同時只能有一張進行中訂單」的規則因此全部自動沿用。 +// 若在這裡另外寫一份建單邏輯,兩條路徑遲早會長歪(其中一條漏掉某個規則, +// 而且是預約那條漏——因為它沒有人在旁邊看著)。 +type ScheduledRideDispatcher struct { + repo *repository.ScheduledRideRepository + rides scheduledRideCreator + lead time.Duration + ticker time.Duration + // expiryGrace 過了約定時間多久就不再轉單(見 dispatchOne 的說明)。 + expiryGrace time.Duration + // now 可注入,供測試控制時間。 + now func() time.Time +} + +func NewScheduledRideDispatcher( + repo *repository.ScheduledRideRepository, + rides scheduledRideCreator, +) *ScheduledRideDispatcher { + return &ScheduledRideDispatcher{ + repo: repo, + rides: rides, + lead: constants.ScheduledRideLeadMinutes * time.Minute, + ticker: 30 * time.Second, + expiryGrace: constants.ScheduledRideExpiryGraceMinutes * time.Minute, + now: time.Now, + } +} + +// SetExpiryGrace 覆寫過期作廢的寬限時間(測試用)。 +func (d *ScheduledRideDispatcher) SetExpiryGrace(g time.Duration) { d.expiryGrace = g } + +// SetLead 覆寫提前發動時間(測試與未來的後台可調用)。 +func (d *ScheduledRideDispatcher) SetLead(lead time.Duration) { d.lead = lead } + +// SetInterval 覆寫掃描週期。 +func (d *ScheduledRideDispatcher) SetInterval(interval time.Duration) { d.ticker = interval } + +// SetNow 覆寫時間來源(測試用)。 +func (d *ScheduledRideDispatcher) SetNow(now func() time.Time) { d.now = now } + +// Run 常駐掃描,直到 ctx 結束。 +// +// 掃描週期(30 秒)遠小於提前量(15 分鐘)是刻意的:即使某一輪整個被跳過 +// (重啟、DB 短暫不可用),下一輪仍遠在約定時間之前,預約不會因此遲到。 +func (d *ScheduledRideDispatcher) Run(ctx context.Context) { + t := time.NewTicker(d.ticker) + defer t.Stop() + log.Info(). + Dur("interval", d.ticker). + Dur("lead", d.lead). + Msg("預約行程排程器已啟動") + for { + select { + case <-ctx.Done(): + log.Info().Msg("預約行程排程器已停止") + return + case <-t.C: + if n, err := d.Tick(ctx); err != nil { + log.Error().Err(err).Msg("預約行程轉單失敗") + } else if n > 0 { + log.Info().Int("dispatched", n).Msg("預約行程已轉為訂單") + } + } + } +} + +// Tick 跑一輪:找出到點的預約並逐筆轉單,回傳成功轉單的筆數。 +// +// 逐筆獨立處理、逐筆記錯——一筆失敗不該讓同一輪的其他預約跟著沒轉成。 +func (d *ScheduledRideDispatcher) Tick(ctx context.Context) (int, error) { + due, err := d.repo.FindDue(d.now(), d.lead, scheduledDispatchBatch) + if err != nil { + return 0, err + } + dispatched := 0 + for i := range due { + ok, err := d.dispatchOne(ctx, &due[i]) + if err != nil { + log.Error().Err(err).Int64("scheduled_ride_id", due[i].ID). + Msg("預約轉單發生錯誤") + continue + } + if ok { + dispatched++ + } + } + return dispatched, nil +} + +// dispatchOne 轉一筆預約;回傳是否真的轉成訂單。 +func (d *ScheduledRideDispatcher) dispatchOne(ctx context.Context, s *model.ScheduledRide) (bool, error) { + // 早就過了約定時間的不再轉單,直接作廢。 + // + // 這條在正常運作下永遠不會成立(提前量 15 分鐘,早轉完了),它防的是 + // **排程器停過一段時間**:重啟時若把積壓的過期預約全部轉成真訂單, + // 乘客昨天預約的車今天會突然開到他家樓下,而且他還得為那趟付錢。 + // 標 failed 而不是留在 pending,是為了讓乘客在清單上看得到「這筆沒成立」與原因—— + // 留著不動的話它會永遠掛在「即將到來」,而那台車永遠不會來。 + if s.ScheduledAt.Before(d.now().Add(-d.expiryGrace)) { + if _, err := d.repo.MarkFailed(s.ID, "預約時間已過,系統未能及時派車"); err != nil { + return false, err + } + log.Warn().Int64("scheduled_ride_id", s.ID). + Time("scheduled_at", s.ScheduledAt). + Msg("預約已過期太久,不再轉單") + return false, nil + } + + // 先認領。認領不到 = 別的排程器(或上一輪的殘留)已經在處理這筆,直接跳過。 + claimed, err := d.repo.ClaimForDispatch(s.ID, s.AttemptCount) + if err != nil { + return false, err + } + if !claimed { + return false, nil + } + attempt := s.AttemptCount + 1 + + req := CustomerCreateRequest{ + PickupLat: s.PickupPoint.Lat, + PickupLng: s.PickupPoint.Lng, + PickupAddress: s.PickupAddress, + DropoffAddress: s.DropoffAddress, + RequiredVehicleType: s.RequiredVehicleType, + } + if s.DropoffPoint != nil { + lat, lng := s.DropoffPoint.Lat, s.DropoffPoint.Lng + req.DropoffLat, req.DropoffLng = &lat, &lng + } + + ride, err := d.rides.CreateByCustomer(ctx, s.CustomerID, req) + if err != nil { + d.recordFailure(s.ID, attempt, err) + return false, nil + } + + ok, err := d.repo.MarkDispatched(s.ID, ride.ID) + if err != nil { + return false, err + } + if !ok { + // 建單成功了,但這筆預約已不在 pending(乘客剛好在這一瞬間取消)。 + // 訂單已經進了派單池,收不回來——記下來讓人查得到,不要靜靜吞掉。 + log.Warn().Int64("scheduled_ride_id", s.ID).Int64("ride_id", ride.ID). + Msg("預約已建出訂單,但預約本身已非 pending(可能同時被取消)") + } + return ok, nil +} + +// recordFailure 依錯誤性質決定「等下一輪重試」還是「直接判死」。 +// +// 這個分類是這支排程器最容易做錯的地方:把永久錯誤(座標無效、車種無效) +// 當成可重試,那筆預約會在池子裡重試到上限才失敗,乘客整整等到約定時間才知道沒車; +// 反過來把暫時錯誤(乘客當下還在另一趟行程上)當成永久失敗, +// 則會把一筆本來只要晚幾分鐘就能成立的預約直接砍掉。 +func (d *ScheduledRideDispatcher) recordFailure(id int64, attempt int, cause error) { + reason := cause.Error() + if isPermanentScheduleError(cause) { + if _, err := d.repo.MarkFailed(id, reason); err != nil { + log.Error().Err(err).Int64("scheduled_ride_id", id).Msg("標記預約失敗時出錯") + } + return + } + if attempt >= constants.ScheduledRideMaxAttempts { + if _, err := d.repo.MarkFailed(id, reason); err != nil { + log.Error().Err(err).Int64("scheduled_ride_id", id).Msg("標記預約失敗時出錯") + } + return + } + if err := d.repo.RecordAttemptError(id, reason); err != nil { + log.Error().Err(err).Int64("scheduled_ride_id", id).Msg("記錄預約重試原因時出錯") + } +} + +// isPermanentScheduleError 這個錯誤重試幾次都不會變好嗎? +// +// 只列**確定**不會自己好的:資料本身就不合法。其餘(乘客還在行程中、 +// 叫車頻率限制、DB 暫時不可用)一律當成可重試——下一輪只差 30 秒,代價很低, +// 而誤判成永久失敗的代價是乘客的車沒了。 +func isPermanentScheduleError(err error) bool { + switch { + case errors.Is(err, ErrInvalidCoords), + errors.Is(err, ErrInvalidVehicleType), + errors.Is(err, ErrStopsUnavailable): + return true + } + return false +} diff --git a/internal/service/scheduled_ride_dispatcher_test.go b/internal/service/scheduled_ride_dispatcher_test.go new file mode 100644 index 0000000..b380a51 --- /dev/null +++ b/internal/service/scheduled_ride_dispatcher_test.go @@ -0,0 +1,462 @@ +package service + +import ( + "context" + + "sync" + "testing" + "time" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/model" + "line-fleet-dispatch/internal/repository" +) + +// mustCreateRide 建一張指定狀態的訂單(供預約測試佈景)。 +func mustCreateRide(t *testing.T, rides *repository.RideRepository, customerID int64, status int16) *model.Ride { + t.Helper() + now := time.Now() + ride := &model.Ride{ + CustomerID: customerID, + Status: status, + PickupPoint: model.GeoPoint{Lat: 25.03, Lng: 121.56}, + PickupAddress: "台北車站", + RequestedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := rides.Create(ride); err != nil { + t.Fatalf("建立訂單失敗:%v", err) + } + return ride +} + +// fakeRideCreator 記錄每次建單請求,並可指定要回傳的錯誤。 +type fakeRideCreator struct { + mu sync.Mutex + calls []CustomerCreateRequest + rides *repository.RideRepository + err error + // 建出來的訂單真的寫進 DB,才能被 scheduled_rides.ride_id 的外鍵接受。 + + t *testing.T +} + +func (f *fakeRideCreator) CreateByCustomer(_ context.Context, customerID int64, req CustomerCreateRequest) (*model.Ride, error) { + f.mu.Lock() + f.calls = append(f.calls, req) + err := f.err + f.mu.Unlock() + if err != nil { + return nil, err + } + // 這裡刻意回 error 而不是 t.Fatalf:併發測試會從非測試 goroutine 呼叫進來, + // 在那裡呼叫 Fatalf 只會讓那個 goroutine 靜默結束,測試反而變成綠的。 + now := time.Now() + ride := &model.Ride{ + CustomerID: customerID, + Status: constants.RideStatusRequested, + PickupPoint: model.GeoPoint{Lat: 25.03, Lng: 121.56}, + PickupAddress: "台北車站", + RequestedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := f.rides.Create(ride); err != nil { + return nil, err + } + return ride, nil +} + +func (f *fakeRideCreator) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +type dispatcherFixture struct { + db *repository.ScheduledRideRepository + svc *ScheduledRideService + creator *fakeRideCreator + disp *ScheduledRideDispatcher + customer int64 +} + +func newDispatcherFixture(t *testing.T, lineUserID string) *dispatcherFixture { + t.Helper() + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + rideRepo := repository.NewRideRepository(db) + repo := repository.NewScheduledRideRepository(db) + + cust, err := customers.FindOrCreateByLineUserID(lineUserID, "排程乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + creator := &fakeRideCreator{rides: rideRepo, t: t} + disp := NewScheduledRideDispatcher(repo, creator) + return &dispatcherFixture{ + db: repo, svc: NewScheduledRideService(repo), + creator: creator, disp: disp, customer: cust.ID, + } +} + +// scheduleAsOfEarlier 以「一小時前的現在」建立預約。 +// +// 為什麼要這樣繞:建立預約時擋的最小前置時間(20 分)**大於**排程器的提前發動量(15 分), +// 所以一筆剛建好的預約永遠不可能立刻到點——那正是設計要的(否則使用者要的其實是現在叫車)。 +// 但排程器要處理的偏偏是「時間已經逼近」的那些,測試得把建立時的時間軸往回推才造得出來。 +func (f *dispatcherFixture) scheduleAsOfEarlier(t *testing.T, in ScheduleRideInput) *model.ScheduledRide { + t.Helper() + row, err := f.svc.createAt(f.customer, in, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("建立預約失敗:%v", err) + } + return row +} + +func (f *dispatcherFixture) schedule(t *testing.T, at time.Time) *model.ScheduledRide { + t.Helper() + return f.scheduleAsOfEarlier(t, ScheduleRideInput{ + ScheduledAt: at, + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }) +} + +// TestDispatcherOnlyPicksDueSchedules 未進入提前發動界線的預約不該被轉單。 +// +// 這是整支排程器最基本、也最該有負向對照的一條:如果 FindDue 的條件寫錯方向, +// 所有測試仍會「有東西被轉單」而看起來是綠的——差別只在「三天後的預約現在就派車了」。 +func TestDispatcherOnlyPicksDueSchedules(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_due") + now := time.Now() + + // 遠在提前量之外(3 小時後出發,提前量 15 分鐘)。 + future := f.schedule(t, now.Add(3*time.Hour)) + // 已進入提前量內(10 分鐘後出發)。 + due := f.schedule(t, now.Add(10*time.Minute)) + + n, err := f.disp.Tick(context.Background()) + if err != nil { + t.Fatalf("Tick 失敗:%v", err) + } + if n != 1 { + t.Fatalf("這一輪應只轉 1 筆,得到 %d", n) + } + if f.creator.callCount() != 1 { + t.Fatalf("應只呼叫一次建單,得到 %d", f.creator.callCount()) + } + + futureAfter, _ := f.db.GetByID(future.ID) + if futureAfter.Status != constants.ScheduledRideStatusPending { + t.Fatalf("未到點的預約應維持 pending,得到 %s", futureAfter.Status) + } + if futureAfter.AttemptCount != 0 { + t.Fatalf("未到點的預約不該被認領,attempt=%d", futureAfter.AttemptCount) + } + + dueAfter, _ := f.db.GetByID(due.ID) + if dueAfter.Status != constants.ScheduledRideStatusDispatched { + t.Fatalf("到點的預約應為 dispatched,得到 %s", dueAfter.Status) + } + if dueAfter.RideID == nil { + t.Fatalf("已轉單的預約必須帶 ride_id") + } + if dueAfter.DispatchedAt == nil { + t.Fatalf("已轉單的預約應記錄 dispatched_at") + } +} + +// TestDispatcherIsIdempotent 同一筆預約連跑兩輪只能建出一張訂單。 +// +// **這支守的是「循序重跑」,不是併發**——它會綠是因為第一輪把狀態改成 dispatched, +// 第二輪的 FindDue 就撈不到了。實測確認過:把 ClaimForDispatch 的樂觀鎖整個拔掉, +// 這支仍然是綠的。真正守著併發的是 TestDispatcherClaimRejectsStaleAttempt +// 與 TestDispatcherConcurrentTicksCreateOneRide,改動認領邏輯時要看的是那兩支。 +func TestDispatcherIsIdempotent(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_idem") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + + if n, err := f.disp.Tick(context.Background()); err != nil || n != 1 { + t.Fatalf("第一輪應轉 1 筆:n=%d err=%v", n, err) + } + if n, err := f.disp.Tick(context.Background()); err != nil || n != 0 { + t.Fatalf("第二輪不該再轉任何一筆:n=%d err=%v", n, err) + } + if got := f.creator.callCount(); got != 1 { + t.Fatalf("建單只該被呼叫一次,得到 %d 次", got) + } + + after, _ := f.db.GetByID(row.ID) + if after.AttemptCount != 1 { + t.Fatalf("attempt_count 應為 1,得到 %d", after.AttemptCount) + } +} + +// TestDispatcherClaimRejectsStaleAttempt 認領帶 attempt_count 樂觀鎖: +// 拿著過期的 attempt 值認領同一筆會失敗(模擬兩個排程器同時掃到)。 +func TestDispatcherClaimRejectsStaleAttempt(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_claim") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + + ok, err := f.db.ClaimForDispatch(row.ID, 0) + if err != nil || !ok { + t.Fatalf("第一次認領應成功:ok=%v err=%v", ok, err) + } + // 另一個排程器手上還是舊的 attempt_count=0。 + ok, err = f.db.ClaimForDispatch(row.ID, 0) + if err != nil { + t.Fatalf("第二次認領出錯:%v", err) + } + if ok { + t.Fatalf("拿過期 attempt_count 的認領必須失敗,否則同一筆預約會建出兩張訂單") + } +} + +// TestDispatcherConcurrentTicksCreateOneRide 兩個排程器**同時**掃到同一筆預約, +// 仍然只能建出一張訂單。 +// +// 這是多副本部署(或重啟時新舊實例短暫重疊)的真實情境,也是這個功能最貴的失敗: +// 訂單一旦派出去就收不回來,重複轉單=乘客莫名其妙多了一張要付錢的車。 +// 循序版的 TestDispatcherIsIdempotent 驗不到這條——那支靠的是狀態已經改掉, +// 而併發的兩個 Tick 是在狀態改掉**之前**就都撈到了同一筆。 +func TestDispatcherConcurrentTicksCreateOneRide(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_race") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + + // 第二個排程器共用同一個 repo 與建單器,等同另一個 pod 上的同一支程式。 + second := NewScheduledRideDispatcher(f.db, f.creator) + + // **重疊必須是造出來的,不能靠運氣**:如果只是兩個 goroutine 各跑一次 Tick, + // 先跑完的那個已經把狀態改成 dispatched,後跑的 FindDue 根本撈不到—— + // 那樣即使把認領的樂觀鎖整個拔掉,測試照樣是綠的(實測連跑三次都綠)。 + // 真正的併發是「兩邊都已經 FindDue 到同一筆、都還沒認領」, + // 所以這裡直接餵兩份 attempt_count=0 的相同快照進 dispatchOne。 + snapA, snapB := *row, *row + + start := make(chan struct{}) + var wg sync.WaitGroup + results := make([]bool, 2) + errs := make([]error, 2) + for i, pair := range []struct { + d *ScheduledRideDispatcher + snap *model.ScheduledRide + }{{f.disp, &snapA}, {second, &snapB}} { + wg.Add(1) + go func(idx int, d *ScheduledRideDispatcher, s *model.ScheduledRide) { + defer wg.Done() + <-start // 起跑線:兩邊盡量同時進入認領 + results[idx], errs[idx] = d.dispatchOne(context.Background(), s) + }(i, pair.d, pair.snap) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("排程器 %d 轉單出錯:%v", i, err) + } + } + if got := f.creator.callCount(); got != 1 { + t.Fatalf("兩個排程器同時處理同一筆,建單仍只能發生一次,得到 %d 次", got) + } + if results[0] == results[1] { + t.Fatalf("必須剛好一邊認領成功、一邊落空,得到 %v / %v", results[0], results[1]) + } + + after, _ := f.db.GetByID(row.ID) + if after.Status != constants.ScheduledRideStatusDispatched { + t.Fatalf("應為 dispatched,得到 %s", after.Status) + } + if after.AttemptCount != 1 { + t.Fatalf("只該有一次認領成功,attempt_count 應為 1,得到 %d", after.AttemptCount) + } +} + +// TestDispatcherRetriesTransientFailure 乘客到點時還在別的行程上: +// 不是永久失敗,要維持 pending 等下一輪,並把原因記在 last_error。 +func TestDispatcherRetriesTransientFailure(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_retry") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + f.creator.err = ErrActiveRideExists + + if n, err := f.disp.Tick(context.Background()); err != nil || n != 0 { + t.Fatalf("建單失敗時不該算成功:n=%d err=%v", n, err) + } + after, _ := f.db.GetByID(row.ID) + if after.Status != constants.ScheduledRideStatusPending { + t.Fatalf("暫時性失敗應維持 pending 等下一輪,得到 %s", after.Status) + } + if after.AttemptCount != 1 { + t.Fatalf("attempt_count 應為 1,得到 %d", after.AttemptCount) + } + if after.LastError == "" { + t.Fatalf("應記下失敗原因") + } + + // 下一輪乘客的行程結束了 → 轉單成功,last_error 應被清掉。 + f.creator.err = nil + if n, err := f.disp.Tick(context.Background()); err != nil || n != 1 { + t.Fatalf("下一輪應轉單成功:n=%d err=%v", n, err) + } + final, _ := f.db.GetByID(row.ID) + if final.Status != constants.ScheduledRideStatusDispatched { + t.Fatalf("應為 dispatched,得到 %s", final.Status) + } + if final.LastError != "" { + t.Fatalf("轉單成功後應清掉 last_error,得到 %q", final.LastError) + } +} + +// TestDispatcherFailsFastOnPermanentError 永久性錯誤(座標無效)要**立刻**判死, +// 不能佔著重試額度一路撐到約定時間才讓乘客發現沒車。 +func TestDispatcherFailsFastOnPermanentError(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_perm") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + f.creator.err = ErrInvalidCoords + + if n, err := f.disp.Tick(context.Background()); err != nil || n != 0 { + t.Fatalf("Tick:n=%d err=%v", n, err) + } + after, _ := f.db.GetByID(row.ID) + if after.Status != constants.ScheduledRideStatusFailed { + t.Fatalf("永久性錯誤應直接 failed,得到 %s(attempt=%d)", after.Status, after.AttemptCount) + } + if after.AttemptCount != 1 { + t.Fatalf("應只嘗試一次就判死,得到 %d", after.AttemptCount) + } + if after.LastError == "" { + t.Fatalf("應留下失敗原因") + } +} + +// TestDispatcherGivesUpAfterMaxAttempts 暫時性錯誤重試到上限後標 failed。 +func TestDispatcherGivesUpAfterMaxAttempts(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_giveup") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + f.creator.err = ErrActiveRideExists + + for i := 0; i < constants.ScheduledRideMaxAttempts; i++ { + if _, err := f.disp.Tick(context.Background()); err != nil { + t.Fatalf("第 %d 輪 Tick 失敗:%v", i+1, err) + } + } + after, _ := f.db.GetByID(row.ID) + if after.Status != constants.ScheduledRideStatusFailed { + t.Fatalf("重試用盡後應為 failed,得到 %s(attempt=%d)", after.Status, after.AttemptCount) + } + if after.AttemptCount != constants.ScheduledRideMaxAttempts { + t.Fatalf("attempt_count 應為 %d,得到 %d", + constants.ScheduledRideMaxAttempts, after.AttemptCount) + } + + // failed 之後就不該再被撿起來重試。 + before := f.creator.callCount() + if n, err := f.disp.Tick(context.Background()); err != nil || n != 0 { + t.Fatalf("failed 後不該再轉單:n=%d err=%v", n, err) + } + if f.creator.callCount() != before { + t.Fatalf("failed 後不該再呼叫建單") + } +} + +// TestDispatcherSkipsCancelled 已取消的預約到點時不該被轉單。 +func TestDispatcherSkipsCancelled(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_cancelled") + row := f.schedule(t, time.Now().Add(5*time.Minute)) + if _, err := f.svc.Cancel(f.customer, row.ID); err != nil { + t.Fatalf("取消失敗:%v", err) + } + + if n, err := f.disp.Tick(context.Background()); err != nil || n != 0 { + t.Fatalf("已取消的預約不該被轉單:n=%d err=%v", n, err) + } + if f.creator.callCount() != 0 { + t.Fatalf("已取消的預約不該呼叫建單,得到 %d 次", f.creator.callCount()) + } +} + +// TestDispatcherCarriesRideFields 轉單時要把預約上的目的地與車種原封不動帶進訂單—— +// 漏掉的話,乘客預約時填的目的地會在到點那一刻人間蒸發。 +func TestDispatcherCarriesRideFields(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_fields") + now := time.Now() + dropLat, dropLng := 25.0478, 121.5170 + row := f.scheduleAsOfEarlier(t, ScheduleRideInput{ + ScheduledAt: now.Add(5 * time.Minute), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + DropoffLat: &dropLat, DropoffLng: &dropLng, DropoffAddress: "台北車站", + RequiredVehicleType: constants.VehicleTypePet, + Note: "有一隻柴犬", + }) + + if n, err := f.disp.Tick(context.Background()); err != nil || n != 1 { + t.Fatalf("Tick:n=%d err=%v", n, err) + } + if len(f.creator.calls) != 1 { + t.Fatalf("應有一次建單呼叫,得到 %d", len(f.creator.calls)) + } + got := f.creator.calls[0] + if got.PickupAddress != "台北101" || got.PickupLat != 25.0330 { + t.Fatalf("上車點沒帶過去:%+v", got) + } + if got.DropoffAddress != "台北車站" { + t.Fatalf("目的地地址沒帶過去:%+v", got) + } + if got.DropoffLat == nil || *got.DropoffLat != dropLat { + t.Fatalf("目的地座標沒帶過去:%+v", got.DropoffLat) + } + if got.RequiredVehicleType != constants.VehicleTypePet { + t.Fatalf("指定車種沒帶過去:%q", got.RequiredVehicleType) + } + _ = row +} + +// TestDispatcherSkipsLongExpiredSchedules 早就過了約定時間的預約不該再轉單。 +// +// 觸發情境是**排程器停過一段時間**(部署、當機、DB 不可用):重新啟動時, +// FindDue 的條件若只有上界(scheduled_at <= now+lead),那些早就過期的 pending +// 會一次全部被撿起來轉成真訂單——乘客三天前預約的車,今天突然有司機開到他家樓下。 +func TestDispatcherSkipsLongExpiredSchedules(t *testing.T) { + f := newDispatcherFixture(t, "U_disp_expired") + + // 兩小時前就該出發的預約(模擬排程器停機期間錯過的那些)。 + // 建立時的「現在」要再往前推,否則會被最小前置時間擋下來—— + // 一筆過期的預約在真實系統裡也是這樣來的:建立時完全合法,是後來才過期的。 + expired, err := f.svc.createAt(f.customer, ScheduleRideInput{ + ScheduledAt: time.Now().Add(-2 * time.Hour), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, time.Now().Add(-4*time.Hour)) + if err != nil { + t.Fatalf("建立過期預約失敗:%v", err) + } + // 對照組:正常到點的那一筆,必須照常轉單。 + due := f.schedule(t, time.Now().Add(5*time.Minute)) + + n, tickErr := f.disp.Tick(context.Background()) + if tickErr != nil { + t.Fatalf("Tick 失敗:%v", tickErr) + } + + expiredAfter, _ := f.db.GetByID(expired.ID) + if expiredAfter.Status == constants.ScheduledRideStatusDispatched { + t.Fatalf("兩小時前就該出發的預約被轉成訂單了——乘客會莫名其妙收到一台車") + } + if expiredAfter.Status != constants.ScheduledRideStatusFailed { + t.Fatalf("過期太久的預約應標為 failed 讓乘客看得到,得到 %s", expiredAfter.Status) + } + if expiredAfter.LastError == "" { + t.Fatalf("過期失敗要留下原因") + } + + dueAfter, _ := f.db.GetByID(due.ID) + if dueAfter.Status != constants.ScheduledRideStatusDispatched { + t.Fatalf("正常到點的預約仍必須轉單,得到 %s", dueAfter.Status) + } + if n != 1 { + t.Fatalf("這一輪應只轉 1 筆(過期那筆不算),得到 %d", n) + } + if f.creator.callCount() != 1 { + t.Fatalf("過期那筆不該呼叫建單,總呼叫數應為 1,得到 %d", f.creator.callCount()) + } +} diff --git a/internal/service/scheduled_ride_test.go b/internal/service/scheduled_ride_test.go new file mode 100644 index 0000000..1efa5bc --- /dev/null +++ b/internal/service/scheduled_ride_test.go @@ -0,0 +1,257 @@ +package service + +import ( + "errors" + "testing" + "time" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/repository" +) + +// TestScheduledRideCreateValidation 建立預約的邊界:太近、太遠、座標、車種、備註長度。 +func TestScheduledRideCreateValidation(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewScheduledRideService(repository.NewScheduledRideRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_sched_valid", "預約乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) + base := ScheduleRideInput{ + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + } + + withTime := func(at time.Time) ScheduleRideInput { + in := base + in.ScheduledAt = at + return in + } + + // 太近:比最小前置時間還早。 + tooSoon := withTime(now.Add((constants.ScheduledRideMinLeadMinutes - 1) * time.Minute)) + if _, err := svc.createAt(me.ID, tooSoon, now); !errors.Is(err, ErrScheduleTooSoon) { + t.Fatalf("太近的預約應回 ErrScheduleTooSoon,得到 %v", err) + } + + // 沒給時間。 + if _, err := svc.createAt(me.ID, base, now); !errors.Is(err, ErrScheduleTooSoon) { + t.Fatalf("零值時間應回 ErrScheduleTooSoon,得到 %v", err) + } + + // 太遠:超過可預約天數。 + tooFar := withTime(now.AddDate(0, 0, constants.ScheduledRideMaxDaysAhead+1)) + if _, err := svc.createAt(me.ID, tooFar, now); !errors.Is(err, ErrScheduleTooFar) { + t.Fatalf("太遠的預約應回 ErrScheduleTooFar,得到 %v", err) + } + + // 剛好在最小前置時間上:可以建。 + ok := withTime(now.Add(constants.ScheduledRideMinLeadMinutes * time.Minute)) + if _, err := svc.createAt(me.ID, ok, now); err != nil { + t.Fatalf("剛好達到最小前置時間的預約應可建立,得到 %v", err) + } + + // 座標無效。 + badCoords := withTime(now.Add(2 * time.Hour)) + badCoords.PickupLat = 999 + if _, err := svc.createAt(me.ID, badCoords, now); !errors.Is(err, ErrInvalidCoords) { + t.Fatalf("無效座標應回 ErrInvalidCoords,得到 %v", err) + } + + // 車種不在白名單。 + badVehicle := withTime(now.Add(2 * time.Hour)) + badVehicle.RequiredVehicleType = "submarine" + if _, err := svc.createAt(me.ID, badVehicle, now); !errors.Is(err, ErrInvalidVehicleType) { + t.Fatalf("無效車種應回 ErrInvalidVehicleType,得到 %v", err) + } + + // 備註過長。 + longNote := withTime(now.Add(2 * time.Hour)) + longNote.Note = string(make([]rune, maxScheduleNoteRunes+1)) + if _, err := svc.createAt(me.ID, longNote, now); !errors.Is(err, ErrScheduleNoteTooLong) { + t.Fatalf("過長備註應回 ErrScheduleNoteTooLong,得到 %v", err) + } +} + +// TestScheduledRideCreateAllowsActiveRide 預約**不該**被「已有進行中訂單」擋住—— +// 現在正在搭車,跟三小時後要用車沒有衝突。這道檢查只該在到點轉單那一刻生效。 +func TestScheduledRideCreateAllowsActiveRide(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + rides := repository.NewRideRepository(db) + svc := NewScheduledRideService(repository.NewScheduledRideRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_sched_active", "行程中乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + mustCreateRide(t, rides, me.ID, constants.RideStatusPickedUp) + + now := time.Now() + if _, err := svc.createAt(me.ID, ScheduleRideInput{ + ScheduledAt: now.Add(3 * time.Hour), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, now); err != nil { + t.Fatalf("行程進行中仍應能建立未來的預約,得到 %v", err) + } +} + +// TestScheduledRideCancel 取消:pending 可取消;已轉單的回 ErrScheduleNotCancellable +// (不能宣稱取消成功——那張真訂單還在派單池裡)。 +func TestScheduledRideCancel(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + rides := repository.NewRideRepository(db) + repo := repository.NewScheduledRideRepository(db) + svc := NewScheduledRideService(repo) + + me, err := customers.FindOrCreateByLineUserID("U_sched_cancel", "取消乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + other, err := customers.FindOrCreateByLineUserID("U_sched_cancel_other", "別人") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + now := time.Now() + row, err := svc.createAt(me.ID, ScheduleRideInput{ + ScheduledAt: now.Add(3 * time.Hour), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, now) + if err != nil { + t.Fatalf("建立預約失敗:%v", err) + } + + // 別人的預約:讀不到也取消不了。 + if _, err := svc.Cancel(other.ID, row.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("取消別人的預約應回 ErrNotFound,得到 %v", err) + } + + cancelled, err := svc.Cancel(me.ID, row.ID) + if err != nil { + t.Fatalf("取消預約失敗:%v", err) + } + if cancelled.Status != constants.ScheduledRideStatusCancelled { + t.Fatalf("取消後狀態應為 cancelled,得到 %s", cancelled.Status) + } + + // 重複取消:已不是 pending。 + if _, err := svc.Cancel(me.ID, row.ID); !errors.Is(err, ErrScheduleNotCancellable) { + t.Fatalf("重複取消應回 ErrScheduleNotCancellable,得到 %v", err) + } + + // 已轉單的預約同樣不可取消。 + dispatchedRow, err := svc.createAt(me.ID, ScheduleRideInput{ + ScheduledAt: now.Add(4 * time.Hour), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, now) + if err != nil { + t.Fatalf("建立第二筆預約失敗:%v", err) + } + ride := mustCreateRide(t, rides, me.ID, constants.RideStatusRequested) + if ok, err := repo.MarkDispatched(dispatchedRow.ID, ride.ID); err != nil || !ok { + t.Fatalf("標記已轉單失敗:ok=%v err=%v", ok, err) + } + if _, err := svc.Cancel(me.ID, dispatchedRow.ID); !errors.Is(err, ErrScheduleNotCancellable) { + t.Fatalf("已轉單的預約應回 ErrScheduleNotCancellable,得到 %v", err) + } +} + +// TestScheduledRideList upcoming 只回 pending,全部則含已取消/已轉單;近的在前。 +func TestScheduledRideList(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewScheduledRideService(repository.NewScheduledRideRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_sched_list", "清單乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + now := time.Now() + mk := func(offset time.Duration) int64 { + row, err := svc.createAt(me.ID, ScheduleRideInput{ + ScheduledAt: now.Add(offset), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, now) + if err != nil { + t.Fatalf("建立預約失敗:%v", err) + } + return row.ID + } + later := mk(8 * time.Hour) + sooner := mk(3 * time.Hour) + toCancel := mk(5 * time.Hour) + + if _, err := svc.Cancel(me.ID, toCancel); err != nil { + t.Fatalf("取消失敗:%v", err) + } + + upcoming, err := svc.List(me.ID, true) + if err != nil { + t.Fatalf("讀 upcoming 清單失敗:%v", err) + } + if len(upcoming) != 2 { + t.Fatalf("upcoming 應只有 2 筆 pending,得到 %d", len(upcoming)) + } + if upcoming[0].ID != sooner || upcoming[1].ID != later { + t.Fatalf("upcoming 應近的在前,得到 %d, %d", upcoming[0].ID, upcoming[1].ID) + } + + all, err := svc.List(me.ID, false) + if err != nil { + t.Fatalf("讀全部清單失敗:%v", err) + } + if len(all) != 3 { + t.Fatalf("全部應有 3 筆,得到 %d", len(all)) + } +} + +// TestScheduledRideLimit 未完成的預約數達上限後不能再建。 +// +// 沒有上限的清單遲早會被腳本灌爆;而且乘客端的「即將到來」是一份要全部渲染的清單, +// 幾百筆會讓那個畫面直接不能用。 +func TestScheduledRideLimit(t *testing.T) { + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + svc := NewScheduledRideService(repository.NewScheduledRideRepository(db)) + + me, err := customers.FindOrCreateByLineUserID("U_sched_limit", "上限乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + + now := time.Now() + mk := func() error { + _, err := svc.createAt(me.ID, ScheduleRideInput{ + ScheduledAt: now.Add(3 * time.Hour), + PickupLat: 25.0330, PickupLng: 121.5654, PickupAddress: "台北101", + }, now) + return err + } + + for i := 0; i < maxPendingSchedules; i++ { + if err := mk(); err != nil { + t.Fatalf("第 %d 筆就失敗了:%v", i+1, err) + } + } + if err := mk(); !errors.Is(err, ErrTooManySchedules) { + t.Fatalf("超過上限應回 ErrTooManySchedules,得到 %v", err) + } + + // **取消一筆就該讓出一個名額**——上限算的是「還沒轉單的」,不是「這輩子建過的」。 + rows, err := svc.List(me.ID, true) + if err != nil || len(rows) == 0 { + t.Fatalf("讀清單失敗:len=%d err=%v", len(rows), err) + } + if _, err := svc.Cancel(me.ID, rows[0].ID); err != nil { + t.Fatalf("取消失敗:%v", err) + } + if err := mk(); err != nil { + t.Fatalf("取消一筆後應該又能建,得到 %v", err) + } +} diff --git a/scripts/seed_demo_data.sh b/scripts/seed_demo_data.sh new file mode 100755 index 0000000..0de09c4 --- /dev/null +++ b/scripts/seed_demo_data.sh @@ -0,0 +1,167 @@ +#!/bin/sh +# 常用地點+預約行程的示範資料。供 App 端測試「住家/公司快速帶入」與「我的預約」。 +# +# 主路徑刻意走**真實 HTTP API**(不是直接寫 DB):這樣塞資料的同時也端到端驗過了那些端點, +# 而不是塞出一份「只有 SQL 造得出來、API 其實不接受」的資料。 +# 只有 dispatched/failed 這兩種狀態非走 SQL 不可——它們是排程器到點後才會產生的結果, +# 沒有任何 API 能直接建出來,而 App 的預約清單要看得到這兩種狀態長什麼樣。 +# +# 用法: +# 前提:後端已啟動(docker compose up -d,或本機 go run ./cmd/server) +# sh scripts/seed_demo_data.sh +# +# 可覆寫: +# API_URL 後端位址(預設 http://localhost:8080) +# SEED_USER 示範乘客的 line_user_id(預設 demo-customer-1) +# SEED_PASS 密碼(預設 demo123456) +# PSQL_DSN psql 連線字串;未設時跳過 dispatched/failed 那兩筆 +# +# 重複執行:常用地點是覆蓋(home/work 是插槽),**但預約會累積**—— +# 每跑一次就多四筆。要乾淨重來的話先清掉: +# DELETE FROM scheduled_rides WHERE customer_id = ( +# SELECT id FROM customers WHERE line_user_id = 'demo-customer-1'); +set -e + +API="${API_URL:-http://localhost:8080}" +USER_ID="${SEED_USER:-demo-customer-1}" +PASS="${SEED_PASS:-demo123456}" + +fail() { echo "✗ $1"; exit 1; } +json_str() { echo "$1" | grep -o "\"$2\":\"[^\"]*" | cut -d'"' -f4; } +json_num() { echo "$1" | grep -o "\"$2\":[0-9]*" | head -1 | cut -d: -f2; } + +echo "== 後端健康檢查 ==" +curl -sf "$API/healthz" >/dev/null || fail "後端未就緒($API)" + +echo "== 示範乘客:註冊(已存在則改登入)==" +REG=$(curl -s -X POST "$API/api/customer/register" \ + -H 'Content-Type: application/json' \ + -d "{\"line_user_id\":\"$USER_ID\",\"name\":\"示範乘客\",\"password\":\"$PASS\"}") +TOKEN=$(json_str "$REG" token) +if [ -z "$TOKEN" ]; then + LOGIN=$(curl -s -X POST "$API/api/customer/login" \ + -H 'Content-Type: application/json' \ + -d "{\"line_user_id\":\"$USER_ID\",\"password\":\"$PASS\"}") + TOKEN=$(json_str "$LOGIN" token) + [ -n "$TOKEN" ] || fail "註冊與登入都失敗:$REG / $LOGIN" + echo "(帳號已存在,改用登入)" +fi +CUSTOMER_ID=$(json_num "$REG" customer_id) +[ -n "$CUSTOMER_ID" ] || CUSTOMER_ID=$(json_num "$LOGIN" customer_id) +echo "customer_id=$CUSTOMER_ID" + +AUTH="Authorization: Bearer $TOKEN" + +# ---------- 常用地點 ---------- +# home/work 是覆蓋語意,這支腳本因此可以重複執行而不會越跑越多筆。 +place() { + RES=$(curl -s -X POST "$API/api/customer/places" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"kind\":\"$1\",\"label\":\"$2\",\"address\":\"$3\",\"lat\":$4,\"lng\":$5}") + ID=$(json_num "$RES" id) + [ -n "$ID" ] || fail "建立地點失敗($2):$RES" + echo " ✓ $2(id=$ID)" +} + +echo "== 常用地點 ==" +place home "住家" "台北市大安區和平東路二段106號" 25.0261 121.5435 +place work "公司" "台北市內湖區瑞光路513巷" 25.0797 121.5750 +place custom "健身房" "台北市信義區松高路12號" 25.0400 121.5680 +place custom "媽媽家" "新北市板橋區文化路一段188號" 25.0210 121.4700 + +echo "== 常用地點現況 ==" +curl -s -H "$AUTH" "$API/api/customer/places" | head -c 400; echo + +# ---------- 預約行程(pending,走 API)---------- +# 時間一律用「相對現在」算,腳本才不會過幾天就因為時間變成過去而全部被擋下來。 +iso_in() { + # $1 = 幾分鐘後。 + # BSD date(macOS)走 -v,GNU date(Linux)走 -d。 + # **只在 macOS 上實跑過**,GNU 那條退路是照文件寫的、未實測。 + if date -u -v+"$1"M '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null; then :; else + date -u -d "+$1 minutes" '+%Y-%m-%dT%H:%M:%SZ' + fi +} + +schedule() { + AT=$(iso_in "$1") + RES=$(curl -s -X POST "$API/api/customer/scheduled-rides" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"scheduled_at\":\"$AT\",\"pickup_lat\":$2,\"pickup_lng\":$3,\"pickup_address\":\"$4\",\"dropoff_lat\":$5,\"dropoff_lng\":$6,\"dropoff_address\":\"$7\",\"required_vehicle_type\":\"$8\",\"note\":\"$9\"}") + ID=$(json_num "$RES" id) + [ -n "$ID" ] || fail "建立預約失敗($4 → $7):$RES" + echo " ✓ $AT|$4 → $7(id=$ID)" + LAST_SCHEDULE_ID="$ID" +} + +echo "== 預約行程(pending)==" +# 90 分鐘後:住家 → 公司,不指定車種 +schedule 90 25.0261 121.5435 "台北市大安區和平東路二段106號" 25.0797 121.5750 "台北市內湖區瑞光路513巷" "" "明早上班" +# 明天同時間:公司 → 住家 +schedule 1500 25.0797 121.5750 "台北市內湖區瑞光路513巷" 25.0261 121.5435 "台北市大安區和平東路二段106號" "" "下班回家" +# 三天後:帶寵物(指定寵物車,驗清潔費那條路徑) +schedule 4320 25.0261 121.5435 "台北市大安區和平東路二段106號" 25.0330 121.5654 "台北市信義區市府路1號" "pet" "帶柴犬看醫生" +# 一週後:要拿來示範「已取消」 +schedule 10080 25.0400 121.5680 "台北市信義區松高路12號" 25.0210 121.4700 "新北市板橋區文化路一段188號" "" "週末回娘家" +TO_CANCEL="$LAST_SCHEDULE_ID" + +echo "== 取消其中一筆(示範 cancelled 狀態)==" +curl -sf -X POST "$API/api/customer/scheduled-rides/$TO_CANCEL/cancel" -H "$AUTH" >/dev/null \ + && echo " ✓ 已取消 id=$TO_CANCEL" + +# ---------- dispatched/failed(只能走 SQL)---------- +if [ -z "$PSQL_DSN" ]; then + echo + echo "== 略過 dispatched/failed 兩筆 ==" + echo " 這兩種狀態是排程器到點後才產生的,沒有 API 可以直接建。" + echo " 要一併塞的話設 PSQL_DSN 再跑一次,例如:" + echo " PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' sh scripts/seed_demo_data.sh" +else + echo + echo "== dispatched/failed(走 SQL)==" + psql "$PSQL_DSN" -v ON_ERROR_STOP=1 -q <