Ionic 逆向重建 Flutter 完整踩坑紀錄:三套 APP、20+ 個坑、一套方法論

重點摘要

  • 從 Ionic 1 / AngularJS + WordPress PHP 逆向重建三套 Flutter APP,歷時一週,踩了 20+ 個分類的坑
  • 最毒的方法論錯誤:審查時讀了原始碼,實作時卻只靠缺失清單——導致多輪審查永不收斂
  • PHP Broker API 的 Content-Type 是 text/html、數值欄位全是 String、token 欄位名稱每個子系統不同
  • WordPress Multisite 的 table prefix 陷阱:直接打 plugin 路徑走 main site(wp_),跟 WP Admin 看到的(wp_2_)不同
  • 逆向工程的核心原則:原始碼是唯一 ground truth,不是用戶的記憶,不是截圖,不是 AI 的推測

這篇文章記錄了一個完整的逆向重建專案:將一套社區管理系統(三套 APP + WordPress 後端)從 Ionic 1 / AngularJS 遷移到 Flutter。不是新開發,是一比一逆向複製——原版有的每一個按鈕、每一個欄位規則、每一個邊角功能,新版都必須完全復現。

三套 APP 分別是:住戶 APP(社區公告、包裹、費用等)、管理平板(出勤、包裹管理、公設租借、訪客登記等)、門禁保全 APP(簽到簽退、巡邏、QR 掃描)。它們共用同一個 WordPress PHP 後端。

以下是按主題分類的完整踩坑紀錄和方法論總結。

一、逆向重建最毒的方法論錯誤是什麼?

逆向重建最毒的方法論錯誤是「審查讀原始碼、實作不讀」。這聽起來不可能,但在 AI 輔助開發中極其常見:

  1. 審查 Agent:從原版 HTML / JS 逐行讀 → 產出缺失清單 → 每項有行號出處 ✅
  2. 實作 Agent / 新 Session:拿到缺失描述(如「公告無窮捲軸缺失」)→ 直接寫 Flutter → 沒有讀原版邏輯
  3. 結果:修了 A,同頁的 B/C/D 仍缺(因為根本沒讀那頁的完整原始碼)
  4. 下輪審查:再從原版讀 → 再找到 B/C/D → 循環不斷

我們經歷了 7 輪審查才讓管理平板的缺失清單收斂。前 3 輪每輪都有 10+ 個新發現,根本原因只有一個——方向反了。

建立的強制規則

  • 拿到缺失 ID 時,必須先讀對應的原始碼(file:line),不得只靠缺失描述動手
  • 修某頁某功能時,如果手上沒有該頁完整 Source Inventory → 必須先做 Source Inventory
  • Review 方向必須是:原版 HTML → 原版 JS → 原版 PHP → 列清單 → 逐項對照新版。禁止從 Flutter 出發猜原版有什麼

二、Source Inventory 協議:逆向工程的唯一正確開始方式

Source Inventory 是在動手寫任何程式之前,先把原版的所有功能逐行列出來的過程。每一項都必須標注原始碼來源(檔案 + 行號),沒有行號的項目等於捏造的。

- [ ] 登出按鈕
      來源:account.html:142 <button ng-click="logout()">

- [ ] 棟別/樓層兩層下拉選單
      來源:visitor-form.html:55-67, controllers.js:9366-9398

- [ ] 黑名單檢查(借用簽名後、API 呼叫前)
      來源:publicequip.js:2040-2052

清單涵蓋四大類:所有 UI 元素(按鈕、欄位、badge、空狀態提示)、所有互動行為(點擊、滑動、下拉刷新)、所有 API 呼叫及欄位映射所有頁面入口/出口

最容易漏的永遠是邊角功能:登出藏在 header component、空狀態提示在條件分支、黑名單檢查在簽名驗證之後。主流程之外的東西,AI 不會主動去找。

三、PHP Broker API 的七大陷阱

這套系統的 API 層是 WordPress PHP plugin,所有請求都走同一個 Broker endpoint。以下是我們踩過的每一個 API 相關的坑:

3.1 Content-Type 是 text/html

PHP Broker 的回應 Content-Type 是 text/html 而非 application/json。Dio(Flutter HTTP 套件)如果設了 ResponseType.json,會解析失敗。必須設 ResponseType.plain 再手動 jsonDecode

3.2 數值欄位全是 String,除了偶爾不是

