Skip to content

feat(dispatch): 訊息送出的冪等鍵——App 逾時後才有辦法安全重試 - #68

Merged
thothawei merged 1 commit into
mainfrom
claude/chat-idempotency
Jul 30, 2026
Merged

feat(dispatch): 訊息送出的冪等鍵——App 逾時後才有辦法安全重試#68
thothawei merged 1 commit into
mainfrom
claude/chat-idempotency

Conversation

@thothawei

Copy link
Copy Markdown
Owner

為什麼需要這個

App 聊天送出逾時後,「後端其實收到了、只是回應遺失」與「後端沒收到」在客戶端看起來完全一樣。

其他寫入路徑(接單/取消/協尋/評分)都能靠「查一次後端狀態」對帳,因為那些狀態在後端是唯一的。訊息不是——「同內容再送一次」本來就是合法行為,所以 App 無法只靠 afterId 補讀分辨「上一次其實送出了」與「使用者真的想再說一次」。由客戶端產生一個鍵、後端據此去重,是唯一能兩者兼顧的做法。

(來源:line-fleet-app 第十七輪把逾時對帳整族清完後,唯一剩下的就是這條,當時就記著「要嘛後端加冪等鍵,要嘛接受重複訊息」。)

改了什麼

  • migration 000024ride_messagesclient_msg_id VARCHAR(64) + partial unique index (ride_id, sender_role, sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL
    • 去重範圍刻意是「同趟同發話者」而不是全表:鍵由客戶端產生,跨使用者撞號不該讓後方那位發不出訊息。
    • 既有訊息與非 App 來源(LINE webhook)維持 NULL,完全不受這條約束。
  • ChatService.SendWithClientID:帶鍵時先查既有那筆 → 有就直接回傳,不重複寫入也不重複推播(WS 與 App 推播都不再發第二次,否則對方會看到同一句話兩次)。
    • 查鍵排在授權之後——否則外人拿鍵去試,就能從「有沒有回既有訊息」反推出那趟有沒有這則訊息。
    • Create 撞唯一索引時(併發重送)改查既有那筆回傳,不把 DB 錯誤丟回 App(App 會顯示成「送出失敗」,而訊息其實已經在了)。
  • Send 保留舊簽名並委派過去,既有呼叫端與 LINE 來源不受影響。
  • WS payload 帶上 client_msg_id,發話者其他裝置的回聲認得出「這是我剛送的那則」。
  • handler 收 client_msg_id,過長回 400。

驗收

  • go build / go vet 乾淨、gofmt -l 無待整理
  • 新增 6 案整合測試(testcontainers)本機全綠:同鍵重送不多一則也不重播/不同鍵的同內容是兩則/去重只在同趟同發話者/不帶鍵維持舊行為/鍵過長擋在服務層/非參與者拿鍵也探不到別人的訊息
  • CI 的純單元測試集(含 ./internal/handler/)照過
  • 反向確認分兩層做(這一段值得看):
    • 只關掉服務層預查 → 測試仍然過,因為 DB 唯一索引 + Create 後的 fallback 接手了
    • 預查與 fallback 都關掉 → 第一案 FAIL 於 duplicate key value violates unique constraint "uq_ride_messages_client_msg_id"
    • 也就是說:兩層防線各自都足夠,服務層預查是避免無謂寫入錯誤的最佳化,真正的保證在索引 + fallback。第一次只關一層時測試沒紅,差點誤判成「測試沒釘住」

部署注意

需要跑 migration(server migrate)。App 端不帶鍵時行為與現在完全相同,所以後端可以先上。

🤖 Generated with Claude Code

App 端聊天送出逾時後,「後端其實收到了、只是回應遺失」與「後端沒收到」在客戶端
看起來一樣。其他寫入路徑(接單/取消/協尋/評分)都能靠「查一次後端狀態」對帳,
因為那些狀態在後端是唯一的;訊息不是——「同內容再送一次」本來就是合法行為,
所以 App 無法只靠補讀分辨「上一次其實送出了」與「使用者真的想再說一次」。
由客戶端產生一個鍵、後端據此去重,是唯一能兩者兼顧的做法。

- migration 000024:ride_messages 加 client_msg_id VARCHAR(64) +
  partial unique index (ride_id, sender_role, sender_id, client_msg_id) WHERE NOT NULL。
  去重範圍刻意是「同趟同發話者」而非全表:鍵由客戶端產生,跨使用者撞號不該
  讓後方那位發不出訊息。既有訊息與非 App 來源(LINE webhook)維持 NULL、不受約束。
- ChatService.SendWithClientID:帶鍵時先查既有那筆 → 有就直接回傳,
  **不重複寫入也不重複推播**(WS 與 App 推播都不再發第二次,否則對方會看到同一句話兩次)。
  查鍵排在授權之後——否則外人拿鍵去試就能反推出那趟有沒有這則訊息。
  Create 撞唯一索引時(併發重送)改查既有那筆回傳,不把 DB 錯誤丟回 App。
- Send 保留舊簽名並委派,既有呼叫端與 LINE 來源不受影響。
- WS payload 帶上 client_msg_id,發話者其他裝置的回聲認得出「這是我剛送的那則」。
- handler 收 client_msg_id,過長回 400(ErrClientMsgIDTooLong)。

驗收:go build/go vet 乾淨、gofmt 無待整理;新增 6 案整合測試(testcontainers)本機
全綠;CI 的純單元測試集(含 handler 包)照過。
反向確認分兩層做:只關掉服務層預查 → 測試仍過(DB 唯一索引 + Create 後的 fallback
接手);預查與 fallback 都關掉 → 第一案 FAIL 於
`duplicate key value violates unique constraint "uq_ride_messages_client_msg_id"`——
證明測試有效,也證明索引真的建起來了。

App 端(line-fleet-app)會在另一支 PR 帶上這個鍵。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thothawei
thothawei merged commit f7caf4d into main Jul 30, 2026
1 check passed
@thothawei
thothawei deleted the claude/chat-idempotency branch July 30, 2026 04:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant