專案

一般

配置概況

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` 皆通過。 

返回