大多數 JSON 數值欄位回傳的是字串("id":"1")。但 $wpdb->get_results() 對 int DB 欄位有時會回傳 PHP native int(JSON 裡沒有引號)。解法:所有 nullable String 欄位一律用 ?.toString(),避免 type 'int' is not a subtype of type 'String?'

3.3 Token 欄位名稱每個子系統不同

子系統 Token 放在 例外
主 Broker(住戶/平板)data.cookie
Guard Broker(門禁)data.tokenvalidate_authdata.cookie

搞混了就會被判定為 anonymous user,API 回 "非有效角色,無法使用!",而且沒有任何認證失敗的明確錯誤碼

3.4 returnCode 不統一

"OK""0"(package 模組)、"1""ERROR""dup"——全是字串,不要做整數比較。每個模組的成功碼不同,必須逐一確認。

3.5 Category / Method 命名混亂

有的是 snake_case(get_ticket_by_member_id),有的是 camelCase(retrivePublicEquipTXN——對,retrive 是拼錯的),有的是完全不同的規則(createTableAccount)。必須從 PHP 程式碼抄出精確字串,不能猜。

3.6 Response 結構不一致

  • mutual:回 {rows_1:[], rows_2:[]}(自己的 + 社區其他人的)
  • ticket:回 {data: {rows, total, totalPages}}(分頁結構)
  • package_light:returnCode 用 "0" 而非 "OK"
  • getTwCode:空表時回 data: ""(空字串),不是 []

3.7 磁卡批次 API 是 flat array

大部分 API 的 data 格式是 {data: {key: value}},但磁卡批次領取是平坦陣列 [{id, card_no, status}]。PHP 端用 is_array($data) 判斷。這意味著 Flutter HTTP client 的 data 參數不能寫死為 Map,必須改成 dynamic

四、WordPress Multisite 的 Table Prefix 陷阱

WordPress Multisite table prefix 陷阱是整個專案最令人崩潰的問題之一。直接打 plugin PHP 路徑(如 /wp-content/plugins/.../API_Broker.php)時,WP 識別為 main site (blog_id=1),使用 wp_ 前綴。但在 WP Admin 介面、phpMyAdmin 看到的是 wp_2_(子站表)。

後果:seed 測試資料時插到 wp_2_ulifeplus_*,但 API 查的是 wp_ulifeplus_*——資料永遠看不到,而且不報錯,就是空陣列。

另外,改網域必須改 5 個地方wp_options(siteurl + home)、wp_blogs(domain)、wp_site(domain)、wp-config.php(DOMAIN_CURRENT_SITE + WP_HOME + WP_SITEURL)。DOMAIN_CURRENT_SITE必須含 port,否則會無限 302 redirect。

五、validate_role() 全面封鎖事件

所有 28 個 PHP endpoint 都在進入主邏輯前呼叫 validate_role()。原版只接受 ulifeplus_api / ulifeplus_admin / administrator / ulifeplus_operator,不含住戶帳號 ulifeplus_subscriber

原因:原版平板用的是專屬服務帳號(QR code 開通碼流程),不是住戶帳號直接登入。重建版讓住戶直接登入 → 所有 API 一律 ERROR

修補方案需要雙層:(1) patch PHP 接受 ulifeplus_subscriber;(2) 給測試帳號加 ulifeplus_api role。而且 wp_capabilities 的序列化格式 a:N:{s:LEN:"role_name";b:1;} 的 LEN 必須精確——寫錯一個字元,整個 unserialize 就失效,用戶變成沒有任何 role。

六、三套 APP 的進化軌跡:從混亂到方法論

第一套:住戶 APP——摸清後端規則

住戶 APP 是最先做的,主要踩了後端相關的坑:Content-Type 是 text/html、數值欄位全是 String、member_id vs WP user_id 混淆、validate_role 全面封鎖、WP Multisite table prefix。這些經驗讓我們對 PHP Broker 的行為有了完整的心智模型。

第二套:管理平板——方法論崩潰與重建

管理平板是最複雜的(10+ 模組),也是方法論崩潰的戰場。7 輪審查、每輪都有新發現、多個 Agent 交叉審查卻互相誤報(false positive)。最終逼迫我們建立了 Source Inventory 協議、四大卡點規則、和「審查必須從原版出發」的強制流程。

核心教訓包括:

  • Grid → Card List 轉換會遺失 checkbox column 和 cellTemplate 等「免費」UI 行為
  • 條件式按鈕(enableShowClosed 等)的每個 ng-if 都是業務意圖,不能合併或省略
  • smart_disp.get_hidden_code() 遮名演算法需要完整移植,不能簡化
  • 日期範圍查詢必須含時間(00:00:00 / 23:59:59),否則 BETWEEN 漏掉當天
  • Feature flag 的預設值不一定是 false,必須讀完整條件判斷

第三套:門禁 APP——方法論成熟

到第三套門禁 APP 時,Source Inventory 協議已經內化。整個 APP 從原始碼分析到 21/21 測試通過只花了一天。但還是踩了新坑:

  • Guard Broker 的 token 欄位是 data.token(不是主 Broker 的 data.cookie
  • PHP class constant const data = "date" 從不生效(bareword array key 行為)
  • showDialog 的 BuildContext 跨 async 導致確認 dialog 靜默失敗
  • HTTP 部署下相機 API 被瀏覽器封鎖(Secure Context 限制)
  • Flutter Web 的 SPA 狀態在測試間污染(需 full HTTP reload)

七、Flutter 遷移的技術坑清單

症狀 修法
flutter_secure_storage Web 失敗HTTPS 才能用 Web Crypto APIWeb 改用 SharedPreferences
Platform.isAndroid Web crashdart:io 在 Web 不可用kIsWeb + defaultTargetPlatform
CORS 重複 headerAccess-Control-Allow-Origin: *, *PHP 已有 CORS,不要加 .htaccess
Dialog context stale確認後邏輯靜默跳過dialogCtx 不用外層 context
Release build debugPrint 消失完全無錯誤訊息寫 SharedPreferences breadcrumb
ConsumerWidget 無 initState需要 async 初始化改用 ConsumerStatefulWidget
base-href 部署子路徑資源 404--base-href=/app/
API_BASE_URL dart-defineWeb 連到 localhost--dart-define=API_BASE_URL=http://IP:PORT

八、AI 輔助逆向工程的認知陷阱

AI 輔助逆向工程的認知陷阱是這個專案最深層的教訓。AI 在讀原始碼方面能力極強,但在以下場景會系統性犯錯:

  1. 「我以為我知道這頁有什麼」——AI 會基於已讀的程式碼推測未讀的部分,經常猜錯
  2. 改名後誤報缺失——搜尋 scan_addr_code() 找不到,但 Flutter 用了 _scanAddressCode(),同樣邏輯換了名字就被誤報
  3. 函數名稱 ≠ 實際行為——updateUser() 實際上還做 log 寫入、狀態機轉換;getActiveItems() SQL 是 status != 'deleted' 不是 status = 'active'
  4. 「延後功能」不是藉口——AI 傾向把不確定的功能標記為「deferred」,但只要原版存在的就必須實作

破解方式:建立 Source Inventory 清單,每項附行號。行號是可審計的(可以回去原始碼驗證),AI 的記憶不可審計。

九、跨專案知識管理:Brain 檔案系統

為了讓踩過的坑不只活在一個專案裡,我們建立了 Domain Brain 系統:每個技術領域一個 markdown 文件,記錄所有踩過的坑、修法、和來源。

例如 legacy-code-rebuild.md 記錄了逆向工程的通用方法論;wordpress-broker.md 記錄了 WP Broker API 的所有特殊行為。每次 fix: commit 時,強制要求更新對應的 brain file。

核心原則:Brain = 上次做的時候踩了什麼坑(經驗)Skill = 正確的做法是什麼(模式)。兩個都要讀。

十、建議給想做逆向重建的團隊

  1. Source Inventory 先行——不管系統多簡單,先把所有功能列出來(附行號)。簡單的系統往往有最多隱性規則
  2. 後端先跑通——先用 curl 打通每一個 API endpoint,確認 request/response 格式,再寫 Flutter
  3. 每個模組獨立測試——不要等到所有模組做完才整合。我們的 21/21 unit + integration test 在開發過程中抓到了大量問題
  4. 記錄每一個坑——Brain 系統不是可選的,是必要的。第一套 APP 踩的坑如果沒記下來,第三套 APP 會再踩一次
  5. 不要信任 AI 的記憶——AI 會遺忘、會推測、會混淆。只有程式碼行號是可驗證的

結語

這個專案讓我深刻體會到:逆向重建的難度不在於「寫新程式」,而在於「完整理解舊系統」。舊系統的每一行 if/else 都是某個真實場景的反映,每一個看似多餘的欄位都有它存在的理由。

AI 可以加速這個過程,但無法取代「逐行讀原始碼」這個步驟。捷徑是最遠的路——我們前 3 輪審查走了捷徑(從 Flutter 出發找問題),花了 3 倍時間;改用正確方法論(從原版出發列清單)後,第三套 APP 一天就完成了。

希望這篇記錄能讓正在考慮做逆向重建的團隊少走一些彎路。

追記:驗收階段又踩的五個大坑(2026-04-13 深夜)

上面的文章發佈後幾小時,用戶開始實際驗收。結果又炸出一系列問題,每一個都是在開發階段「看了原始碼但沒看完整」造成的。

坑 10:Dart 的 Map<dynamic, dynamic> 型別推導陷阱

Flutter 的 API client 在呼叫 api.call(data: {}, token: token) 時,{} 空 Map literal 在 dynamic 參數中被 Dart 推導為 Map<dynamic, dynamic>。原本程式碼檢查 effectiveData is Map<String, dynamic>false → token 永遠不被加到 request 裡。

影響範圍:27 個 API call。所有傳 data: {} 的頁面全壞(儀表板、帳號開通等),但傳了明確 key 的頁面(如 data: {'address_code': x})正常。這造成「有些頁面好有些壞」的假象,極難排查。

教訓:原版 JavaScript param.token = value 不做任何型別檢查,直接賦值。重寫到靜態型別語言時,不要加「聰明」的型別守衛。笨方法之所以沒 bug,就是因為它笨。

坑 11:config.js 常數映射層被完全忽略

原版 Ionic 有一層 config.js 常數映射:sel → "select"upd → "update"del → "delete"add → "insert"sel2 → "select2"。Flutter 重寫時,有些地方用了短名(config.js 的 key),有些用了長名(PHP 接受的值)。PHP 只認長名。

影響範圍:19 個 API call。寄放交辦頁面完全打不開(PHP switch/case 不匹配,回 ERROR 但 message 空字串),debug 時完全看不到線索。

教訓:逆向重建時必須識別原版的每一層抽象。看了 controllers.js(呼叫端)和 factories.js(發送端)不夠——中間的 config.js 映射層不能跳過。每一個字串值都必須追到 PHP handler 的 switch/case 確認一致。

坑 12:捏造不存在的 API method

Flutter 程式碼裡出現了 method: 'select_category_detail'(包裹統計)和 category: 'util'(多帳號驗證碼)等原版完全不存在的 API call。追溯原始碼發現:前者在原版是純客戶端計算(groupBy + filter),後者正確的 category 是 'app_member'、method 是 'register_switchsite_validation_code'

教訓:如果在原始碼裡找不到某個 API endpoint,那它就不存在。不要「合理推測」一個 API 可能存在。

坑 13:API URL 不應該寫死在 compile time

Flutter 用 --dart-define=API_BASE_URL=http://192.168.0.48:8010 在 compile time 寫死 API URL。結果 LAN 和 HTTPS 要分別 build,Cloudflare 快取舊 JS 時 URL 就錯。

原版做法:API_BROKER_URL 在 config.js 是空字串,QR 碼啟動或登入時從 server 回傳 burl(blog_site_url),組合成完整 URL 存入 localStorage。

修法:Web 版用 Uri.base.origin 自動偵測當前頁面的 origin 作為 API base URL。不管從哪個網域開,API 自動跟著。compile time 的 --dart-define 只作為 fallback。

坑 14:外部服務走錯路由

廣告 Banner API 在原版是打一個完全獨立的外部伺服器(vender01.tw),不是走 WP Broker。Flutter 把它包成了 category: 'ad_banner' 送進 WP Broker,當然找不到。

教訓:原版有多個 factory(brokerFactorycommonServerFactoryomFactoryapi-vender),每個打不同的 server。重寫時必須識別每個 API call 走的是哪個 factory、打的是哪個 server。

總結:為什麼驗收時才發現這些問題

以上 5 個坑有一個共通點:「看了原始碼」不等於「看完了原始碼」。每次都是「看了主要的幾個檔案」但漏了中間層(config.js 映射、factory 路由、型別推導規則)。

逆向重建的正確審查流程應該是:從 PHP handler 的 switch/case 開始,反向追每一個常數值回到 config.js,再追到 controllers.js 的呼叫端。不是從呼叫端出發猜 PHP 接受什麼。方向反了,就永遠追不完。

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *