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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。
至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。
詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。

**目前**:`flutter analyze` 無 issue、`flutter test` **356 passed**(47 個測試檔,2026-07-30 實跑)。
~~351 passed~~/~~339 passed~~/~~328 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。
**目前**:`flutter analyze` 無 issue、`flutter test` **361 passed**(48 個測試檔,2026-07-30 實跑)。
~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。

**2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪):
先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)——
Expand All @@ -238,8 +238,13 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。
乘客看到「評分失敗」,再按一次只會拿到 409,而他其實已經評過了。
改成逾時/409 時查一次 `GET /customer/rides/:id` 的 `ride.rating.score`;
模擬器實跑驗到表單自己關閉、那一列變成 ★★★★☆「已評分」。
**至此 App 兩端的寫入路徑逾時對帳全部補完**,只剩聊天送出刻意不做
(訊息沒有唯一狀態,需要後端冪等鍵,見 `docs/TODO.md`)。
**至此 App 兩端的寫入路徑逾時對帳全部補完**。
- **聊天送出**(第十八輪,跨端):這條沒有唯一狀態可查——「同內容再送一次」本來就合法,
所以改由客戶端產生冪等鍵、後端據此去重(dispatch
[#68](https://github.com/thothawei/fleet-dispatch/pull/68) 的 migration 000024)。
App 端逾時後補讀並**用鍵比對**:找到=上一次其實送出了(泡泡出現、輸入框清空、不報錯);
找不到就留著內容與**同一個鍵**讓他重試——重試因為冪等而不會多一則。
模擬器實跑+真後端 curl 都驗過:同鍵重送回既有那筆,不同鍵的同內容仍是兩則。

**2026-07-30 這一批**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十二~十三輪):
- **對話訊息也會推播**:先前對話只走 WS,對方 App 一離開前景就收不到,訊息躺在伺服器上
Expand Down
57 changes: 56 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,58 @@ DB 裡那位乘客只有一筆新訂單,證明 App 沒有重送。

---

## 💬 2026-07-30 第十八輪:聊天送出的冪等鍵(跨端,逾時對帳最後一條)

> 第十七輪把逾時對帳整族清完時,唯一剩下的就是聊天送出,並且**刻意沒做**——
> 當時的判斷是「這條不能照抄,需要後端冪等鍵,屬跨端決策」。這一輪就是把那個決策做掉。

### 為什麼這條不能用「查一次狀態」對帳

其他寫入(接單/取消/協尋/評分)在後端都有**唯一狀態**:查一次就知道生效沒。
訊息沒有——「同內容再送一次」本來就是合法行為,
所以 App 無法只靠 `afterId` 補讀分辨「上一次其實送出了」與「使用者真的想再說一次」。
**唯一的解法是由客戶端給一個鍵、後端據此去重。**

### 後端(dispatch [#68](https://github.com/thothawei/fleet-dispatch/pull/68))

- migration 000024:`ride_messages` 加 `client_msg_id VARCHAR(64)` + partial unique index
`(ride_id, sender_role, sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL`。
去重範圍刻意是「同趟同發話者」而非全表——鍵由客戶端產生,跨使用者撞號不該讓後方那位發不出訊息。
- `ChatService.SendWithClientID`:帶鍵時先查既有那筆 → 有就回它,**不重複寫入也不重複推播**
(否則對方會看到同一句話兩次)。查鍵排在**授權之後**,不然外人拿鍵去試就能反推那趟有沒有這則訊息。
- 反向確認分兩層:只關掉服務層預查 → 測試仍過(DB 唯一索引 + `Create` 後的 fallback 接手);
兩層都關 → FAIL 於 `duplicate key ... uq_ride_messages_client_msg_id`。
**兩層防線各自都足夠**——第一次只關一層時測試沒紅,差點誤判成「測試沒釘住」。

### App 端(本 repo)

- `RideMessage` 多 `clientMsgId`;兩端 `sendMessage` 多具名參數 `clientMsgId`
(用 null-aware element `'client_msg_id': ?clientMsgId`,沒帶時請求形狀不變)。
- `ride_chat_screen`:
- 這一則的鍵在第一次送出時產生,**失敗時留著**(`_pendingClientMsgId`)——
重試沿用同一個鍵才不會在後端變成兩則;送出確認落地才清掉,下一則拿新的鍵。
- 逾時(`statusCode == null`)→ 補讀 `afterId` 之後的訊息,**用鍵比對**:
找到=上一次其實送出了 → 泡泡出現、輸入框清空、不顯示錯誤。
找不到 → 留著錯誤與內容讓他重試(重試安全)。
- 補讀到的其他訊息(對方同時說的話)一併顯示——既然問了就別浪費。
- 明確拒絕(有狀態碼,如 400 訊息過長)**不對帳**。

### 驗收

- App:`flutter analyze` 無 issue、`flutter test` **361 passed**(356 +新 5)。
反向確認兩層:關掉補讀對帳 → 2 案 FAIL;重試不沿用同一個鍵 → 1 案 FAIL。
- 後端:6 案整合測試(testcontainers)本機全綠;CI 純單元集照過。
- **模擬器實跑(blackhole `POST:/api/rides/\d+/messages` + `ws_block`,WS 全程被擋)**:
1. 代理 log `上游回 200,不交還 App` = 後端真的寫入了。
2. 逾時 15 秒後出現對帳那次 `GET /api/rides/26/messages`。
3. 畫面出現自己的綠色泡泡、輸入框清空、**沒有錯誤橫幅**。
4. DB `ride_messages` 只有一則,`client_msg_id = customer-1785385960604-0`(`role-毫秒-序號`)。
- **冪等性在真後端上直接驗過**(curl,dev DB 已跑 migration 24):
- 帶**同一個鍵**重送 → 回 `id=6`(連 `created_at` 都是原本那筆),DB 沒有新增。
- 帶**不同鍵**、同樣內容 → 新的 `id=7`。「真的想再說一次」照樣成立。

---

## 下次任務

> **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)**
Expand Down Expand Up @@ -1933,7 +1985,10 @@ DB 裡那位乘客只有一筆新訂單,證明 App 沒有重送。
> ✅ **`submitRating` 已於第十七輪做掉**(見上方專段,含模擬器實跑)。
> **逾時對帳這一族到此清完**,現況表在第十七輪那段。
>
> **➡️ 剩下唯一一條逾時路徑:聊天送出(`ride_chat_screen._send`)——刻意先不做**:
> ~~**➡️ 剩下唯一一條逾時路徑:聊天送出——刻意先不做**~~
> ✅ **已於第十八輪做掉(跨端)**:後端加冪等鍵(dispatch #68 的 migration 000024)、
> App 端逾時後用鍵比對補讀,見上方專段。**逾時對帳整族到此完全清完。**
> 下面這段保留當時的判斷理由——它解釋了為什麼這條非得動後端:
> 訊息可能其實送出了,畫面只顯示錯誤、輸入框內容留著,乘客重送 →
> **後端多一筆重複訊息**(沒有冪等鍵)。
> **不能照抄協尋/評分那套判準**:協尋單與評分在後端都是唯一狀態(查一次就知道生效沒),
Expand Down
16 changes: 14 additions & 2 deletions lib/core/api/customer_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,23 @@ class CustomerApiClient {
}

/// 發送訊息;即時遞送由後端透過 WS chat.message 推給雙方。
Future<RideMessage> sendMessage(int rideId, String body) async {
/// 送出一則訊息。
///
/// [clientMsgId] 是**冪等鍵**(後端 dispatch #68):帶同一個鍵重送,後端會回既有那筆、
/// 不會多一則訊息。送出逾時後的重試一定要沿用同一個鍵——不然對方會看到同一句話兩次。
Future<RideMessage> sendMessage(
int rideId,
String body, {
String? clientMsgId,
}) async {
try {
final res = await _dio.post<Map<String, dynamic>>(
'/rides/$rideId/messages',
data: {'body': body},
data: {
'body': body,
// null-aware element:沒帶鍵時整個欄位不出現(維持既有請求形狀)。
'client_msg_id': ?clientMsgId,
},
);
return RideMessage.fromJson(
Map<String, dynamic>.from(res.data!['message'] as Map),
Expand Down
16 changes: 14 additions & 2 deletions lib/core/api/fleet_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,23 @@ class FleetApiClient {
}

/// 發送訊息;即時遞送由後端透過 WS chat.message 推給雙方。
Future<RideMessage> sendMessage(int rideId, String body) async {
/// 送出一則訊息。
///
/// [clientMsgId] 是**冪等鍵**(後端 dispatch #68):帶同一個鍵重送,後端會回既有那筆、
/// 不會多一則訊息。送出逾時後的重試一定要沿用同一個鍵——不然對方會看到同一句話兩次。
Future<RideMessage> sendMessage(
int rideId,
String body, {
String? clientMsgId,
}) async {
try {
final res = await _dio.post<Map<String, dynamic>>(
'/rides/$rideId/messages',
data: {'body': body},
data: {
'body': body,
// null-aware element:沒帶鍵時整個欄位不出現(維持既有請求形狀)。
'client_msg_id': ?clientMsgId,
},
);
return RideMessage.fromJson(
Map<String, dynamic>.from(res.data!['message'] as Map),
Expand Down
9 changes: 9 additions & 0 deletions lib/core/models/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ class RideMessage {
required this.senderId,
required this.body,
this.createdAt,
this.clientMsgId,
});

final int id;
Expand All @@ -594,6 +595,13 @@ class RideMessage {
final String body;
final DateTime? createdAt;

/// 送出時帶的冪等鍵(後端 `client_msg_id`);null =這則沒帶
/// (對方送的、舊訊息、或 LINE 等非 App 來源)。
///
/// **逾時後就是靠它認出「上一次其實送出了」**:訊息在後端沒有唯一狀態,
/// 「同內容再送一次」本來就合法,所以補讀時只能用這個鍵比對,不能比內容。
final String? clientMsgId;

factory RideMessage.fromJson(Map<String, dynamic> json) {
return RideMessage(
id: (json['id'] as num).toInt(),
Expand All @@ -602,6 +610,7 @@ class RideMessage {
senderId: (json['sender_id'] as num?)?.toInt() ?? 0,
body: json['body'] as String? ?? '',
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
clientMsgId: json['client_msg_id'] as String?,
);
}
}
Expand Down
5 changes: 3 additions & 2 deletions lib/customer/customer_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -972,8 +972,9 @@ class CustomerController extends ChangeNotifier {
Future<List<RideMessage>> fetchMessages(int rideId, {int afterId = 0}) =>
_api.fetchMessages(rideId, afterId: afterId);

Future<RideMessage> sendMessage(int rideId, String body) =>
_api.sendMessage(rideId, body);
Future<RideMessage> sendMessage(int rideId, String body,
{String? clientMsgId}) =>
_api.sendMessage(rideId, body, clientMsgId: clientMsgId);

/// 叫車:以目前 GPS 為上車點,帶乘客輸入的上車/目的地地址;
/// 若目的地由地圖選點取得,另帶精確座標(dropoffLat/Lng)。
Expand Down
5 changes: 3 additions & 2 deletions lib/driver/driver_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -852,8 +852,9 @@ class DriverController extends ChangeNotifier {
Future<List<RideMessage>> fetchMessages(int rideId, {int afterId = 0}) =>
_api.fetchMessages(rideId, afterId: afterId);

Future<RideMessage> sendMessage(int rideId, String body) =>
_api.sendMessage(rideId, body);
Future<RideMessage> sendMessage(int rideId, String body,
{String? clientMsgId}) =>
_api.sendMessage(rideId, body, clientMsgId: clientMsgId);

/// 重新拉未結案協尋工作清單(登入後、遺失物頁下拉)。
Future<void> refreshLostItems() async {
Expand Down
77 changes: 69 additions & 8 deletions lib/shared/screens/ride_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ class RideChatScreen extends StatefulWidget {
final String title;
final Future<List<RideMessage>> Function(int rideId, {int afterId})
loadHistory;
final Future<RideMessage> Function(int rideId, String body) send;
/// 送出一則訊息。[clientMsgId] 是冪等鍵——同一則訊息的重試沿用同一個鍵,
/// 後端據此去重(dispatch #68),所以重送不會讓對方看到同一句話兩次。
final Future<RideMessage> Function(int rideId, String body,
{String? clientMsgId}) send;
final Stream<RideMessage> incoming;

/// 進出聊天室通知 controller(清未讀/暫停未讀累計)。
Expand All @@ -48,6 +51,12 @@ class _RideChatScreenState extends State<RideChatScreen> {
bool _sending = false;
String? _error;

// 目前這一則「還沒確認落地」的訊息用的冪等鍵。
// 送出成功(或對帳確認其實成功)就清掉,讓下一則拿新的鍵;
// **失敗時要留著**——重試沿用同一個鍵才不會在後端變成兩則。
String? _pendingClientMsgId;
var _clientMsgSeq = 0;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -124,24 +133,76 @@ class _RideChatScreenState extends State<RideChatScreen> {
Future<void> _send() async {
final body = _input.text.trim();
if (body.isEmpty || _sending) return;
// 這一則的鍵:第一次送出時產生,重試時**沿用**(後端據此去重)。
final clientMsgId = _pendingClientMsgId ??= _newClientMsgId();
setState(() => _sending = true);
try {
final msg = await widget.send(widget.rideId, body);
final msg =
await widget.send(widget.rideId, body, clientMsgId: clientMsgId);
if (!mounted) return;
setState(() {
_append(msg);
_input.clear();
_error = null;
});
_jumpToBottom();
_acceptSent(msg);
} on ApiException catch (e) {
if (!mounted) return;
// **逾時不代表沒送出**:後端可能已經寫入,只是回應遺失。
// 訊息沒有唯一狀態可查(「同內容再送一次」本來就合法),所以補讀回來
// 用**冪等鍵**比對——找到就是上一次其實送出了。
final recovered = e.statusCode == null
? await _findSentByClientMsgId(clientMsgId)
: null;
if (!mounted) return;
if (recovered != null) {
_acceptSent(recovered);
return;
}
// 沒找到(或這一問也失敗)→ 留著錯誤、輸入內容與同一個鍵,讓他重試。
// 重試是安全的:後端帶同鍵不會多一則。
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _sending = false);
}
}

/// 訊息確認落地:附加到畫面、清空輸入框與錯誤,並讓下一則取得新的鍵。
void _acceptSent(RideMessage msg) {
setState(() {
_append(msg);
_input.clear();
_error = null;
_pendingClientMsgId = null;
});
_jumpToBottom();
}

/// 逾時後的對帳:補讀最後一則之後的訊息,看有沒有帶著這個鍵的那一則。
///
/// 補讀到的其他訊息(對方同時說的話)也一併顯示——既然問了就別浪費。
/// 這一問本身失敗時回 null(不知道就不亂改,維持原本的錯誤)。
Future<RideMessage?> _findSentByClientMsgId(String clientMsgId) async {
try {
final afterId = _messages.isEmpty ? 0 : _messages.last.id;
final fresh = await widget.loadHistory(widget.rideId, afterId: afterId);
RideMessage? mine;
for (final m in fresh) {
if (m.clientMsgId == clientMsgId) {
mine = m;
} else {
setState(() => _append(m));
}
}
return mine;
} on ApiException {
return null;
}
}

/// 產生這一則訊息的冪等鍵。
///
/// 不需要全域唯一——後端的去重範圍是「同一趟行程的同一位發話者」,
/// 所以「同一個聊天室內、同一支 App 執行期間不重複」就夠了。
/// 時間戳(毫秒)+序號可避開同一毫秒連送兩則的碰撞。
String _newClientMsgId() =>
'${widget.selfRole}-${DateTime.now().millisecondsSinceEpoch}-${_clientMsgSeq++}';

@override
Widget build(BuildContext context) {
// 沿用 app root 那支 reactor:`inactive` 不算回前景(通知列下拉、來電橫幅都會觸發它,
Expand Down
3 changes: 2 additions & 1 deletion test/chat_resume_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class _FakeChat {
return history.where((m) => m.id > afterId).toList();
}

Future<RideMessage> send(int rideId, String body) async =>
Future<RideMessage> send(int rideId, String body,
{String? clientMsgId}) async =>
_msg(99, body);
}
Loading
Loading