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
2 changes: 2 additions & 0 deletions db/migrations/000024_add_ride_message_client_id.down.sql
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions db/migrations/000024_add_ride_message_client_id.up.sql
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 8 additions & 2 deletions internal/handler/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/model/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions internal/repository/ride_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 48 additions & 3 deletions internal/service/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,29 +48,63 @@ 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
}
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
}
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,
SenderID: senderID,
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{
Expand Down Expand Up @@ -156,12 +195,18 @@ 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,
"sender_id": m.SenderID,
"body": m.Body,
"created_at": m.CreatedAt.Format(time.RFC3339),
}
// 帶上冪等鍵,發話者自己其他裝置的 WS 回聲才認得出「這是我剛送的那則」
// (沒帶鍵的訊息就不放這個欄位,維持既有形狀)。
if m.ClientMsgID != nil {
payload["client_msg_id"] = *m.ClientMsgID
}
return payload
}
202 changes: 202 additions & 0 deletions internal/service/chat_idempotency_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading