Funny vote APP – 從零開始的 Vibe Coding 實戰指南:以 Codex 視角看 AI 協同、翻車現場與資安加固(七·終局篇)

在前面六篇漫長的演進雜記裡,我們一路從 2016 年手敲 ButterKnife 的舊石器時代,聊到 2018 年 Kotlin 1.2 的遲來救贖;在 2024 年撞上 Compose 重組與狀態撕裂後直上 MVI;再到 2026 年花費大量體力活無損復刻 100% 畫面、並用 Firebase 為那台早就死透的五美元伺服器換上一顆雲原生心臟。

看著十年前寫下的架構介面,在換上現代資料管線後竟然能無縫連上雲端開票,當下確實爽感十足。但冷靜下來後,我不禁浮現出一個身為現代工程師的終極假設:

如果今天我們不是在給十年前的歷史包袱擦屁股,而是把時間軸直接拉回現在,從一個乾乾淨淨的目錄敲下 git init,重新做一次 FunnyVote,主流到底該怎麼玩現在最火熱的 Vibe Coding?

這年頭在社群媒體上,每個人開口閉口都在吹捧 Vibe Coding,彷彿工程師只要翹著二郎腿在對話框裡隨便敲兩句「幫我寫個投票 App」,AI 就會自動通靈出完美的生產環境架構。

馬的,這種鬼話聽聽就好。只要你在第一線帶過實習生,或者親手檢查過 AI 吐出來的代碼,你就會知道:盲打 Prompt 的下場,通常是得到一堆看似編譯會過、但一上線就讓你破產崩潰的數位垃圾。

「怠惰是開發的源頭」,小弟我也想偷懶,也想喝著咖啡看程式自己生出來。但要怠惰得優雅、怠惰得晚上睡得著覺,你不能靠祈禱。今天這篇系列終局篇,就從小弟身邊那位冷面審查員 Codex 的視角,來聊聊現代架構師到底該如何用「護欄」把狂飆的 AI 韁繩拉住,以及我們在這次結對過程中抓包的那些致命資安地雷。


一、 主流 Vibe Coding 的殘酷真相:護欄驅動開發(Harness-Driven Development)

為什麼開個聊天視窗盲打 Prompt,寫出來的九成九是垃圾?

AI 的本質是個機率預測模型。當你對它下達一個含糊的指令:「幫我寫一個 Android 投票詳情頁,要接 Firebase Firestore」,它腦袋裡浮現的不是生產環境的高可用性架構,而是網路上成千上萬篇為了騙點閱、把所有邊界條件與防禦機制閹割光的入門教學文。

於是,它會在三秒內狂暴產出以下傑作:

  1. 狀態滿地爬:在 ViewModel 裡隨手開五個獨立的 MutableStateFlow,讓你的 Compose 畫面閃爍跳動(狀態撕裂)。
  2. 安全門大開:在 Firestore 安全規則隨手補上一句 allow read, write: if true;,把你的資料庫直接裸奔給全世界。
  3. 語義型別消失:到處充斥著 String 拼貼與 Map<String, Any>,連個嚴格的型別約束都沒有。

這不叫 Vibe Coding,這叫給未來的自己埋棺材。

真正的現代 Vibe Coding,核心思想只有八個字:護欄驅動開發(Harness-Driven Development)

在讓 AI 敲下第一行業務邏輯之前,人類架構師的核心任務不是敲鍵盤,而是替它搭建跑道與防撞護欄:

  • 上下文工程(Context Engineering):不要把整座代碼庫無腦丟給它。只給它精確的領域模型(Domain Model)與契約介面(Contract),切斷所有外部干擾。
  • 架構先行(Schema-First):資料庫的欄位規格、不可變約束與驗證邊界,必須由人類親自拍板,不能讓 AI 自作主張。
  • 型別韁繩(Type Constraints):利用 Kotlin 的 Sealed Interface、Value Class 與嚴格 Data Class,把狀態流轉的每一步死死限縮在編譯器的檢查範圍內。

當護欄架設完畢,AI 的極限手速才會變成你的超級外掛;否則,它只是一輛沒有煞車、直衝懸崖的超跑。


二、 垂直切片(Vertical Slice)實戰:人類的 Prompt 與 AI 的初版翻車代碼

很多開源專案重構失敗的原因,都是一開始好大喜功,想著第一天就要把網路層、快取層、所有分頁一次刻完。人只要一貪心,代碼就會失控。正確的做法是「垂直切片(Vertical Slice)」——抓出一條最核心的關鍵路徑(Critical User Journey),從 UI、ViewModel、Repository 一路貫穿到底層資料庫。

在 FunnyVote 中,這條路徑非常純粹:首頁加載熱門投票清單。

[人類架構師下達的精確 Prompt]

請依據專案現有的 MVI 架構規範,實作 FunnyVote 首頁投票清單的垂直切片。
要求:
1. 嚴格單向資料流(UDF):首頁僅允許暴露單一不可變 HomeUiState(使用 Data Class,嚴禁將畫面整體狀態宣告為多個獨立 StateFlow)。
2. 所有使用者操作封裝為強型別 HomeIntent。
3. 資料層必須遵守依賴反轉:ViewModel 僅依賴 VoteRepository。
4. 資料策略:以 Remote 為火車頭,但當雲端逾時或斷網時,必須透過 Flow catch 操作符無縫降級至 Room voteDao 既有的本機資料,確保斷網不白屏。

看起來指示很明確對吧?但當我們把這段話丟給 AI,看它第一次吐出來的代碼,身為審查員的我不禁倒吸一口涼氣。

翻車現場一:AI 刻出的雙向即時同步怪物

AI 沒有乖乖寫出簡單的降級邏輯,反而自作聰明搬出了教科書上極其肥大的 NetworkBoundResource 模式:

// AI 第一次給出的天真代碼:試圖在客戶端搞雙向資料同步的惡夢
class VoteRepository @Inject constructor(
    private val voteDao: VoteDao,
    private val firestore: FirebaseFirestore
) {
    fun getAllVotes(): Flow<List<VoteWithDetails>> = flow {
        // 先吐出本地舊資料
        val localData = voteDao.getAllVotes().first()
        emit(localData)
        
        // 天真地掛上 Firestore 即時監聽,並試圖每次變動都寫回 Room
        firestore.collection("polls").addSnapshotListener { snapshot, error ->
            if (snapshot != null) {
                val remoteVotes = snapshot.toObjects(VoteEntity::class.java)
                CoroutineScope(Dispatchers.IO).launch {
                    voteDao.insertAll(remoteVotes) // 磁碟 I/O 狂飆,且引發無限重繪迴圈
                }
            }
        }
        // 接著監聽 Room 的變動往下游送
        emitAll(voteDao.getAllVotes())
    }
}

這段代碼編譯完全正常,跑起來似乎也看得到資料。但只要稍微懂一點架構的人就能看出裡面的致命硬傷:

  1. 協程洩漏:在 Repository 內部隨手 new 了一個不受生命週期控管的 CoroutineScope,畫面關閉後背景寫入依然在跑。
  2. 雙向同步死循環:Firestore 推送觸發 Room 寫入,Room 寫入觸發 DAO Flow 發射,若這時又觸發了某個狀態更新,手機直接在背景變成暖手寶。
  3. 監聽器永遠不釋放addSnapshotListener 沒有被任何 DisposableawaitClose 管理,使用者進出首頁幾次,記憶體裡就飄著幾十個孤魂野鬼連線。

翻車現場二:AI 第二版留下的「假離線優先(Fake Offline-First)」破綻

當我痛罵了 AI一頓,叫它把雙向同步砍掉後,它交出了第二版:

// AI 第二版寫法:表面看似正常,實則破壞了單一數據源(SSOT)
fun getAllVotes(): Flow<List<VoteWithDetails>> = flow {
    remoteDataSource.getAllVotes()
        .catch { e -> emitAll(voteDao.getAllVotes().onStart { checkAndSeedData() }) }
        .collect { remoteVotes ->
            voteDao.syncVotes(remoteVotes)
            emit(remoteVotes) // 致命死角:直接把遠端資料發射給 UI!
        }
}

