Feature #1399
是由 陳國瑋 於 約 24 小時 前更新
## 目標
依父項 #1397 及其英文/繁體中文規格附件,完成 LIFF 對新版 DriverState、semantic actions、長期離峰充電與 WebSocket 即時狀態契約的串接。
附件內容為本子任務的需求與驗收基準。
## 實作範圍
* BE OpenAPI contract 完成後重新產生 types。畫面依據 `driverState.title`、`description`、`actions` 與 semantic action code 呈現,不自行由 raw connector status 重建業務狀態。
* `PREPARING` 顯示「準備中」,並停用重複開始操作。
* `FINISHING` 顯示「停止中」,並停用重複停止操作。
* `OFF_PEAK_WAITING` 與 `QUEUED` 都提供「取消排隊」主要操作,但必須透過 action code 呼叫正確 command,不得以按鈕文字判斷操作。
* `PUBLIC` Connector 不顯示離峰 switch;此項顯示差異保留在 FE。
* `PRIVATE` Connector 的離峰 switch 保持可點擊。送出後以 API authoritative response 判斷成功,或顯示不允許原因,不在 FE 複製完整 business matrix。
* 在離峰 `CHARGING` 時,不允許直接關閉 off-peak switch;停止本輪充電時需顯示確認 modal。
* 確認停止後,本次 current off-peak window 不再自動充電;若使用者反悔,需先切換為 OFF,再切換為 ON 重新排程。
* Start/Stop/Off-Peak command 使用 `operationId`/idempotency contract,並處理以下結果:
* 結構化 `403`/`409`
* `Rejected`
* timeout
* 暫態失敗訊息
* Connector 詳細頁只使用 WebSocket initial snapshot 取得 connector info:
* 不再額外呼叫 detail REST API
* 不進行輪詢
* 不使用 REST 作為斷線 fallback
* 初次 WebSocket 連線失敗時,只進行一次短暫重連;若仍失敗,則退回列表並顯示 modal。
* 已連線後發生斷線時:
* 立即停用舊 snapshot 的 `driverState.actions`
* 不得使用 stale actions 執行 Start、Stop 或 Off-Peak
* 只進行一次短暫重連
* 重連失敗時,退回列表並顯示 modal
* 重連成功後,必須收到 fresh snapshot 才能恢復操作
* 補齊必要的 loading、pending、confirmation、error modal 與重連狀態,避免重複點擊與狀態倒退。
## 驗收重點
* `PUBLIC`/`PRIVATE`、`MANUAL`/`OFF_PEAK` 的 switch、主要按鈕與 modal 行為符合 #1397 附件 matrix。
* CloudLink/Fortune 在「先插槍、尚未按開始」時,仍顯示正確的可開始操作;只有真正送出且尚未終結的 RemoteStart,才顯示 `PREPARING`/「準備中」。
* 離峰開啟成功後,`OFF_PEAK_WAITING` 立即顯示「取消排隊」;建立 Priority 後轉為 `QUEUED`,操作仍為取消排隊。
* RemoteStart/RemoteStop 失敗時,會向車主顯示本次操作未成功,不會無提示地回到可重複按開始/停止的畫面。
* WebSocket 尚未連上、已斷線或 snapshot 已過期時,使用者不能沿用舊 actions 執行操作。
* 測試範圍涵蓋:
* 公樁/私樁
* CloudLink/Fortune
* 手動/離峰
* 成功/拒絕/逾時
* 初次連線失敗
* 既有連線中斷
## 邊界
* API 授權、command 合法性與 operation lifecycle 由 BE authoritative 判斷;FE 僅依 contract 呈現並送出語意化 action。
* Charge Group 與 `PUBLIC` slot 設定問題,分別由 #1395、#1396 處理。
## 備註記錄(8 則)
### #1 - 陳國瑋(2026-07-31T03:09:43Z)
#### 2026-07-31 BE/FE WebSocket contract 補充(Ken 已確認)
* 新版 Backend 會直接移除舊的 `driverState.start`/`driverState.stop`;FE 必須只使用 `driverState.actions[]` 與 semantic action code,不得保留對舊欄位的依賴。
* `driverState`、`driverState.reason` 與 `driverState.actions[]` 是 Connector 共用狀態,會推送給所有正在觀看該 Connector 的合法 sessions。
* Transient `operationResult` 是使用者定向通知,不得當成 Connector 共用 modal 廣播:
* 手動 Start/Stop/Cancel 的非同步結果,以實際 `requestedByUserId` 為 target。
* Scheduler 發起的 OFF_PEAK RemoteStart 結果,以長期離峰設定的 `enabledByUserId` 為 target。
* Branch MQ payload 會提供 `operationResult.targetUserId`;HQ WebSocket 依已驗證 session 對應的 Branch user identity 篩選,只將 operation result 傳給 target user。這只是通知路由,不在 HQ 重做 command authorization。
* FE 收到 `operationResult` 時,依 `operationId` 去重後顯示一次結果 modal;不得因後續共用 `driverState` 更新而重複顯示同一結果。
* WebSocket initial snapshot 不重播已終結的 transient operation result;重新開頁或 reconnect 不應顯示過期失敗 modal。仍持續生效的問題由目前 `driverState.reason`/description 顯示。
* 非 target 的其他關聯住戶仍會收到共用 driverState 更新,但不會收到該次 operation modal。
此契約由 Backend #1398 實作,FE #1399 依最終 OpenAPI/WebSocket DTO 串接;兩張 ticket 必須協調部署。
### #2 - 陳國瑋(2026-07-31T03:59:32Z)
#1397 需求附件已於 2026-07-31 修訂並重新上傳:
- 英文附件 #1102
- 繁中附件 #1103
FE 請依新版契約調整:移除舊 driverState.start/stop 與既有 LIFF start/stop/dequeue/offpeak-switch 呼叫,統一改呼叫 POST /api/connector/commands,action 使用 START_CHARGING、STOP_CHARGING、CANCEL_START、CANCEL_MANUAL_START、ENABLE_OFF_PEAK、DISABLE_OFF_PEAK。開始/停止成功 dispatch 為 202 並取得 operationId;用 operationId 去重 targeted WebSocket operationResult 並只顯示一次 modal。WebSocket initial snapshot 不會重播 terminal result,只可能帶未完成的 activeOperation。公樁仍由 FE 隱藏離峰 switch。
### #3 - 陳國瑋(2026-07-31T10:11:35Z)
#### 2026-07-31 BE/FE 人工 E2E 交接
#1398 Backend 已部署到 Ken Branch 與 docker1 HQ staging,安全 API/WebSocket contract 驗證已通過;完整 E2E 將等待本 #1399 完成並部署後,由 LIFF 起點人工執行。
父項 #1397 已新增附件 #1106:
redmine-1397-liff-manual-e2e-test-plan-zh-TW.md
FE 完成後的共同驗收重點包括:
- 詳細頁只用 WebSocket initial snapshot,不另 call detail REST。
- public 不顯示離峰 switch;private 顯示 switch。
- READY/OFF_PEAK_WAITING/QUEUED/PREPARING/CHARGING/FINISHING 與 semantic actions。
- operationId、一次性 targeted operationResult、initial snapshot 不重播 terminal result。
- WebSocket 斷線時舊 actions 立即不可操作,一次重連失敗後退列表並顯示 modal。
- 離峰 charging 停止確認、current-window suppression,以及 OFF→ON 才重新排程。
- PUBLIC 非 transaction owner 停止被拒絕;PRIVATE 任一目前關聯住戶可停止。
附件另含 Ken 環境 CP001-001/002 的公私樁切換、資料準備、16 個案例、證據與還原步驟。此 note 只做驗收交接,不變更 #1399 目前狀態。
### #4 - 陳國瑋(2026-07-31T10:33:36Z)
#### 2026-07-31 POST /api/connector/commands OpenAPI/FE 使用補充
FE 回報 `POST /api/connector/commands` 的 200/202/403/409 response body 在 OpenAPI 只顯示通用 object,導致 `yarn openapi:refresh` 無法產生可用型別。這不是 FE 使用方式問題,而是 #1398 Backend 原本 endpoint 使用 `ResponseEntity<?>` 且 `@ApiResponse` 未明確指定 schema。
#1398 已補 OpenAPI schema annotation:
* `200 OK` 與 `202 Accepted` response body:`ConnectorCommandResponse`
* `403 Forbidden` 與 `409 Conflict` response body:`ConnectorCommandErrorResponse`
請 FE 在新版 OpenAPI 產出後依下列契約串接。
##### Request
`POST /api/connector/commands`
Header:
* `X-Line-Id`: 由 LIFF session 帶入,FE 不傳 userId。
Body:
```json
{
"buildingId": "B001",
"connectorId": "CP001-001",
"action": "START_CHARGING"
}
```
`action` 必須直接使用目前 snapshot 的 `driverState.actions[].code`,可用值:
* `START_CHARGING`
* `STOP_CHARGING`
* `CANCEL_START`
* `CANCEL_MANUAL_START`
* `ENABLE_OFF_PEAK`
* `DISABLE_OFF_PEAK`
FE 不要用按鈕文字反推 action,也不要自行組 OCPP command。
##### Success response:200/202 = ConnectorCommandResponse
欄位:
* `action`: 本次執行的 semantic action。
* `operationId`: 只有實際 dispatch RemoteStart/RemoteStop 時會有值;同步 command 可能為 null。
* `operationStatus`: 只有非同步 operation 會有值,通常為 `SENT`。
* `enableOffPeak`: Branch 實際離峰偏好;FE switch 應以此值校正。
* `driverState`: command 後最新狀態;FE 應立即用它更新畫面。
* `activeOperation`: 尚未終結的 RemoteStart/RemoteStop operation;沒有則為 null。
`200 OK`:同步完成,例如建立/取消 queue、開關離峰偏好、取消尚未 dispatch 的 start 等。通常不代表有 `operationId`。
`202 Accepted`:RemoteStart/RemoteStop 已送出,但尚未等到 OCPP 最終結果。FE 進入 pending UI,保存 `operationId`,等待 WebSocket targeted `operationResult`,並用 `operationId` 去重 modal。
範例:
```json
{
"action": "START_CHARGING",
"operationId": "18456",
"operationStatus": "SENT",
"enableOffPeak": false,
"driverState": {
"code": "PREPARING",
"title": "準備中",
"description": "正在要求充電樁開始充電",
"reason": null,
"actions": []
},
"activeOperation": {
"operationId": "18456",
"type": "START",
"source": "MANUAL",
"status": "SENT",
"requestedAt": "2026-07-31T18:20:00",
"deadlineAt": "2026-07-31T18:20:30"
}
}
```
##### Error response:403/409 = ConnectorCommandErrorResponse
欄位:
* `errorCode`: 穩定 business error code,供 FE 做必要分流。
* `message`: 可顯示給車主的錯誤訊息。
* `enableOffPeak`: Branch 實際離峰偏好;尤其 off-peak switch 被拒絕時,FE 必須用它回復 switch 狀態。
* `driverState`: 拒絕當下最新狀態;FE 應用它更新主要按鈕與狀態文字。
`403 Forbidden`:使用者或 connector 存取權限不允許,例如 PUBLIC 非 transaction owner 停止、PRIVATE 未關聯住戶操作等。
`409 Conflict`:使用者有權看該 connector,但目前狀態不允許該 action,例如 charging 中重複 start、pending operation 中重複操作、離峰 current-window suppression 等。
範例:
```json
{
"errorCode": "COMMAND_NOT_ALLOWED",
"message": "目前狀態不允許執行此操作",
"enableOffPeak": true,
"driverState": {
"code": "CHARGING",
"title": "充電中",
"description": "車輛正在充電",
"reason": null,
"actions": []
}
}
```
##### FE AI 實作重點
* 呼叫前:只允許使用 fresh WebSocket snapshot 裡 enabled 的 `driverState.actions[]`。WebSocket 斷線期間不得沿用舊 actions。
* 呼叫後:無論 200/202/403/409,都要以 response body 的 `driverState` 與 `enableOffPeak` 校正畫面。
* 只有 202 且有 `operationId` 時,才等待 WebSocket `operationResult` 顯示一次性 modal。
* 403/409 不會再透過 operationResult 補一次;FE 應直接顯示 response body 的 `message`。
* PUBLIC connector 不顯示 off-peak switch;PRIVATE connector 可送出 switch command,由 API 回應決定成功或拒絕。
### #5 - 陳國瑋(2026-07-31T13:25:31Z)
#### 2026-07-31 HQ staging OpenAPI 已更新,可重新產生 FE types
#1398 Backend 已 push 並重新部署 HQ staging。
* Branch:`feature/redmine-1398-driver-state-offpeak-backend`
* Latest commit:`a21baa3`
* HQ staging container:`ems-hq-staging-api` 已重建並為 `Up`
已驗證 staging HQ `/api-docs` 的 `POST /api/connector/commands` response schema:
* 200/202:`ConnectorCommandResponse`
* 403/409:`ConnectorCommandErrorResponse`
Ryan/FE AI 現在可以重新執行 `yarn openapi:refresh`。若產生的 client 對非 2xx response 不直接回傳 typed body,FE 仍需在 HTTP error handler 讀取 response body,body schema 依本 ticket 前一則 contract note 的 `ConnectorCommandErrorResponse` 處理。
### #6 - 鍾正剛(2026-07-31T14:36:18Z)
#### 2026-07-31 FE 實作完成(待部署後人工 E2E)
已完成 #1399 前端實作,branch: `feature/redmine-1399-liff-driver-state-actions`(commit 2e4386b),依 #1397 附件 #1102/#1103 與 note #4940 的 command API 契約串接。**尚未建立 MR。**
##### 主要變更
* 所有車主操作統一改呼叫 `POST /api/connector/commands`,`action` 直接使用 `driverState.actions[].code`,不以按鈕文字反推指令;移除舊的 start/stop/dequeue/offpeak-switch 與 `driverState.start`/`stop`。
* 詳細頁改為 WebSocket-only:移除 detail REST 呼叫、輪詢與斷線 fallback。
* 新增 `activeOperation`/`operationResult` 支援;202 進入 pending,以 `operationId` 去重、結果 modal 只顯示一次,initial snapshot 不重播。
* WebSocket 斷線立即停用舊 actions;一次短暫重連仍失敗即退回列表並顯示 modal。
* 200/202/403/409 一律以回應的 `driverState` 與 `enableOffPeak` 校正畫面。
* 離峰 switch 改由 `ENABLE_OFF_PEAK`/`DISABLE_OFF_PEAK` action 決定,FE 不再維護 business matrix;停止離峰充電顯示規格 §11.5 三項說明的確認 modal。
##### 與 Ken 確認後的調整
* **公私樁合併為單一路由** `/charger/[id]`,刪除 `/charger/[id]/private` 與兩份重複 dashboard,差異改由 WebSocket snapshot 的 `areaType` 與 `actions[]` 導出。順帶修正既有問題:`rate-info.tsx` 的「返回設備」原本一律連到公樁畫面,私樁使用者會看不到離峰 switch。
* **移除全部 FE 狀態轉換與死碼**:`getChargerStatus`/`ChargerStatus` 及七個未使用型別、`useConnector`(未使用且有 10 秒輪詢)、`logUserAction`。
* **操作行為紀錄**:原 `logUserAction` 呼叫的 `/api/logs/user-actions` 會被 rewrite 轉到 HQ,而 HQ 沒有任何 log endpoint,實際上每次開始/停止都在等一個必定 404 的請求且結果被吞掉,等於沒有留下紀錄。已改走 `logUI`(→ `/api/logs` → winston)並統一 metadata:`action`/`phase`/`result`/`httpStatus`/`operationId`/`errorCode`/`driverState` 前後值/被阻擋原因。`operationId` 與 Branch `e_remote_transaction_log.id` 相同,可依附件 #1106 §11.2 串起 LIFF、HQ、Branch、OCPP 與 transaction。另修正 WS log 原本寫入完整 payload 的問題,改為只記摘要。log 保存期由 14d 調整為 **90d**。
##### 部署注意(重要)
依規格不提供舊契約相容層,因此**本 branch 必須與 #1398 協調部署**。2026-07-31 查核:staging `hq-api.sylksoft.com` 已是新契約;正式站 `hq-api.cloudlinkems.com` 仍為舊版且 `DriverConnectorPresentationDto` 尚未存在。若在正式站 HQ 升級前部署本前端,充電功能會全面失效。
另需同步更新各環境的 `LOG_MAX_FILES=90d`(`env.example` 已更新,實際部署環境變數需一併調整)。
##### 驗證狀態
* `npx tsc --noEmit`:通過
* `yarn build`:通過
* `yarn lint`:**無法執行**,既有問題(`eslint-config-next` 以 CommonJS 匯出,`next lint` 讀取 `rules` 具名匯出失敗),發生在載入 ESLint 設定階段,與本次改動無關,本次未修改任何 lint/build 設定。
* 實機 E2E:**尚未執行**,依附件 #1106 於部署後由 LIFF 起點人工執行,未預先標記任何案例為 PASS。
##### E2E Catalog Impact
**No catalog change**。本次 FE 行為已完整涵蓋於 #1398 宣告的 UI-002、UI-010、CHG-033~035、ROT-021~024;為避免與 #1398 branch 對 `documents/E2E_TEST_CASE_INVENTORY.md` 的大幅修改衝突,本 ticket 不重複編輯該檔。
完整交付說明見 branch 內 `documents/redmine-1399-liff-driver-state-fe-notes.md`。
### #7 - 鍾正剛(2026-07-31T15:07:09Z)
#### 2026-07-31 修正:command 被送出兩次
回報按下停止充電時 `POST /api/connector/commands` 會送兩次。已定位並修正,commit `dc82cd6`。
##### 原因
`src/components/query-provider.tsx` 的 QueryClient 全域預設為 `mutations: { retry: 1 }`。React Query 只在 `mutationFn` reject 時重試,因此只要 command 被拒絕(403/409)或逾時,就會自動再送一次,總共兩次。
也就是說:**看到兩次請求,代表第一次其實已經失敗**,第二次是自動重送。
##### 風險
connector command 不是 idempotent —— Branch 每次成功 dispatch 都會持久化一筆 remote operation。自動重試違反規格:
* §9:operation 未完成期間不得盲目重送 RemoteStart。
* §10:不自動重送 RemoteStop。
* §15.2:同一 Connector 不得存在重疊的未完成 operation。
最危險的是逾時情境:第一次請求可能已送達 Branch、只是回應遺失,自動重試就會送出第二筆真實的充電指令,並在 audit log 產生重複的 operationId 紀錄。
##### 修正
在 `useConnectorCommand()` 明確覆寫 `retry: 0`,重試一律改由車主明確再按一次。已加註解說明不可移除的理由。
驗證:`npx tsc --noEmit` 與 `yarn build` 皆通過。
##### 另外提醒(不在本次修改範圍)
全域 `mutations.retry: 1` 仍套用在其他 mutation 上(例如綁定流程)。這些同樣不是 idempotent,建議評估是否將全域預設改為 `retry: 0`、再由個別需要重試的查詢自行開啟。若要調整,因影響範圍跨功能,建議另開 ticket 處理。
### #8 - 鍾正剛(2026-07-31T15:11:57Z)
#### 2026-07-31 補充:mutation 全域關閉自動重試
依 Ryan 指示,將 QueryClient 的全域 `mutations.retry` 由 1 改為 **0**,commit `524aaaf`。
盤點結果:專案內共 5 支 mutation,先前全部繼承 `retry: 1`,且全部都是有副作用、非 idempotent 的操作:
| 位置 | 操作 | 自動重試的後果 |
|------|------|---|
| `app/charger/hooks/use-connector-commands.ts` | connector command | 重複的 RemoteStart/RemoteStop operation 與 audit 紀錄 |
| `src/hooks/use-binding.ts:206` | sendVerificationCode | 重複發送 OTP 簡訊,使用者收到兩組驗證碼 |
| `src/hooks/use-binding.ts:219` | verifyCode | 多消耗一次驗證次數,可能觸發鎖定 |
| `src/hooks/use-binding.ts:191` | executeBinding | 重複執行綁定 |
| `app/setting/hooks/use-unbind.ts:28` | unbindUser | 重複解除綁定 |
沒有任何一支適合自動重試,因此改為全域 `retry: 0`,重試一律由使用者明確再操作一次。`useConnectorCommand` 仍保留自身的 `retry: 0` 宣告,確保全域設定日後被改動時,這支安全關鍵的 API 仍不會被重送。
驗證:`npx tsc --noEmit` 與 `yarn build` 皆通過。
返回