diff --git a/db/migrations/000024_add_ride_message_client_id.down.sql b/db/migrations/000024_add_ride_message_client_id.down.sql new file mode 100644 index 0000000..56d7c1f --- /dev/null +++ b/db/migrations/000024_add_ride_message_client_id.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS uq_ride_messages_client_msg_id; +ALTER TABLE ride_messages DROP COLUMN IF EXISTS client_msg_id; diff --git a/db/migrations/000024_add_ride_message_client_id.up.sql b/db/migrations/000024_add_ride_message_client_id.up.sql new file mode 100644 index 0000000..58c26f8 --- /dev/null +++ b/db/migrations/000024_add_ride_message_client_id.up.sql @@ -0,0 +1,18 @@ +-- 訊息送出的冪等鍵(client_msg_id)。 +-- +-- 為什麼需要它:App 送出訊息逾時後,「後端其實收到了、只是回應遺失」與 +-- 「後端沒收到」在客戶端看起來完全一樣。其他寫入路徑(接單/取消/協尋/評分) +-- 都能靠「查一次後端狀態」對帳,因為那些狀態在後端是唯一的; +-- **訊息不是**——「同內容再送一次」本來就是合法行為, +-- 所以 App 端無法只靠補讀分辨「上一次其實送出了」與「使用者真的想再說一次」。 +-- 由客戶端產生一個鍵、後端據此去重,是唯一能兩者兼顧的做法。 +-- +-- 允許 NULL:既有訊息沒有這個鍵,且 LINE webhook 之類非 App 來源也不需要帶。 +ALTER TABLE ride_messages ADD COLUMN IF NOT EXISTS client_msg_id VARCHAR(64); + +-- 去重範圍是「同一趟行程的同一位發話者」——**不是全表唯一**。 +-- 鍵由客戶端產生,跨使用者撞號不該讓後方那位發不出訊息。 +-- partial index(WHERE NOT NULL)讓沒帶鍵的訊息完全不受這條約束。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_ride_messages_client_msg_id + ON ride_messages (ride_id, sender_role, sender_id, client_msg_id) + WHERE client_msg_id IS NOT NULL; diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 1a44113..147ea82 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -57,12 +57,17 @@ func (h *ChatHandler) Send(c *gin.Context) { } var req struct { Body string `json:"body"` + // ClientMsgID 客戶端產生的冪等鍵(選填)。帶同一個鍵重送會拿回既有那筆訊息, + // 不會多出一則——App 送出逾時後就是靠它安全重試(見 ChatService.SendWithClientID)。 + ClientMsgID string `json:"client_msg_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "參數錯誤"}) return } - msg, err := h.chat.Send(role, middleware.SubjectIDFromCtx(c), rideID, req.Body) + msg, err := h.chat.SendWithClientID( + role, middleware.SubjectIDFromCtx(c), rideID, req.Body, req.ClientMsgID, + ) if err != nil { c.JSON(chatStatusForErr(err), gin.H{"error": err.Error()}) return @@ -73,7 +78,8 @@ func (h *ChatHandler) Send(c *gin.Context) { // chatStatusForErr 對話錯誤對應 HTTP 狀態碼。 func chatStatusForErr(err error) int { switch { - case errors.Is(err, service.ErrEmptyMessage), errors.Is(err, service.ErrMessageTooLong): + case errors.Is(err, service.ErrEmptyMessage), errors.Is(err, service.ErrMessageTooLong), + errors.Is(err, service.ErrClientMsgIDTooLong): return http.StatusBadRequest default: return readStatusForErr(err) diff --git a/internal/model/models.go b/internal/model/models.go index 9f86c7b..065f3e8 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -266,6 +266,10 @@ type RideMessage struct { SenderID int64 `gorm:"column:sender_id;not null" json:"sender_id"` Body string `gorm:"not null" json:"body"` CreatedAt time.Time `gorm:"not null" json:"created_at"` + // ClientMsgID 客戶端產生的冪等鍵;nil = 沒帶(既有訊息、LINE webhook 等非 App 來源)。 + // 送出逾時後 App 用同一個鍵重送,後端據此回既有那筆而不新增—— + // 訊息在後端沒有唯一狀態,這是唯一能分辨「其實送出了」與「想再說一次」的辦法。 + ClientMsgID *string `gorm:"column:client_msg_id" json:"client_msg_id,omitempty"` } func (RideMessage) TableName() string { diff --git a/internal/repository/ride_message.go b/internal/repository/ride_message.go index f91c1ac..c7d64b5 100644 --- a/internal/repository/ride_message.go +++ b/internal/repository/ride_message.go @@ -25,6 +25,27 @@ func (r *RideMessageRepository) Create(m *model.RideMessage) error { return r.db.Create(m).Error } +// FindByClientMsgID 查同一趟行程、同一位發話者、同一個冪等鍵的既有訊息; +// 沒有時回 (nil, nil)。**去重範圍刻意不含全表**——鍵由客戶端產生, +// 跨使用者撞號不該讓後方那位發不出訊息。 +func (r *RideMessageRepository) FindByClientMsgID(rideID int64, senderRole string, senderID int64, clientMsgID string) (*model.RideMessage, error) { + if clientMsgID == "" { + return nil, nil + } + var m model.RideMessage + err := r.db.Where( + "ride_id = ? AND sender_role = ? AND sender_id = ? AND client_msg_id = ?", + rideID, senderRole, senderID, clientMsgID, + ).First(&m).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + // ListByRide 依 id 正序回傳該行程的訊息;afterID > 0 時只取其後的訊息(增量補讀)。 // limit <= 0 或超過 MaxListRows 時以 MaxListRows 為準。 func (r *RideMessageRepository) ListByRide(rideID, afterID int64, limit int) ([]model.RideMessage, error) { diff --git a/internal/service/chat.go b/internal/service/chat.go index 32b37a1..6eb9191 100644 --- a/internal/service/chat.go +++ b/internal/service/chat.go @@ -15,13 +15,18 @@ import ( ) var ( - ErrEmptyMessage = errors.New("訊息內容不可為空") - ErrMessageTooLong = errors.New("訊息長度超過上限") + ErrEmptyMessage = errors.New("訊息內容不可為空") + ErrMessageTooLong = errors.New("訊息長度超過上限") + ErrClientMsgIDTooLong = errors.New("訊息識別碼過長") ) // chatMaxRunes 單則訊息長度上限(rune 數;DB 另有 char_length ≤ 1000 的最後防線)。 const chatMaxRunes = 500 +// chatMaxClientMsgIDLen 冪等鍵長度上限,對齊 DB 的 VARCHAR(64)。 +// 客戶端只需要一個「這一次送出」的識別碼,UUID/時間戳+隨機數都遠短於這個數。 +const chatMaxClientMsgIDLen = 64 + // ChatService 乘客↔司機行程內對話:訊息持久化 + WebSocket 即時遞送(chat.message) // + App 推播給**收訊那一方**(對方 App 不在前景時 WS 早就斷了)。 type ChatService struct { @@ -43,7 +48,22 @@ func (s *ChatService) SetAppNotifier(d *notify.Dispatcher) { // Send 驗證發話者是該趟行程的乘客/被指派司機後寫入訊息,並即時推播給行程雙方。 // 行程任何狀態皆可傳訊(完成後的遺失物協尋也走同一條對話)。 +// +// 不帶冪等鍵的舊行為:每次呼叫都是一則新訊息。App 端請改用 [SendWithClientID]。 func (s *ChatService) Send(role string, senderID, rideID int64, body string) (*model.RideMessage, error) { + return s.SendWithClientID(role, senderID, rideID, body, "") +} + +// SendWithClientID 同 [Send],但帶客戶端產生的冪等鍵。 +// +// **同一趟行程、同一位發話者、同一個鍵再送一次 → 回既有那筆,不新增、也不重播** +// (WS 與 App 推播都不再發第二次——對方已經收到過了,重播只會讓他看到同一句話兩次)。 +// +// 這支存在的理由:訊息送出逾時後,「後端其實收到了、只是回應遺失」與「後端沒收到」 +// 在客戶端看起來一樣,而訊息在後端**沒有唯一狀態**可查(「同內容再送一次」本來合法), +// 所以無法像接單/取消/協尋/評分那樣靠查詢對帳。鍵由客戶端給、後端據此去重, +// 是唯一能同時支援「安全重試」與「使用者真的想再說一次」的做法。 +func (s *ChatService) SendWithClientID(role string, senderID, rideID int64, body, clientMsgID string) (*model.RideMessage, error) { body = strings.TrimSpace(body) if body == "" { return nil, ErrEmptyMessage @@ -51,6 +71,10 @@ func (s *ChatService) Send(role string, senderID, rideID int64, body string) (*m if utf8.RuneCountInString(body) > chatMaxRunes { return nil, ErrMessageTooLong } + clientMsgID = strings.TrimSpace(clientMsgID) + if len(clientMsgID) > chatMaxClientMsgIDLen { + return nil, ErrClientMsgIDTooLong + } ride, err := s.rides.GetByID(rideID) if err != nil { return nil, ErrNotFound @@ -58,6 +82,12 @@ func (s *ChatService) Send(role string, senderID, rideID int64, body string) (*m if err := authorizeRideParticipant(ride, role, senderID); err != nil { return nil, err } + // 授權通過後才查鍵:不能讓外人拿鍵去試探別人那趟有沒有這則訊息。 + if existing, err := s.messages.FindByClientMsgID(rideID, role, senderID, clientMsgID); err != nil { + return nil, err + } else if existing != nil { + return existing, nil + } msg := &model.RideMessage{ RideID: rideID, SenderRole: role, @@ -65,7 +95,16 @@ func (s *ChatService) Send(role string, senderID, rideID int64, body string) (*m Body: body, CreatedAt: time.Now(), } + if clientMsgID != "" { + msg.ClientMsgID = &clientMsgID + } if err := s.messages.Create(msg); err != nil { + // 併發重送(兩次請求幾乎同時到)時唯一索引會擋下後到的那筆—— + // 這時要回先寫進去的那一則,不是把 DB 錯誤丟回 App + // (它會顯示成「送出失敗」,而訊息其實已經在了)。 + if existing, ferr := s.messages.FindByClientMsgID(rideID, role, senderID, clientMsgID); ferr == nil && existing != nil { + return existing, nil + } return nil, err } s.publishToRideParties(ride, events.Event{ @@ -156,7 +195,7 @@ func (s *ChatService) publishToRideParties(ride *model.Ride, ev events.Event) { // rideMessagePayload 序列化訊息為 WS 事件 payload(與 REST 回應同欄位)。 func rideMessagePayload(m *model.RideMessage) map[string]any { - return map[string]any{ + payload := map[string]any{ "id": m.ID, "ride_id": m.RideID, "sender_role": m.SenderRole, @@ -164,4 +203,10 @@ func rideMessagePayload(m *model.RideMessage) map[string]any { "body": m.Body, "created_at": m.CreatedAt.Format(time.RFC3339), } + // 帶上冪等鍵,發話者自己其他裝置的 WS 回聲才認得出「這是我剛送的那則」 + // (沒帶鍵的訊息就不放這個欄位,維持既有形狀)。 + if m.ClientMsgID != nil { + payload["client_msg_id"] = *m.ClientMsgID + } + return payload } diff --git a/internal/service/chat_idempotency_test.go b/internal/service/chat_idempotency_test.go new file mode 100644 index 0000000..4b40d1f --- /dev/null +++ b/internal/service/chat_idempotency_test.go @@ -0,0 +1,202 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/events" + "line-fleet-dispatch/internal/repository" +) + +// 訊息送出的冪等鍵(client_msg_id)。 +// +// 為什麼要有它:App 送出逾時後,「後端其實收到了、只是回應遺失」與「後端沒收到」 +// 在客戶端看起來一樣,而訊息在後端**沒有唯一狀態**可查——「同內容再送一次」本來就合法, +// 所以無法像接單/取消/協尋/評分那樣靠一支查詢對帳。 +// 鍵由客戶端給、後端據此去重,是唯一能同時支援「安全重試」與「真的想再說一次」的做法。 + +// chatIdemFixture 一趟已指派司機的行程 + 一位乘客一位司機。 +type chatIdemFixture struct { + chat *ChatService + fp *fakePublisher + messages *repository.RideMessageRepository + customers *repository.CustomerRepository + rideID int64 + custID int64 + driverID int64 +} + +func newChatIdemFixture(t *testing.T, tag string) chatIdemFixture { + t.Helper() + db := newServiceTestDB(t) + customers := repository.NewCustomerRepository(db) + drivers := repository.NewDriverRepository(db) + rides := repository.NewRideRepository(db) + messages := repository.NewRideMessageRepository(db) + + cust, err := customers.FindOrCreateByLineUserID("U_idem_cust_"+tag, "冪等乘客") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + driver, err := drivers.FindOrCreate("U_idem_drv_"+tag, "冪等司機") + if err != nil { + t.Fatalf("建立司機失敗:%v", err) + } + ride := newTestRide(t, rides, cust.ID, constants.RideStatusRequested) + if err := rides.AcceptRide(ride.ID, driver.ID, 300); err != nil { + t.Fatalf("指派司機失敗:%v", err) + } + fp := &fakePublisher{} + return chatIdemFixture{ + chat: NewChatService(rides, messages, fp), + fp: fp, + messages: messages, + customers: customers, + rideID: ride.ID, + custID: cust.ID, + driverID: driver.ID, + } +} + +func (f chatIdemFixture) countMessages(t *testing.T) int { + t.Helper() + rows, err := f.messages.ListByRide(f.rideID, 0, 100) + if err != nil { + t.Fatalf("讀訊息失敗:%v", err) + } + return len(rows) +} + +func TestChatSendWithClientID_同鍵重送回既有那筆且不重播(t *testing.T) { + f := newChatIdemFixture(t, "resend") + + first, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "我在 7-11 門口", "c-1") + if err != nil { + t.Fatalf("第一次送出失敗:%v", err) + } + // 這就是「上一次其實成功了、只是回應遺失」之後 App 的重試。 + second, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "我在 7-11 門口", "c-1") + if err != nil { + t.Fatalf("重送不該失敗:%v", err) + } + + if second.ID != first.ID { + t.Fatalf("重送應回既有那筆(id %d),得到 id %d", first.ID, second.ID) + } + if got := f.countMessages(t); got != 1 { + t.Fatalf("同一個鍵不該多出一則訊息,DB 共有 %d 則", got) + } + // 第一次送出推播給行程雙方=2 則;重送不該再發,否則對方會看到同一句話兩次。 + if got := f.fp.count(); got != 2 { + t.Fatalf("重送不該重播 WS 事件,累計發了 %d 則(應為 2)", got) + } + if first.ClientMsgID == nil || *first.ClientMsgID != "c-1" { + t.Fatalf("寫入的訊息要留著鍵,得到 %v", first.ClientMsgID) + } +} + +func TestChatSendWithClientID_不同鍵的同內容是兩則(t *testing.T) { + f := newChatIdemFixture(t, "twice") + + if _, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "在嗎", "c-1"); err != nil { + t.Fatalf("第一次送出失敗:%v", err) + } + // 使用者真的想再說一次同一句話——這是合法行為,不能被去重吃掉。 + if _, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "在嗎", "c-2"); err != nil { + t.Fatalf("第二次送出失敗:%v", err) + } + + if got := f.countMessages(t); got != 2 { + t.Fatalf("不同鍵的同內容應是兩則,DB 共有 %d 則", got) + } + if got := f.fp.count(); got != 4 { + t.Fatalf("兩則訊息各推播雙方=4 則,得到 %d", got) + } +} + +func TestChatSendWithClientID_去重只在同趟同發話者(t *testing.T) { + f := newChatIdemFixture(t, "scope") + + // 鍵由客戶端產生,兩端各自產號、撞號是可能的。 + // 撞號時若被當成同一則,後方那位就發不出訊息了。 + if _, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "乘客說的", "same-key"); err != nil { + t.Fatalf("乘客送出失敗:%v", err) + } + if _, err := f.chat.SendWithClientID(events.RoleDriver, f.driverID, f.rideID, "司機說的", "same-key"); err != nil { + t.Fatalf("司機送出失敗(撞號不該被當成重送):%v", err) + } + + rows, err := f.messages.ListByRide(f.rideID, 0, 100) + if err != nil { + t.Fatalf("讀訊息失敗:%v", err) + } + if len(rows) != 2 { + t.Fatalf("不同發話者的同一個鍵應各自成立,DB 共有 %d 則", len(rows)) + } + if rows[0].Body != "乘客說的" || rows[1].Body != "司機說的" { + t.Fatalf("兩則內容錯誤:%q / %q", rows[0].Body, rows[1].Body) + } +} + +func TestChatSendWithClientID_不帶鍵維持舊行為(t *testing.T) { + f := newChatIdemFixture(t, "nokey") + + first, err := f.chat.Send(events.RoleCustomer, f.custID, f.rideID, "沒帶鍵一") + if err != nil { + t.Fatalf("送出失敗:%v", err) + } + second, err := f.chat.Send(events.RoleCustomer, f.custID, f.rideID, "沒帶鍵二") + if err != nil { + t.Fatalf("送出失敗:%v", err) + } + + if first.ID == second.ID { + t.Fatal("沒帶鍵時每次都該是新訊息") + } + if first.ClientMsgID != nil || second.ClientMsgID != nil { + t.Fatal("沒帶鍵的訊息不該被塞進鍵(partial index 靠 NULL 放行它們)") + } + if got := f.countMessages(t); got != 2 { + t.Fatalf("應有兩則,得到 %d", got) + } +} + +func TestChatSendWithClientID_鍵過長要擋在服務層(t *testing.T) { + f := newChatIdemFixture(t, "toolong") + + _, err := f.chat.SendWithClientID( + events.RoleCustomer, f.custID, f.rideID, "哈囉", + strings.Repeat("x", chatMaxClientMsgIDLen+1), + ) + if !errors.Is(err, ErrClientMsgIDTooLong) { + t.Fatalf("鍵超過 DB 欄位長度應回 ErrClientMsgIDTooLong,得到 %v", err) + } + if got := f.countMessages(t); got != 0 { + t.Fatalf("被擋下的送出不該寫入任何訊息,DB 共有 %d 則", got) + } +} + +func TestChatSendWithClientID_非參與者拿鍵也探不到別人的訊息(t *testing.T) { + f := newChatIdemFixture(t, "authz") + // **路人必須建在同一個 DB 裡**:再開一次 newServiceTestDB 會拿到重置過的狀態, + // 路人的 id 剛好等於行程乘客的 id,授權就假通過了(第一版就是這樣寫錯的)。 + stranger, err := f.customers.FindOrCreateByLineUserID("U_idem_stranger", "路人") + if err != nil { + t.Fatalf("建立乘客失敗:%v", err) + } + if stranger.ID == f.custID { + t.Fatalf("路人與行程乘客同一個 id(%d),這個案子測不到授權", stranger.ID) + } + + if _, err := f.chat.SendWithClientID(events.RoleCustomer, f.custID, f.rideID, "私訊", "c-1"); err != nil { + t.Fatalf("乘客送出失敗:%v", err) + } + + // 授權必須排在查鍵**之前**:否則外人拿鍵去試,就能從「有沒有回既有訊息」 + // 反推出那趟有沒有這則訊息。 + if _, err := f.chat.SendWithClientID(events.RoleCustomer, stranger.ID, f.rideID, "亂入", "c-1"); !errors.Is(err, ErrForbidden) { + t.Fatalf("非參與者應回 ErrForbidden,得到 %v", err) + } +}