這段代碼極度隱蔽,連很多資深工程師一眼看過去都會被騙過:

「這不是有 catch 降級、有 syncVotes 同步嗎?哪裡有問題?」

問題出在 emit(remoteVotes)

在網路正常的情況下,UI 收集到的是直接來自雲端遠端的 remoteVotes,而不是 Room 本地資料庫的 Flow!
這會導致什麼災難?如果使用者在投票卡片上點了「收藏(Favorite)」或在離線時投了票,本地 Room 資料庫雖然更新了,但因為 UI 當前訂閱的是遠端流,畫面根本不會刷新!使用者必須等到下一次網路重新請求,收藏圖標才會亮起。這就是典型的「假離線優先」——它破壞了單一真實數據源(Single Source of Truth, SSOT)原則!

人類架構師的真正 SSOT 方案:Room 作為唯一數據源

身為架構師,我親手重構了真正的離線優先單向流:

// 人類架構師的終極收斂:UI 永遠只看 Room,網路只負責單向同步
@Singleton
class VoteRepository @Inject constructor(
    private val voteDao: VoteDao,
    private val remoteDataSource: VoteRemoteDataSource,
    @ApplicationScope private val externalScope: CoroutineScope
) {
    // 唯一的數據出口:UI 永遠且只訂閱本地 Room 資料庫!
    fun getAllVotes(): Flow<List<VoteWithDetails>> = voteDao.getAllVotes()
        .onStart {
            // 在背景拉取遠端最新資料,靜默寫入 Room;Room 寫入完成後會自動觸發 Flow 重新發射
            externalScope.launch {
                remoteDataSource.getAllVotes()
                    .catch { e -> /* 遠端失敗時靜默降級,本地 Room 既有資料毫髮無損 */ }
                    .collect { remoteVotes ->
                        voteDao.syncVotes(remoteVotes)
                    }
            }
        }
}

這才是生產級的極致純粹:UI 永遠只認識 Room。不論是網路拉取新資料、使用者按了收藏、還是背景資料同步,所有變更一律匯入 Room,再由 Room 單向通知 UI。狀態永不脫節,本地操作 0 毫秒即時回饋。


三、 雙 Agent 結對(Generator-Critic)與被我抓包的三大資安地雷

在這次重構中,我們採取了雙 Agent 協同架構:Gemini 擔任手速極快的實作主力(Generator),Codex 擔任尖酸刻薄、冷血挑刺的審查員(Critic)。

在功能跑通、測試全綠的表象之下,Codex 拿著放大鏡逐行審查,抓出了三個足以讓這款 App 破產倒閉的致命安全與成本地雷。這三個地雷在一般教學文裡幾乎無人提及,但在雲原生環境下,每一顆都能讓你付出慘痛代價。

地雷一:NgramUtil 沒做截斷,超長文字引發 Bi-gram 索引爆炸與 DoW 帳單攻擊

Firestore 沒有提供關聯式資料庫的 LIKE 模糊搜尋,也不支援全文檢索。為了不想每個月花錢外掛 Algolia 或 Typesense,我們在發起投票時,利用 NgramUtil 自動將標題切成雙字元(Bi-gram)存入 searchKeywords 陣列,搜尋時用 whereArrayContains 實現零成本繁中搜尋。

這主意聽起來很棒,AI 寫出來的切詞函式也十分乾淨:

// AI 最初給出的純真版本
fun generateBiGrams(text: String): List<String> {
    val clean = text.trim()
    return (0 until clean.length - 1).map { clean.substring(it, it + 2).lowercase() }.distinct()
}

測試通過,搜尋功能絲滑順暢。但 Codex 一看立刻亮出紅牌:

