From d4f5229207806501f65b261cfc04648c28788553 Mon Sep 17 00:00:00 2001 From: awei Date: Thu, 30 Jul 2026 15:55:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=AF=AB=E5=85=A5=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E4=B8=8D=E6=98=8E=E6=99=82=E8=A6=81=E9=87=8D=E8=AE=80?= =?UTF-8?q?=E5=BE=8C=E7=AB=AF=EF=BC=8C=E4=B8=8D=E8=A6=81=E8=AA=AA=E3=80=8C?= =?UTF-8?q?=E8=AB=8B=E7=A8=8D=E5=BE=8C=E5=86=8D=E8=A9=A6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 盤點九條寫入路徑(fleet-app TODO 早就記著「admin 端完全沒有逾時對帳的 概念」)。多數端點形狀上就冪等,App 那套「查一次狀態」是過度設計; 真正的問題九條全中:onError 只跳錯誤訊息、從來不重新讀後端。 實跑重現(blackhole 掉強制取消的回應,後端其實已取消、DB status=9): 畫面停在「前往接客」+「請求逾時,請稍後再試」+確認對話框還開著 → 操作者照著再按一次 → 500「訂單狀態已變更,無法取消」。乘客與司機 都已經收到取消通知了,後台卻以為這張單沒取消掉。 新增 utils/writeError.ts: - isUncertainWrite:連線類(無 response)或 409 = 這次寫入結果不明。 - handleWriteError:結果不明 → 重讀指定 query + 不宣稱失敗的措辭; 明確失敗(400/403/500)維持原行為。顯示訊息與重新整理綁在同一支, 否則遲早出現「訊息說已重新整理、其實沒有」的謊。 九條寫入全部改用它;確認對話框在送出後一律關閉(表單驗證那種仍留著)。 後端同批:dispatch #69 把該回 409 的狀態衝突從 500 改掉——沒有 409, 前端分不出「伺服器壞了」與「已經生效了」。 驗收:tsc/oxlint/build 綠、Vitest 143 passed(134+新 9); 反向確認拿掉重讀只有新加 3 案 FAIL;真瀏覽器 E2E 逾時與 409 兩條都跑過。 Co-Authored-By: Claude Opus 5 --- docs/TODO.md | 70 +++++++++++++++++- src/pages/DriversPage.tsx | 26 +++++-- src/pages/FeeSettingsPage.tsx | 9 ++- src/pages/MembershipInvoicesPage.tsx | 16 ++++- src/pages/OrderDetailPage.test.tsx | 90 +++++++++++++++++++++++ src/pages/OrderDetailPage.tsx | 14 +++- src/pages/SettingsPage.tsx | 9 ++- src/pages/UsersPage.tsx | 16 ++++- src/utils/writeError.test.ts | 104 +++++++++++++++++++++++++++ src/utils/writeError.ts | 51 +++++++++++++ 10 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 src/utils/writeError.test.ts create mode 100644 src/utils/writeError.ts diff --git a/docs/TODO.md b/docs/TODO.md index eb589f1..23faf69 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -13,7 +13,7 @@ | 核心瀏覽 | ✅ 登入、營運總覽 Dashboard、即時車隊地圖、訂單列表/詳情+軌跡回放(含日期/關鍵字篩選)、司機列表、日報表(可匯出 CSV) | | 寫入操作 | ✅ 司機啟停、派單參數、強制取消(2026-07-08) | | 韌性 | ✅ 全域 Error Boundary、JWT `exp` 主動登出(2026-07-10) | -| 測試 | ✅ **26 測試檔/134 tests**(2026-07-28;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`) | +| 測試 | ✅ **27 測試檔/143 tests**(2026-07-30;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`/`writeError`) | | CI | ✅ lint + test + build(`.github/workflows/ci.yml`);2026-07-10 修好 `npm ci` ERESOLVE | | 視覺驗收 | ✅ C5(2026-07-08)+ UI/UX 翻新腳本已對齊新路由(`docs/screenshots/ux-2026-07-10/`) | @@ -31,6 +31,74 @@ --- +## ⏱️ 寫入結果不明時的處理(2026-07-30,跨 repo) + +> 起因:fleet-app 從第五輪起陸續把 App 端每一條寫入都補上「逾時對帳」, +> 而 app 的 TODO 早就記著「**admin 端完全沒有逾時對帳的概念**,值得盤點一次」。 +> 這一輪就是那次盤點——**先取證再修**,證據是真瀏覽器 + 吃掉回應的代理 +> ([`line-fleet-app/tool/lossy_proxy.py`](../../line-fleet-app/tool/lossy_proxy.py))。 + +### 盤點結果:九條寫入路徑,真正會害人的是「強制取消」 + +`src/api/admin.ts` 的寫入端點共九支。多數**形狀上就是冪等的** +(PUT 費率/派單參數、PATCH `paid`/`enabled` 都是設定成某個值,重送會收斂), +所以 App 那套「查一次狀態再判斷生效沒」的對帳在這裡是過度設計。 +真正的問題只有一個,而且九條全中: + +**`onError` 只跳一則錯誤訊息,從來不重新讀後端。** 於是逾時之後畫面停在舊狀態, +訊息還寫著「請求逾時,**請稍後再試**」——把操作者推向唯一錯誤的下一步。 + +### 實跑重現(`ride #30`,blackhole `POST:/api/admin/rides/\d+/cancel`) + +1. 代理 log:`上游回 HTTP/1.1 200 OK,不交還 App` = **後端真的取消了**(DB `status=9`)。 +2. 15 秒後畫面:紅色 toast「請求逾時,請稍後再試」、狀態欄仍是「前往接客」、 + **確認對話框還開著**。 +3. 操作者照著訊息再按一次 → **HTTP 500 `訂單狀態已變更,無法取消`**。 + +也就是說:乘客與司機都已經收到取消通知了,後台卻連按兩次、看到兩則錯誤, +最後以為「這張單沒取消掉」。 + +### 修法(兩端一起) + +**後端** dispatch [PR #69](https://github.com/thothawei/fleet-dispatch/pull/69): +`AdminHandler.CancelRide` 把 `ErrRideStarted`/`ErrRideStateChanged` 從 500 改判 **409** +(乘客端 `readStatusForErr` 早就是這樣分類,admin 這支是漏網的)。 +沒有 409,前端根本分不出「伺服器壞了」與「這件事已經生效了」。 + +**前端**(本 repo)新增 `src/utils/writeError.ts`: + +- `isUncertainWrite(err)`:**連線類(沒有 response)或 409** = 這次寫入結果不明。 +- `handleWriteError(err, fallback, { notify, queryClient, invalidate })`: + 結果不明 → **重讀指定的 query + 用不宣稱失敗的措辭** + (「沒有收到後端回應,這次操作可能已經生效——畫面已重新整理,請確認結果後再決定是否重試」); + 明確的失敗(400/403/500)→ 維持原本行為,顯示原因、不動畫面。 + **顯示訊息與重新整理綁在同一支函式**,否則遲早出現「訊息說已重新整理、其實沒有」的謊。 +- 九條寫入路徑全部改用它(訂單取消、車輛審核、司機啟停、費率、派單參數、會費帳單兩支、帳號兩支)。 +- **確認對話框在送出後一律關閉**(訂單強制取消、車輛核准/退回):結果由 toast 說明、 + 畫面已重讀,留著對話框只會誘導再按一次。原因沒填那種**表單驗證**仍然留著對話框。 + +### 驗收 + +- `tsc -b`/`oxlint`/`vite build` 綠;**Vitest 143 passed**(134 + 新 9)。 +- **反向確認**:把 `handleWriteError` 的重讀拿掉 → 只有新加的 3 案 FAIL,其餘照過。 +- **真瀏覽器 E2E(Claude Browser + 本機 dispatch + lossy proxy)**: + - 逾時(`ride #33`):代理吃掉回應 → 15 秒後**對話框自動關閉**、 + toast「沒有收到後端回應,這次操作可能已經生效——畫面已重新整理…」、 + 狀態欄當場變「已取消」、完成時間出現、「強制取消」按鈕消失。 + 代理 log 可見逾時後那次 `GET /api/admin/rides/33`。 + - 409(`ride #34`):先用 API 在後台背後取消,畫面還停在「前往接客」→ 按強制取消 → + `POST … -> 409 Conflict` → **同一秒**接著 `GET /api/admin/rides/34` → 畫面翻成「已取消」。 + (這條的 toast 文案由單元測試斷言;瀏覽器這邊證的是 409→重讀→畫面正確這條鏈。) + +### 沒做、以及為什麼 + +- **沒有把 App 那套「查一次狀態確認生效」搬過來**:admin 的寫入多為冪等設定, + 重讀畫面就足以讓操作者判斷,再加一層判準只是多一組會過時的規則。 +- **`GenerateMembershipInvoices` 本來就冪等**(後端同月重跑只回 `created: 0`), + 維持現狀即可,不特別處理。 + +--- + ## 一、已完成 ✅ - [x] **C2. 司機審核啟用/停用 UI** — `DriversPage.tsx`:Switch + 停用確認 + `patchDriverStatus` diff --git a/src/pages/DriversPage.tsx b/src/pages/DriversPage.tsx index 913b9cf..23c75fa 100644 --- a/src/pages/DriversPage.tsx +++ b/src/pages/DriversPage.tsx @@ -16,7 +16,7 @@ import { VEHICLE_REVIEW_STATUS, VEHICLE_TYPE_LABEL, } from '../constants'; -import { apiError } from '../utils/apiError'; +import { handleWriteError } from '../utils/writeError'; const STATUS_OPTIONS = [ { value: 'all', label: '全部狀態' }, @@ -78,7 +78,12 @@ export default function DriversPage() { message.success('已更新司機狀態'); queryClient.invalidateQueries({ queryKey: ['drivers'] }); }, - onError: (err) => message.error(apiError(err, '操作失敗')), + onError: (err) => + handleWriteError(err, '操作失敗', { + notify: message, + queryClient, + invalidate: [['drivers']], + }), }); // 車輛審核(O5):核准/退回。退回必附原因(後端也擋)。 @@ -89,7 +94,12 @@ export default function DriversPage() { message.success(vars.approve ? '已核准車輛' : '已退回車輛'); queryClient.invalidateQueries({ queryKey: ['drivers'] }); }, - onError: (err) => message.error(apiError(err, '審核失敗')), + onError: (err) => + handleWriteError(err, '審核失敗', { + notify: message, + queryClient, + invalidate: [['drivers']], + }), }); const approveVehicle = (driver: Driver) => { @@ -98,7 +108,9 @@ export default function DriversPage() { content: `車種 ${VEHICLE_TYPE_LABEL[driver.VehicleType] ?? driver.VehicleType}/車牌 ${driver.PlateNumber}。核准後即可開始接單。`, okText: '核准', cancelText: '取消', - onOk: () => reviewMutation.mutateAsync({ id: driver.ID, approve: true }), + // 失敗也關掉對話框(與訂單強制取消同一個理由):清單在結果不明時已重讀過後端, + // 再按一次核准多半只會換來 409「這台車不是待審核狀態」。 + onOk: () => reviewMutation.mutateAsync({ id: driver.ID, approve: true }).catch(() => undefined), }); }; @@ -123,7 +135,11 @@ export default function DriversPage() { message.error('退回必須填寫原因'); return Promise.reject(new Error('note required')); } - return reviewMutation.mutateAsync({ id: driver.ID, approve: false, note: note.trim() }); + // 原因沒填是上面那個 reject(對話框要留著讓他補打); + // 送出之後的結果一律關掉對話框,理由同核准。 + return reviewMutation + .mutateAsync({ id: driver.ID, approve: false, note: note.trim() }) + .catch(() => undefined); }, }); }; diff --git a/src/pages/FeeSettingsPage.tsx b/src/pages/FeeSettingsPage.tsx index 9321b02..09e07f5 100644 --- a/src/pages/FeeSettingsPage.tsx +++ b/src/pages/FeeSettingsPage.tsx @@ -4,7 +4,7 @@ import { Alert, App, Button, Card, Form, InputNumber, Skeleton } from 'antd'; import { fetchFeeSettings, updateFeeSettings, type FeeSettings } from '../api/admin'; import { isSuperadmin } from '../auth/auth'; -import { apiError } from '../utils/apiError'; +import { handleWriteError } from '../utils/writeError'; // 表單以「元 / %」為單位(對使用者友善),送出前換算回後端的「分 / bps」。 interface FeeFormValues { @@ -65,7 +65,12 @@ export default function FeeSettingsPage() { form.setFieldsValue(toForm(updated)); queryClient.setQueryData(['fee-settings'], updated); }, - onError: (err) => message.error(apiError(err, '更新失敗')), + onError: (err) => + handleWriteError(err, '更新失敗', { + notify: message, + queryClient, + invalidate: [['fee-settings']], + }), }); if (isLoading) { diff --git a/src/pages/MembershipInvoicesPage.tsx b/src/pages/MembershipInvoicesPage.tsx index 94621f6..49b88ed 100644 --- a/src/pages/MembershipInvoicesPage.tsx +++ b/src/pages/MembershipInvoicesPage.tsx @@ -13,7 +13,7 @@ import { } from '../api/admin'; import PageHeader from '../components/PageHeader'; import { isSuperadmin } from '../auth/auth'; -import { apiError } from '../utils/apiError'; +import { handleWriteError } from '../utils/writeError'; import { fmtYuan } from '../utils/money'; const STATUS_OPTIONS = [ @@ -49,7 +49,12 @@ export default function MembershipInvoicesPage() { message.success(created > 0 ? `已產生 ${created} 筆會費帳單` : '本月無新帳單可產生(已是最新)'); invalidate(); }, - onError: (err) => message.error(apiError(err, '產生帳單失敗')), + onError: (err) => + handleWriteError(err, '產生帳單失敗', { + notify: message, + queryClient, + invalidate: [['membership-invoices', monthStr]], + }), }); const paidMutation = useMutation({ @@ -58,7 +63,12 @@ export default function MembershipInvoicesPage() { message.success(paid ? '已標記為已繳' : '已改為未繳'); invalidate(); }, - onError: (err) => message.error(apiError(err, '更新失敗')), + onError: (err) => + handleWriteError(err, '更新失敗', { + notify: message, + queryClient, + invalidate: [['membership-invoices', monthStr]], + }), }); const columns: ColumnsType = [ diff --git a/src/pages/OrderDetailPage.test.tsx b/src/pages/OrderDetailPage.test.tsx index 750a969..4f1b97a 100644 --- a/src/pages/OrderDetailPage.test.tsx +++ b/src/pages/OrderDetailPage.test.tsx @@ -1,10 +1,56 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AxiosError, AxiosHeaders } from 'axios'; import OrderDetailPage from './OrderDetailPage'; import { setRole } from '../auth/auth'; import { renderWithProviders } from '../test/render'; +/** 逾時/斷線:請求送出去了,但沒有任何回應——後端可能已經做完了。 */ +function connectionError(): AxiosError { + return new AxiosError('timeout of 15000ms exceeded', 'ECONNABORTED'); +} + +/** 後端有明確回答的失敗。 */ +function responseError(status: number, message: string): AxiosError { + const err = new AxiosError('failed'); + err.response = { + status, + statusText: '', + data: { error: message }, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }; + return err; +} + +/** 進行中(可取消)的訂單詳情。 */ +function activeRideFixture() { + return { + ride: { + id: 2, + customer_id: 10, + driver_id: 2, + status: 1, + pickup_point: { lat: 25.034, lng: 121.566 }, + pickup_address: '台北101', + dropoff_point: null, + dropoff_address: '', + requested_at: '2026-07-06T14:53:13+08:00', + accepted_at: null, + picked_up_at: null, + completed_at: null, + distance_m: null, + eta_pickup_sec: 100, + created_at: '2026-07-06T14:53:13+08:00', + updated_at: '2026-07-06T14:53:13+08:00', + }, + track_geojson: '{"type":"LineString","coordinates":[]}', + events: [], + }; +} + const mockFetchRideDetail = vi.fn(); const mockCancelRideByAdmin = vi.fn(); const mockFetchRideMessages = vi.fn(); @@ -280,6 +326,50 @@ describe('OrderDetailPage', () => { expect(screen.queryByRole('button', { name: /強制取消/ })).not.toBeInTheDocument(); }); + // 取消的回應在網路上遺失時,後端其實**已經取消了**。 + // 舊行為是一則「請求逾時,請稍後再試」+畫面停在舊狀態,操作者只能再按一次 → + // 換來一則「訂單狀態已變更,無法取消」。這兩案釘住新行為:重讀後端 + 不說失敗。 + it('取消逾時(結果不明)要重讀訂單,而不是停在舊狀態', async () => { + setRole('superadmin'); + mockFetchRideDetail.mockResolvedValue(activeRideFixture()); + mockCancelRideByAdmin.mockRejectedValue(connectionError()); + + renderWithProviders(, { route: '/orders/2', path: '/orders/:id' }); + await waitFor(() => { + expect(screen.getByRole('button', { name: /強制取消/ })).toBeInTheDocument(); + }); + const readsBefore = mockFetchRideDetail.mock.calls.length; + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /強制取消/ })); + await user.click(await screen.findByRole('button', { name: /確認取消/ })); + + await waitFor(() => { + expect(mockFetchRideDetail.mock.calls.length).toBeGreaterThan(readsBefore); + }); + expect(await screen.findByText(/可能已經生效/)).toBeInTheDocument(); + expect(screen.queryByText('請求逾時,請稍後再試')).not.toBeInTheDocument(); + }); + + it('明確的失敗(400)維持原樣:顯示原因、不重讀', async () => { + setRole('superadmin'); + mockFetchRideDetail.mockResolvedValue(activeRideFixture()); + mockCancelRideByAdmin.mockRejectedValue(responseError(400, '參數錯誤')); + + renderWithProviders(, { route: '/orders/2', path: '/orders/:id' }); + await waitFor(() => { + expect(screen.getByRole('button', { name: /強制取消/ })).toBeInTheDocument(); + }); + const readsBefore = mockFetchRideDetail.mock.calls.length; + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /強制取消/ })); + await user.click(await screen.findByRole('button', { name: /確認取消/ })); + + expect(await screen.findByText('參數錯誤')).toBeInTheDocument(); + expect(mockFetchRideDetail.mock.calls.length).toBe(readsBefore); + }); + it('顯示乘客指定車種與司機當時的車輛快照(兩者是不同欄位)', async () => { mockFetchRideDetail.mockResolvedValue({ ride: { diff --git a/src/pages/OrderDetailPage.tsx b/src/pages/OrderDetailPage.tsx index 112beec..e9f1527 100644 --- a/src/pages/OrderDetailPage.tsx +++ b/src/pages/OrderDetailPage.tsx @@ -24,8 +24,8 @@ import { } from '../constants'; import { DEFAULT_MAP_CENTER, MAP_HEIGHT, MAP_STYLE } from '../components/mapStyle'; -import { apiError } from '../utils/apiError'; import { fmtYuan } from '../utils/money'; +import { handleWriteError } from '../utils/writeError'; const TRACK_SOURCE_ID = 'ride-track'; const TRACK_LAYER_ID = 'ride-track-line'; const PLAYBACK_INTERVAL_MS = 300; @@ -78,7 +78,12 @@ export default function OrderDetailPage() { queryClient.invalidateQueries({ queryKey: ['ride', rideId] }); queryClient.invalidateQueries({ queryKey: ['rides'] }); }, - onError: (err) => message.error(apiError(err, '取消失敗')), + onError: (err) => + handleWriteError(err, '取消失敗', { + notify: message, + queryClient, + invalidate: [['ride', rideId], ['rides']], + }), }); const confirmCancel = () => { @@ -88,7 +93,10 @@ export default function OrderDetailPage() { okText: '確認取消', okButtonProps: { danger: true }, cancelText: '返回', - onOk: () => cancelMutation.mutateAsync(), + // **失敗也關掉對話框**:結果由 onError 的訊息負責說明,畫面(結果不明時) + // 也已經重讀過後端——操作者該看的是那個結果,不是留在對話框上再按一次確認, + // 那一按多半只會換來另一則「訂單狀態已變更」。要重試,頁面上的按鈕還在。 + onOk: () => cancelMutation.mutateAsync().catch(() => undefined), }); }; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index a95178d..98034e3 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -8,7 +8,7 @@ import { type DispatchSettings, } from '../api/admin'; import { canDispatch } from '../auth/auth'; -import { apiError } from '../utils/apiError'; +import { handleWriteError } from '../utils/writeError'; const FIELD_RULES = { radius_m: { min: 100, max: 50000, label: '搜尋半徑(公尺)' }, @@ -39,7 +39,12 @@ export default function SettingsPage() { form.setFieldsValue(updated); queryClient.setQueryData(['dispatch-settings'], updated); }, - onError: (err) => message.error(apiError(err, '更新失敗')), + onError: (err) => + handleWriteError(err, '更新失敗', { + notify: message, + queryClient, + invalidate: [['dispatch-settings']], + }), }); if (isLoading) { diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index cc43a29..4cafc19 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -4,7 +4,7 @@ import { App, Button, Card, Form, Input, Modal, Select, Switch, Table } from 'an import type { ColumnsType } from 'antd/es/table'; import { createAdmin, listAdmins, updateAdmin, type AdminUser } from '../api/admin'; -import { apiError } from '../utils/apiError'; +import { handleWriteError } from '../utils/writeError'; const ROLE_OPTS = [ { value: 'viewer', label: '檢視者' }, @@ -39,7 +39,12 @@ export default function UsersPage() { form.resetFields(); refresh(); }, - onError: (err) => message.error(apiError(err, '建立失敗')), + onError: (err) => + handleWriteError(err, '建立失敗', { + notify: message, + queryClient, + invalidate: [['admins']], + }), }); const updateMut = useMutation({ @@ -49,7 +54,12 @@ export default function UsersPage() { message.success('已更新'); refresh(); }, - onError: (err) => message.error(apiError(err, '更新失敗')), + onError: (err) => + handleWriteError(err, '更新失敗', { + notify: message, + queryClient, + invalidate: [['admins']], + }), }); const columns: ColumnsType = [ diff --git a/src/utils/writeError.test.ts b/src/utils/writeError.test.ts new file mode 100644 index 0000000..5063f2a --- /dev/null +++ b/src/utils/writeError.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AxiosError, AxiosHeaders } from 'axios'; +import type { QueryClient } from '@tanstack/react-query'; + +import { handleWriteError, isUncertainWrite } from './writeError'; + +function axiosErr(status?: number, body?: { error?: string }, code?: string): AxiosError { + const err = new AxiosError('boom', code); + if (status !== undefined) { + err.response = { + status, + statusText: '', + data: body ?? {}, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }; + } + return err; +} + +/** 假的 queryClient:只需要記下 invalidateQueries 收到哪些 key。 */ +function fakeClient() { + const calls: unknown[] = []; + const client = { + invalidateQueries: (arg: { queryKey: unknown }) => { + calls.push(arg.queryKey); + return Promise.resolve(); + }, + } as unknown as QueryClient; + return { client, calls }; +} + +describe('isUncertainWrite', () => { + it('連線類(逾時/完全沒回應)=結果不明', () => { + expect(isUncertainWrite(axiosErr(undefined, undefined, 'ECONNABORTED'))).toBe(true); + expect(isUncertainWrite(axiosErr())).toBe(true); + }); + + it('409 也算結果不明——多半是上一次其實已經生效', () => { + expect(isUncertainWrite(axiosErr(409, { error: '訂單狀態已變更,無法取消' }))).toBe(true); + }); + + it('明確的失敗不算:400/403/500 都是後端有回答', () => { + expect(isUncertainWrite(axiosErr(400))).toBe(false); + expect(isUncertainWrite(axiosErr(403))).toBe(false); + expect(isUncertainWrite(axiosErr(500))).toBe(false); + }); + + it('不是 axios 錯誤就不算', () => { + expect(isUncertainWrite(new Error('程式壞了'))).toBe(false); + }); +}); + +describe('handleWriteError', () => { + it('逾時:重讀指定的 query,而且訊息不可以說「失敗」', () => { + const { client, calls } = fakeClient(); + const notify = { warning: vi.fn(), error: vi.fn() }; + + handleWriteError(axiosErr(undefined, undefined, 'ECONNABORTED'), '取消失敗', { + notify, + queryClient: client, + invalidate: [['ride', 30], ['rides']], + }); + + expect(calls).toEqual([['ride', 30], ['rides']]); + expect(notify.error).not.toHaveBeenCalled(); + const msg = notify.warning.mock.calls[0][0] as string; + expect(msg).toContain('可能已經生效'); + expect(msg).toContain('已重新整理'); + // 這條是本次修正的重點:不能再讓操作者以為「什麼都沒發生,再按一次就好」 + expect(msg).not.toContain('失敗'); + expect(msg).not.toContain('請稍後再試'); + }); + + it('409:一樣重讀,並且把後端說的原因原樣帶出來', () => { + const { client, calls } = fakeClient(); + const notify = { warning: vi.fn(), error: vi.fn() }; + + handleWriteError(axiosErr(409, { error: '訂單狀態已變更,無法取消' }), '取消失敗', { + notify, + queryClient: client, + invalidate: [['ride', 30]], + }); + + expect(calls).toEqual([['ride', 30]]); + expect(notify.warning.mock.calls[0][0]).toContain('訂單狀態已變更,無法取消'); + expect(notify.warning.mock.calls[0][0]).toContain('已重新整理'); + }); + + it('明確的失敗:照原本行為顯示原因,且**不動畫面**(免得蓋掉他正在看的東西)', () => { + const { client, calls } = fakeClient(); + const notify = { warning: vi.fn(), error: vi.fn() }; + + handleWriteError(axiosErr(400, { error: '參數錯誤' }), '取消失敗', { + notify, + queryClient: client, + invalidate: [['ride', 30]], + }); + + expect(calls).toEqual([]); + expect(notify.warning).not.toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith('參數錯誤'); + }); +}); diff --git a/src/utils/writeError.ts b/src/utils/writeError.ts new file mode 100644 index 0000000..fdfae84 --- /dev/null +++ b/src/utils/writeError.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; +import type { QueryClient, QueryKey } from '@tanstack/react-query'; + +import { apiError } from './apiError'; + +/** + * 寫入操作「結果不明」的判準:請求送出去了,但我們沒有拿到後端對這次操作的答案。 + * + * - **連線類**(逾時、斷線、後端沒回應):後端很可能**已經執行完了**,只是回應在路上遺失。 + * - **409**:後端明說「當下狀態不允許」——在重試的情境下,多半正是**上一次其實已經生效**。 + * + * 兩者的共同點是「畫面上的狀態已經不可信」,該做的事一樣:重讀後端、如實說明。 + */ +export function isUncertainWrite(err: unknown): boolean { + if (!axios.isAxiosError(err)) return false; + return !err.response || err.response.status === 409; +} + +/** 只需要 message.warning/error 兩支;用結構型別避免把 antd 的型別帶進 utils。 */ +type Notifier = { + warning: (content: string) => void; + error: (content: string) => void; +}; + +/** + * 寫入失敗的統一出口:**結果不明時一定要重讀後端,並且不能說「失敗了」**。 + * + * 為什麼把「顯示訊息」與「重新整理」綁在同一支函式:這兩件事分開寫,遲早會出現 + * 「訊息說畫面已重新整理、但其實沒有」的謊。綁在一起就沒有那個失敗模式。 + * + * 明確的失敗(400/403/500…)維持原本行為:顯示原因、不動畫面,讓操作者自己重試。 + */ +export function handleWriteError( + err: unknown, + fallback: string, + ctx: { notify: Notifier; queryClient: QueryClient; invalidate: QueryKey[] }, +): void { + if (!isUncertainWrite(err)) { + ctx.notify.error(apiError(err, fallback)); + return; + } + for (const queryKey of ctx.invalidate) { + void ctx.queryClient.invalidateQueries({ queryKey }); + } + const conflict = axios.isAxiosError(err) && err.response?.status === 409; + ctx.notify.warning( + conflict + ? `${apiError(err, fallback)}(可能上一次已經生效,畫面已重新整理,請確認結果)` + : '沒有收到後端回應,這次操作可能已經生效——畫面已重新整理,請確認結果後再決定是否重試', + ); +}