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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions db/migrations/000025_create_customer_saved_places.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS customer_saved_places;
26 changes: 26 additions & 0 deletions db/migrations/000025_create_customer_saved_places.up.sql
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions db/migrations/000026_create_scheduled_rides.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS scheduled_rides;
44 changes: 44 additions & 0 deletions db/migrations/000026_create_scheduled_rides.up.sql
Original file line number Diff line number Diff line change
@@ -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);
134 changes: 134 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions internal/constants/saved_place.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading