重點摘要
- 從 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 輔助開發中極其常見:
- 審查 Agent:從原版 HTML / JS 逐行讀 → 產出缺失清單 → 每項有行號出處 ✅
- 實作 Agent / 新 Session:拿到缺失描述(如「公告無窮捲軸缺失」)→ 直接寫 Flutter → 沒有讀原版邏輯 ❌
- 結果:修了 A,同頁的 B/C/D 仍缺(因為根本沒讀那頁的完整原始碼)
- 下輪審查:再從原版讀 → 再找到 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.token | validate_auth 讀 data.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 API | Web 改用 SharedPreferences |
Platform.isAndroid Web crash | dart:io 在 Web 不可用 | kIsWeb + defaultTargetPlatform |
| CORS 重複 header | Access-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-define | Web 連到 localhost | --dart-define=API_BASE_URL=http://IP:PORT |
八、AI 輔助逆向工程的認知陷阱
AI 輔助逆向工程的認知陷阱是這個專案最深層的教訓。AI 在讀原始碼方面能力極強,但在以下場景會系統性犯錯:
- 「我以為我知道這頁有什麼」——AI 會基於已讀的程式碼推測未讀的部分,經常猜錯
- 改名後誤報缺失——搜尋
scan_addr_code()找不到,但 Flutter 用了_scanAddressCode(),同樣邏輯換了名字就被誤報 - 函數名稱 ≠ 實際行為——
updateUser()實際上還做 log 寫入、狀態機轉換;getActiveItems()SQL 是status != 'deleted'不是status = 'active' - 「延後功能」不是藉口——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 = 正確的做法是什麼(模式)。兩個都要讀。
十、建議給想做逆向重建的團隊
- Source Inventory 先行——不管系統多簡單,先把所有功能列出來(附行號)。簡單的系統往往有最多隱性規則
- 後端先跑通——先用 curl 打通每一個 API endpoint,確認 request/response 格式,再寫 Flutter
- 每個模組獨立測試——不要等到所有模組做完才整合。我們的 21/21 unit + integration test 在開發過程中抓到了大量問題
- 記錄每一個坑——Brain 系統不是可選的,是必要的。第一套 APP 踩的坑如果沒記下來,第三套 APP 會再踩一次
- 不要信任 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(brokerFactory、commonServerFactory、omFactory、api-vender),每個打不同的 server。重寫時必須識別每個 API call 走的是哪個 factory、打的是哪個 server。
總結:為什麼驗收時才發現這些問題
以上 5 個坑有一個共通點:「看了原始碼」不等於「看完了原始碼」。每次都是「看了主要的幾個檔案」但漏了中間層(config.js 映射、factory 路由、型別推導規則)。
逆向重建的正確審查流程應該是:從 PHP handler 的 switch/case 開始,反向追每一個常數值回到 config.js,再追到 controllers.js 的呼叫端。不是從呼叫端出發猜 PHP 接受什麼。方向反了,就永遠追不完。
發佈留言