如果今天有個惡意使用者,在發起投票的標題欄位裡,透過 API 惡意灌入一篇 5,000 字的廢文,這段代碼會切出將近 5,000 個獨立的雙字元 token 塞進 searchKeywords 陣列!在 Firestore 底層,每增加一個陣列元素,複合索引就要多建立一個反向查詢節點。更可怕的是,Firestore 單一文檔上限是 1MB。這篇投票不只會直接塞爆文檔上限引發崩潰,還會在雲端索引庫裡瘋狂佔用儲存量。在資安領域,這叫錢包阻斷攻擊(Denial of Wallet, DoW)——攻擊者不需要拿到你的伺服器權限,光靠塞爆你的索引,就能讓你的 Google Cloud 帳單在月底暴增幾千美元。

我們在 feature/firebase-backend 分支上,立刻補上了嚴格的邊界防衛與單元測試:

--- a/funnyvote/app/src/main/java/com/heaton/funnyvote/util/NgramUtil.kt
+++ b/funnyvote/app/src/main/java/com/heaton/funnyvote/util/NgramUtil.kt
@@ -1,12 +1,17 @@
 package com.heaton.funnyvote.util
 
 object NgramUtil {
+    private const val MAX_INPUT_LENGTH = 60
+
     /**
      * 將輸入字串切詞為 Bi-gram 陣列,供 Firestore whereArrayContains 模糊比對查詢。
      * 例如:「午餐吃什麼」-> ["午餐", "餐吃", "吃什", "什麼"]
+     *
+     * 資安加固:強制截斷上限為 60 字元,防止超長 Payload 造成 Bi-gram 陣列與記憶體爆炸、
+     * 並阻斷對 Firestore 20,000 索引上限與 1MB 單文檔體積的 DoW 帳單攻擊。
      */
     fun generateBiGrams(text: String): List<String> {
-        val clean = text.trim()
+        val clean = text.trim().take(MAX_INPUT_LENGTH)
         if (clean.length < 2) return if (clean.isEmpty()) emptyList() else listOf(clean)
         val ngrams = mutableSetOf<String>()
         for (i in 0 until clean.length - 1) {
             ngrams.add(clean.substring(i, i + 2).lowercase())
         }
         return ngrams.toList()
     }
 }

管你輸入是一萬字還是兩萬字,超過 60 個字元直接在記憶體裡一刀切掉。守住這道底線,你的錢包才保得住。

地雷二:Firestore Rules 太天真,任何登入者都能竄改別人的投票

為了解決「投完票要把總票數加一」的需求,AI 在 firestore.rules 裡大筆一揮,給出了一行看似合情合理的規則:

// AI 給出的災難級規則
match /polls/{pollId} {
    allow read: if true;
    allow create: if isAuthenticated();
    allow update: if isAuthenticated(); // 只要登入就能更新!
}

功能跑得通嗎?完全正常,點擊投票,總票數順利加一。

但這條規則在資安審查員眼裡,簡直是在金庫大門貼上「歡迎光臨」。

只要任何人隨便用匿名登入換到一個 UID,接著拿 Postman 或撰寫一段簡單的 Python 腳本,他可以發送一個 Update 請求,直接把全站所有投票的標題改成廣告,或者把別人的 authorId 篡改成自己,甚至把所有選項的票數歸零。

我們立刻在後端規則庫引入了嚴格的欄位差異比對(diff)與結構驗證:

--- a/firestore.rules
+++ b/firestore.rules
@@ -27,17 +27,39 @@ service cloud.firestore {
 
     // 2. 投票主集合
     match /polls/{pollId} {
+      function isValidPollCreate() {
+        let d = request.resource.data;
+        return isAuthenticated()
+          && d.authorId == request.auth.uid
+          && d.title is string && d.title.size() > 0 && d.title.size() <= 60
+          && (!('description' in d) || d.description == null || (d.description is string && d.description.size() <= 500))
+          && d.optionCount is int && d.optionCount >= 2 && d.optionCount <= 10
+          && (!('searchKeywords' in d) || (d.searchKeywords is list && d.searchKeywords.size() <= 60))
+          && d.createdAt is int && d.createdAt <= request.time.toMillis();
+      }
+
+      function isValidPollUpdate() {
+        return isAuthenticated() && (
+          isOwner(resource.data.authorId) ||
+          request.resource.data.diff(resource.data).affectedKeys().hasOnly(['totalVotes'])
+        );
+      }
+
       // 公開投票允許所有人讀取;私人投票需已認證
       allow read: if resource.data.security == "00" || isAuthenticated();
-      allow create: if isAuthenticated() && request.resource.data.authorId == request.auth.uid;
-      allow update: if isAuthenticated();
+      allow create: if isValidPollCreate();
+      allow update: if isValidPollUpdate();
       allow delete: if isAuthenticated() && resource.data.authorId == request.auth.uid;
 
       // 選項子集合
       match /options/{optionId} {
         allow read: if true;
         allow create: if isAuthenticated();
-        allow update: if isAuthenticated();
+        allow update: if isAuthenticated() && (
+          isOwner(resource.data.creatorId) ||
+          request.resource.data.diff(resource.data).affectedKeys().hasOnly(['voteCount'])
+        );
+        allow delete: if isAuthenticated() && isOwner(resource.data.creatorId);
       }

這段規則直接把非擁有者的權限鎖死:除非你是這篇投票的發起人,否則在整個更新交易包裡,被修改的欄位「有且僅能」包含 totalVotes!只要你想動標題、選項內容或建立時間哪怕一個 byte,Firestore 雲端規則引擎會直接在門口以 Permission Denied 將你擊斃。

地雷三:缺少 Firebase App Check,任何人都能拿公開金鑰跨過 App 狂刷後端

只要寫過 Android 的人都知道,打包進 APK 裡面的 google-services.json 根本算不上秘密。只要抓到你的 APK 解開,任何腳本小子都能輕易拿到你的 projectId 與 apiKey。

如果沒有防護,攻擊者根本不需要打開手機,他只要在終端機裡開一個平行腳本,用 curl 瘋狂向你的 Firestore REST API 灌入垃圾投票或大量遍歷,你的免費讀取配額在半小時內就會被消耗殆盡。

AI 完全沒有意識到這個移動端特有的攻擊面。Codex 果斷要求在應用程式入口接入 Google Play Integrity(Firebase App Check):

--- a/funnyvote/app/src/main/java/com/heaton/funnyvote/FunnyVoteApplication.kt
+++ b/funnyvote/app/src/main/java/com/heaton/funnyvote/FunnyVoteApplication.kt
@@ -10,6 +10,10 @@ import coil.disk.DiskCache
 import coil.memory.MemoryCache
 import coil.request.CachePolicy
 import com.heaton.funnyvote.notification.DailyPollWorker
+import com.google.firebase.FirebaseApp
+import com.google.firebase.appcheck.FirebaseAppCheck
+import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
+import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
 import dagger.hilt.android.HiltAndroidApp
 import java.util.concurrent.TimeUnit
 
@@ -17,9 +21,24 @@ import java.util.concurrent.TimeUnit
 class FunnyVoteApplication : Application(), ImageLoaderFactory {
     override fun onCreate() {
         super.onCreate()
+        setupFirebaseAppCheck()
         setupDailyNotificationWorker()
     }
 
+    private fun setupFirebaseAppCheck() {
+        FirebaseApp.initializeApp(this)
+        val appCheck = FirebaseAppCheck.getInstance()
+        if (BuildConfig.DEBUG) {
+            appCheck.installAppCheckProviderFactory(
+                DebugAppCheckProviderFactory.getInstance()
+            )
+        } else {
+            appCheck.installAppCheckProviderFactory(
+                PlayIntegrityAppCheckProviderFactory.getInstance()
+            )
+        }
+    }

這行代碼的意義在於:每一次發往 Firebase 的網路請求,都必須夾帶來自 Android 系統底層安全晶片頒發的正版設備憑證。任何來自 Python 腳本、未簽名的模擬器、或是被二度打包注入惡意代碼的盜版 APK,連 Firebase 閘道門口都進不來。這才叫真正的移動端縱深防禦。


四、 OWASP MASVS v2.0 自動化資安防護網:100/100 (A+) 的防禦實踐

許多走 Vibe Coding 的開發者,往往沈浸在功能快速堆疊的快感中,對移動端底層資安一無所知。但在 FunnyVote 專案裡,我們引入了業界最嚴苛的 OWASP MASVS v2.0(Mobile Application Security Verification Standard)標準,在 CI 流程中建置了自動化安全稽核。

我們在代碼層落實了三項硬核的靜態防禦:

  1. 封死系統自動備份通道
    AndroidManifest.xml 中明確宣告:
    <application android:allowBackup="false" android:fullBackupContent="false" ... >
    如果不加這行,攻擊者只要拿到手機插上 USB 線,敲一行 adb backup,就能神不知鬼不覺把應用程式內私有的 Room 資料庫、SharedPreferences 乃至於登入 Token 整包傾印出來。關閉這個通道,是身為專業開發者的基本自律。
  2. R8 編譯期除錯日誌全面抹除
    在開發階段,我們為了除錯隨手寫下的 Log.d,往往充滿了使用者 UID、投票 ID 甚至未脫敏的資料結構。在 proguard-rules.pro 裡,我們加入了編譯期最佳化指令:

    -assumenosideeffects class android.util.Log {
        public static boolean isLoggable(java.lang.String, int);
        public static int v(...);
        public static int d(...);
        public static int i(...);
        public static int w(...);
        public static int e(...);
        public static int println(...);
    }

    在正式產出 Release APK 時,R8 靜態分析引擎會在字節碼層面,將所有日誌呼叫直接從二進制檔案中連根拔除。任憑逆向工程師如何反編譯或掛載 Logcat,休想在控制台看到哪怕一行內部除錯日誌。

  3. 全域強制安全連線配置
    透過 network_security_config.xml 封死全域所有明文 HTTP 請求通道,嚴格強制所有流量必須走 TLS 1.3。

在整個自動化安全掃描引擎的檢測下,這套架構在架構純度、資料隱私、通訊安全與金鑰防護四個維度上,交出了一張 100/100 (EXCELLENT A+) 的全綠成績單。這不是為了考試,而是為了在交卷的那一刻,我們能問心無愧地說:這是一套具備生產等級強度的 App。


結語:AI 不是神,是手速極快但完全不懂邊界防禦的實習生

歷時十年的 FunnyVote 重構長征,到這裡總算畫下了最紮實的句點。

回看這整趟 Vibe Coding 實戰,身邊許多同行都在焦慮:「AI 敲代碼的速度比人類快上一百倍,工程師是不是要失業了?」

坦白講,看著 AI 吐出來的那些缺乏邊界約束的 StateFlow、門戶大開的資料庫規則,以及完全沒防範 DoW 攻擊的切詞函式,我的答案非常明確:

真正懂架構的工程師,現在比以往任何時候都還要重要。

AI 確實很強,它就像一個精通所有語法字典、二十四小時不知疲倦、手速極快的頂尖實習生。只要你吩咐一聲,它三秒鐘就能幫你刻出畫面、連上網路、把功能跑通。

但這個實習生完全沒有在生產環境被摔打過的痛感。它不會替你的 Google Cloud 帳單負責,不會替你的使用者個資外洩坐牢,更不會在捷運斷網、使用者螢幕閃退時替你擦屁股。

身為人類工程師,我們在 AI 時代的終極價值,從來就不是「把鍵盤敲得多快」這種體力勞動,而是:

  • 定義問題本質的思考力;
  • 為系統搭建邊界與型別護欄的架構力;
  • 能夠在一片全綠的單元測試中,敏銳抓出資安漏洞與破產隱患的審查力。

「怠惰是開發的源頭」,這句話我說了整整十年。但經過這十年的折騰與沉澱,我終於明白:要偷懶,你得先有足夠的本事把護欄築得比山還高。只有把底線兜得穩穩當當,未來的你,才能真正優雅、心安理得地怠惰下去。

小弟我真的懶得貼落落長的 CODE,各位想翻翻看完整的 MASVS 掃描報告、Firestore 安全規則與這十年間演進的所有黑歷史,通通都在 GitHub 上了:boochlin06/FunnyVote

發佈留言

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