用 Go + PostgreSQL(PostGIS) + Redis + LINE Bot 打造的叫車派遣系統。客戶在 LINE 傳一則「位置訊息」就能叫車,系統即時找出最近的待命司機、推播接單邀請、產生 Google Maps 導航連結,並全程回報司機位置與預估抵達時間(ETA),最後留下行程軌跡與日報表。
這是一個學習/作品專案,重點在把即時地理查詢、分散式搶單鎖、路徑 ETA、地理圍籬等後端主題用一條完整業務流程串起來。完整開發規格見 docs/spec.md,技術決策見 docs/decisions.md。
客戶 LINE ──叫車(位置訊息)──┐
├─► LINE Webhook ─► Go 後端 (Gin) ─┐
司機 LINE / LIFF ─接單/回報─┘ │
│
┌───────────────────────┬──────────────────────┬───────┘
▼ ▼ ▼
Redis GEO PostgreSQL + PostGIS OSRM 路徑引擎
(司機即時位置、 (訂單狀態機、軌跡、 (最短路徑 + ETA,
最近車查詢、搶單鎖) 電子圍籬、日報表) 預設公開 router)
分層遵循 Handler → Service → Repository → DB,與 Laravel 的 Controller/Service/Repository 對應,方便從 PHP 背景切換閱讀。
一趟完整行程會經過以下狀態機(internal/constants/ride.go):
REQUESTED(0) ─► ASSIGNED(1) ─► ACCEPTED(2) ─► PICKED_UP(3) ─► COMPLETED(4)
(或任一步 CANCELLED(9))
| # | 步驟 | 觸發 | 系統行為 |
|---|---|---|---|
| 1 | 叫車 | 客戶在 LINE 傳位置 | webhook 驗簽 → 建立/更新客戶 → 建立訂單 REQUESTED,pickup 存成 PostGIS geography 點 |
| 2 | 派單 | 建單後非同步觸發 | Redis GEOSEARCH 找 pickup 半徑內最近 N 台待命司機(濾掉離線)→ 訂單轉 ASSIGNED → 推播接單邀請(含 postback 按鈕) |
| 3 | 搶單 | 司機按「接受派單」 | Redis SETNX 搶單鎖,只有第一位搶到的司機成單 → 訂單轉 ACCEPTED、寫 driver_id、司機轉載客中;其餘司機收到「手慢了」 |
| 4 | 導航 | 接單成功 | 回傳 https://www.google.com/maps/dir/?... deep link,司機一點開啟手機 Google Maps 導航到上車點 |
| 5 | ETA 回報 | 司機位置更新 | OSRM 算「司機→上車點」實際路網時間,推播客戶「距您 X 公尺、約 Y 分鐘抵達」 |
| 6 | 抵達/上車 | 司機進入上車點 100m | PostGIS ST_DWithin 電子圍籬自動判定抵達;司機按上車 → 訂單轉 PICKED_UP,開始記錄軌跡到 ride_tracks |
| 7 | 下車/完成 | 司機按完成 | 訂單轉 COMPLETED,PostGIS ST_Length 由軌跡算里程;計費里程取「軌跡 vs OSRM pickup→dropoff 路線」大者(F3,軌跡稀疏時的退路),依當前費率算好車資/手續費/司機實得,快照定格寫進該筆 ride |
| 8 | 報表 | 隨時查詢 | 軌跡回放輸出 GeoJSON;日/月報表聚合各司機趟數、里程、營業額/手續費/應付總公司(=手續費+月會費);會費可產生 membership_invoices 帳單 |
cp .env.example .env # LINE 憑證可留空,留空時 API 測試免簽章
docker compose up --build -d # 起 app + postgis + redis
curl http://localhost:8080/healthz # → {"status":"ok"}
sh scripts/smoke_test.sh # 跑完整 M1-M4 流程(需乾淨 DB)
make test # 整合測試(testcontainers 起真 Redis/PostGIS,需 Docker)
# 選用:起 20 台模擬司機灌位置(勿與 smoke_test 同時跑,會互相搶單)
docker compose --profile simulator up -d simulator重跑
smoke_test.sh前建議先docker compose down -v清掉舊資料,避免殘留訂單干擾。
docker compose up -d postgis redis
DB_HOST=127.0.0.1 DB_PORT=5433 REDIS_ADDR=127.0.0.1:6380 go run ./cmd/server三個變數都要覆寫:
.env裡的postgis/redis是 compose 網路內的主機名, 在主機上解析不到。Redis 一定要用 6380,不要用 6379:Mac 上常駐的
redis-server綁127.0.0.1:6379, Docker 綁*:6379,localhost 一律由本機那台接手——連錯不會報錯,服務照跑, 但你在容器裡redis-cli KEYS什麼都查不到(狀態其實寫在本機那台)。 這也是 postgis 讓開 5432 的同一個理由。用lsof -nP -iTCP:6379 -sTCP:LISTEN可以看誰在聽。
| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | /healthz |
健康檢查(DB + Redis) |
| POST | /webhook/line |
LINE webhook(受 X-Line-Signature 保護;回應含 ride_ids 方便測試) |
| POST | /api/driver/register |
註冊司機 |
| POST | /api/driver/location |
司機回報位置(進 Redis GEO;行程中同時寫軌跡) |
| POST | /api/rides/:id/accept |
接單(搶單鎖) |
| POST | /api/rides/:id/pickup |
客戶上車 |
| POST | /api/rides/:id/complete |
完成行程 |
| GET | /api/rides/:id/track |
軌跡回放(GeoJSON Feature) |
| POST | /api/customer/register login、POST /api/rides |
乘客 App:註冊/登入、叫車(可帶 dropoff_lat/lng) |
| GET | /api/driver/earnings?month=YYYY-MM |
司機收入(趟數/營業額/手續費/實得/會費/應付總公司) |
| GET/POST | /api/rides/:id/messages?after= |
行程內對話:歷史/發話(僅本趟乘客/司機;即時遞送走 WS chat.message) |
| POST | /api/rides/:id/lost-items |
乘客對已完成行程建遺失物協尋單(處理費=車資×%,建單快照) |
| POST | /api/customer/rides/estimate |
建單前車資預估(純唯讀,與完成計費共用同一套費率,R) |
| POST | /api/customer/rides/:id/rating |
乘客評分司機 1–5 星+評論,一趟一評(B5) |
| 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):
| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | /api/admin/login |
後台登入(種子 admin/admin) |
| GET | /api/admin/rides?status=&limit=&offset=&from=&to=&q= |
訂單列表(伺服器端分頁,回 total) |
| GET/POST | /api/admin/rides/:id、/api/admin/rides/:id/cancel |
訂單詳情(軌跡+事件+停靠點+乘客評分)/強制取消 |
| GET/PATCH | /api/admin/drivers、/api/admin/drivers/:id/status |
司機列表(含 rating_avg/rating_count)/啟停 |
| POST | /api/admin/drivers/:id/vehicle-review |
車輛審核:核准/退回(退回須附原因,O5) |
| GET | /api/admin/reports/daily?date=、/reports/monthly?month= |
日/月報表(含金額) |
| GET/PUT | /api/admin/settings/dispatch、/api/admin/settings/fees |
派單參數/費率設定(費率限 superadmin) |
| GET/POST/PATCH | /api/admin/membership-invoices[...] |
會費帳單:列表/產生/標記已繳 |
| GET/POST/PATCH | /api/admin/admins[...] |
後台帳號管理(superadmin) |
| 表 | 用途 | 地理欄位 |
|---|---|---|
drivers |
司機、狀態(離線/待命/載客中) | — |
customers |
客戶(依 LINE userId 唯一) | — |
rides |
訂單狀態機、上/下車點、里程、ETA、計費快照(fare_amount_cents/commission_amount_cents/driver_net_amount_cents) |
pickup_point / dropoff_point geography(Point,4326) |
ride_tracks |
行程軌跡(按月分區) | location geography(Point,4326) |
fleet_settings |
費率設定單列(起步價/每公里/最低車資/手續費 bps/月會費/遺失物處理費 bps,金額存分) | — |
membership_invoices |
會費帳單(每司機每月一張,金額快照、UNIQUE(driver_id, period) 防重複) |
— |
ride_messages |
乘客↔司機行程內對話(WS 即時遞送的歷史真源) | — |
lost_item_requests |
遺失物協尋單(處理費快照、狀態機 open→found→paid→returned/closed、部分唯一索引擋重複未結案單) | — |
admins |
後台帳號(角色 viewer/dispatcher/superadmin) | — |
計費設計:金額全系統存「分」(整數,避免浮點),手續費存 bps(1500=15%)。車資/手續費在行程完成當下依當前費率算好、快照定格寫進該筆 ride,日後調費率不影響歷史帳。詳見 docs/TODO.md「F. 手續費/會費/營運報表」。
Redis 鍵:drivers:geo(GEO 位置集合)、driver:{id}:loc(最新位置+時間戳)、ride:{id}:lock(搶單鎖,30s TTL)、ratelimit:{lineUserId}(叫車限流)。
本專案已在本機 Docker 完整跑通並驗證(go build / go vet / go test 皆綠):
- ✅ 端到端流程:
smoke_test.sh於乾淨 DB 走完「叫車→派單→接單→上車→軌跡→完成→報表」,並斷言接單真的成功、日報表確實含該司機。 - ✅ 搶單鎖:
SETNX確保同一單只有一位司機成單。 - ✅ LINE 簽章安全:以真實 HMAC-SHA256 簽章請求驗證——正確簽章
200、偽造簽章401、缺簽章401。 - ✅ PostGIS:pickup 點、
ST_DWithin圍籬、ST_Length里程、ST_AsGeoJSON回放均正確輸出。 - ✅ App 推播送出路徑(2026-07-30):
scripts/push_e2e.sh/push_cancel_e2e.sh以假 device token 走完整鏈路,從 log 判定收件人——10 則推播全部推對人 (行程狀態、對話、協尋各步驟),取消三條另驗到「乘客自己取消完全沒有推播」。 不需 Firebase 憑證(走LogPusherstub);憑證到位後不必改程式碼。
本機測試不需要 LINE 憑證;要接真帳號跑手機叫車時:
- 在 LINE Developers 建 Messaging API channel,取得 Channel secret 與 Channel access token,填入
.env。 - 建一個 LIFF app(司機定位頁),Endpoint 指向
https://<你的網域>/liff/,取得LIFF_ID。 - 用 ngrok/cloudflared 對外:
ngrok http 8080,把 webhook URL 設為https://xxxx.ngrok.io/webhook/line。 - 手機用 LINE 加官方帳號好友,傳一則位置訊息即可叫車。
完整前置清單見 docs/checklist.md。
2026-07-16 加入的 N/O/P 與寵物車清潔費、司機聯絡方式已全數實作並合併進 main (PR #35–#43),本段先前寫「都還沒實作」是過期資訊,2026-07-27 修正。 各章完整規格與驗收紀錄見 docs/TODO.md。
剩下的多屬量體上升後才需要或待產品/外部資源,勿過早做:
- P4 #19 付款金流:完成後付款仍是佔位(遺失物處理費目前也是記帳式確認,無真金流)。 需先定金流方案,屬產品決策。
- P4 #20
/metrics:Prometheus 指標(派單成功率、接單耗時、在線司機數、API 延遲)。 - P4 #18
/api/driver/rides:司機歷史列表;收入面已由GET /api/driver/earnings(F7)涵蓋, 缺的是逐趟明細。 - F9-7
rides月分割:量體達千萬級時依completed_at做 declarative partitioning。 drivers/membership_invoices真分頁:逼近MaxListRows=5000時比照rides改 offset/keyset 伺服器端分頁(含前端)。- F3 強化(可選):軌跡稀疏偵測目前用「軌跡 vs 路線取大者」,是否再加 「後台手動校正單筆車資」待產品定。
- 評分的營運動作(B5 下游):目前三端都只「看得到」評分,沒有低分司機的處理流程 (通知/停權/申訴)。等實際累積評分、營運說得出要做什麼再開——做在前面只會做出沒人用的流程。
- LIFF 背景定位:網頁
watchPosition需頁面在前景,司機切到導航時位置更新會變慢;正式車隊需原生 App 背景定位。 - LINE push 月額度:設計上盡量用 reply token(互動回覆不計額度);未設 token 時 push 自動略過。
- OSRM:預設用公開
router.project-osrm.orgdemo server、無即時路況;自架請改OSRM_URL並參考 docs/spec.md §4.2。
Go 1.25 · Gin · GORM · golang-migrate · PostgreSQL 16 + PostGIS 3.4 · Redis 7 · LINE Messaging API · OSRM · Docker Compose
cmd/server 服務進入點與路由組裝
cmd/simulator 司機模擬器(灌位置、壓測用)
internal/handler HTTP handler(webhook、driver、ride、report、health)
internal/service 業務邏輯(dispatch 派單、ride 訂單、tracking 追蹤、eta)
internal/repository 資料存取(GORM + 原生 PostGIS SQL)
internal/redis Redis GEO / 搶單鎖 / 限流封裝
internal/line LINE Messaging API 輕量封裝
internal/osrm OSRM 路徑 client
internal/middleware LINE 簽章驗證
db/migrations golang-migrate SQL(啟動時自動套用)
web/liff 司機 LIFF 定位頁
scripts/smoke_test.sh 端到端煙霧測試