[{"content":"我只是想做一個 App，但我一個人 用 AI 把想法變成真的 — 第一篇\n事情是這樣開始的 我是一個 Android 工程師。工作上寫程式，下班後也寫程式，算是那種對技術有點著迷的人。\n但有一天，吃飯的時候有個念頭冒出來，讓我後來花了將近一年的時間。\n那是一頓在日本餐廳的飯。菜單是日文的，我打開手機的翻譯 App，對著菜單掃了幾次——螢幕上確實出現了中文，但是以奇怪的方式浮在原始日文上面，版面一團混亂。我努力辨認哪道菜是什麼、多少錢，但根本無法好好看。\n最後我把手機放下，靠著用手指比著菜單、說破碎的英文，好不容易點了餐。\n走出餐廳的時候，我腦子裡一直轉著一個問題：\n「為什麼沒有一個 App，能把菜單整個掃進去，然後用清楚的格式呈現每道菜？」\n我回到台灣，這個問題還在。幾個月後，我決定自己做。\n「我一個人，要怎麼做一個 App？」 你可能覺得：他本來就是工程師，做個 App 不是很正常嗎？\n但你要知道，一個完整的 App 背後有多少東西：\nAndroid 客戶端：你在手機上看到的介面 後端 API：App 把照片傳到哪裡、資料存在哪裡 資料庫：你的翻譯歷史、帳號資訊存在什麼地方 AI 整合：誰來「看懂」菜單、翻譯內容 帳號系統：登入、登出、密碼、Google 帳號 訂閱付費：如果要賺錢，怎麼收費 安全設計：確保別人拿不走你的資料 通常，這些事情在科技公司由不同的人負責：前端工程師、後端工程師、資料庫工程師、設計師……\n我一個人。\n我的專業是 Android 開發，也就是說，我只有「Android 客戶端」那一塊是完整的強項。後端？幾乎不懂。安全設計？知道「不要 hardcode 密碼」，除此之外一片空白。\n這件事如果在三年前，我會怎麼做？\n答案很誠實：我不會做。不是因為不想，而是「一個人做不到」這個現實太重了，在開始之前就會放棄。\n但這是 2026 年，我有了一個新的工具：Claude（Anthropic 出品的 AI 助手）。\nAI 的第一個用途：打破「我不懂」的牆 我第一次認真用 AI 輔助開發，是在試圖搞清楚後端應該怎麼做的時候。\n我就這樣問 Claude：\n「我在做一個 Android App，需要一個後端 API 來處理菜單照片翻譯。我幾乎沒有後端經驗，預算很少，需要快速部署。你推薦什麼技術？」\nClaude 給了我一個比較，解釋了幾個選項的優缺點，然後問了我幾個問題：預計用戶量是多少？能接受的學習成本有多高？\n根據我的回答，它推薦了 Cloudflare Workers——一個在全球各地的伺服器節點上跑程式的服務，幾乎不需要維護，而且有相當多的免費額度。\n然後最重要的事發生了：它開始教我。\n不是只給我代碼叫我貼上去，而是一步一步解釋：「你的 App 發送一個 HTTP 請求，這個請求包含……」、「Workers 接收到之後，會……」\n我把這段解釋讀了兩遍，突然覺得「後端」不再是一個神秘的黑箱，而是一個我能想像的流程。\n那一刻我理解了 AI 真正的價值 用了幾個月之後，我發現真正讓我進步最快的，不是讓 AI 寫代碼，而是讓 AI 解釋我不懂的東西。\n有一次，我在後端碰到一個問題：發送到 AI 翻譯 API 的請求，有時候回傳「你的地區不支援」的錯誤。但我的帳號明明是美國帳號，為什麼？\n我把問題告訴 Claude，它解釋：你的程式跑在 Cloudflare 的伺服器上，Cloudflare 會把你的程式自動部署到離用戶最近的節點。如果你的用戶在亞洲，程式可能跑在香港的伺服器——而某些 AI API 在亞洲地區有限制。\n這個知識不是 AI 幫我「做完」某件事。它是讓我理解了一個我以前完全沒想過的問題維度。\n一個人 = 一個小團隊？ 做 MaiNeu 到現在，這個 App 包含了：\n完整的 Android 客戶端 後端 API（從零學起的） 資料庫設計和管理（從零學起的） 帳號系統和安全設計（從零學起的） 自動化測試和部署流程（從零學起的） AI 翻譯功能 這些在正常的科技公司，至少需要 4-5 個不同專業背景的工程師。\n我一個人，花了大概一年，做了一個接近完整的產品。\n以前，「我不懂後端」意味著「我做不了這件事」。 現在，「我不懂後端」意味著「我需要更多時間問問題和學習」。\n這是一個本質上的改變。\n但有些事情 AI 沒辦法替你做 第一，有了想法之後，你還是要自己決定。\n這個功能現在做有意義嗎？這個設計對用戶友善嗎？AI 可以給你分析、給你選項，但最終的判斷還是要你來。\n第二，AI 也會出錯。\n有一次，AI 給我的函式庫使用方法是錯的——那個函式庫的 API 已經在新版本裡改掉了，AI 的知識是舊的。我花了半天才找到問題在哪。\n第三，「學會問問題」才是真正的技能。 用 AI 的方式決定了你能得到多少幫助——這一點我想單獨在下一篇說清楚。\n給那些有想法但不確定能不能做的人 如果你有一個想法，但你覺得「我不懂技術，所以做不到」——\n「不懂技術」這件事，在 AI 工具出現之後，確實不再是最大的障礙了。\n最大的障礙，是你有沒有辦法把你的想法說清楚——說清楚給 AI 聽，說清楚給潛在用戶聽，說清楚給你自己聽。\n如果你能清楚地說出：「我想解決的問題是 X，目標用戶是 Y，我希望他們能做到 Z」——那你已經有了一個起點。\n接下來的事，我們邊做邊學。\n下一篇：當 AI 說「沒關係，我來解釋」——用 AI 學會我以前不懂的東西\n","permalink":"https://blog.maineu.com/general/01-one-person-one-idea/","summary":"\u003ch1 id=\"我只是想做一個-app但我一個人\"\u003e我只是想做一個 App，但我一個人\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003e用 AI 把想法變成真的 — 第一篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"事情是這樣開始的\"\u003e事情是這樣開始的\u003c/h2\u003e\n\u003cp\u003e我是一個 Android 工程師。工作上寫程式，下班後也寫程式，算是那種對技術有點著迷的人。\u003c/p\u003e\n\u003cp\u003e但有一天，吃飯的時候有個念頭冒出來，讓我後來花了將近一年的時間。\u003c/p\u003e","title":"我只是想做一個 App，但我一個人"},{"content":"為什麼做 MaiNeu？一個 Android 工程師的 Side Project 起點 MaiNeu 開發旅程 第一篇\n那頓飯的挫折 事情的起點是一頓普通的旅遊晚餐。\n菜單是日文的。我打開 Google Translate 的相機功能，對著菜單掃，螢幕上出現一堆漂浮的中文字——「炭火燒烤特選和牛」、「季節野菜天婦羅」——但這些字是貼在原始日文上方的，版面混亂，根本沒辦法看清楚哪道菜是什麼價格，有沒有我不能吃的成分。\n我用相機翻譯，翻了三次，最後還是靠著用手指比著菜單、用破碎的英文跟服務生溝通。\n回到台灣後，我一直在想一個問題：為什麼沒有一個 App，能把菜單掃描進去，然後用清楚的列表呈現每道菜的名稱、翻譯、價格？為什麼每次用翻譯 App 看菜單，都是一種「勉強能用」的體驗？\n這個問題在我腦子裡放了幾個月。2026 年初，我決定自己做。\n第一版長什麼樣子 最初的想法極其簡單：\n讓用戶拍一張菜單照片 傳送到 Gemini API 把結果顯示出來 第一個 prototype 大概花了兩個週末完成。代碼簡陋到我現在不敢看：一個 Activity，一個 ViewModel，一個直接在 ViewModel 裡面呼叫 Gemini API 的函式，回傳一個字串，直接顯示在 Text() 裡。\n沒有 loading state。沒有錯誤處理。沒有任何架構可言。\n但它可以用。\n我把第一張測試菜單（一張日式拉麵店的菜單圖片）丟進去，Gemini 在大約三秒後回傳了一份結構化的 JSON，裡面有每道菜的日文名稱、中文翻譯、價格——甚至包含了一些過敏原提示。\n那個瞬間讓我意識到：這件事是可以做到的。\n第一個真實的技術選型困難 很快地，「可以用」和「做成產品」之間的距離變得非常明顯。\n第一個大問題是：後端要放哪裡？\n我是 Android 工程師，幾乎沒有後端經驗。最初的版本是直接從 App 呼叫 Gemini API——這意味著 API Key 必須放在 App 裡。這是一個非常明顯的安全問題：只要有人反編譯 APK，就能拿到 API Key。\n我知道這個問題，但不知道怎麼解。\n第一個想法是 Firebase Cloud Functions。我以前聽過，但從來沒用過。研究了幾天之後，發現要用 Firebase Functions 需要 Node.js 背景，而且冷啟動時間對這種「即時翻譯」的場景來說可能太長。\n後來在某篇技術文章裡看到了 Cloudflare Workers——一個跑在全球邊緣節點上的無伺服器平台，幾乎沒有冷啟動，而且有相當慷慨的免費額度。\n我決定試試。\n學習後端的第一週 第一週寫後端的感受，可以用一個詞形容：完全失控。\nTypeScript 我會一點（以前碰過一些 Web 開發），但 Cloudflare Workers 的環境和我熟悉的 Node.js/Browser 環境都不一樣。它有自己的限制：不能用 fs 模組（沒有檔案系統）、setTimeout 行為不同、很多 npm 套件因為依賴 Node.js 原生模組而無法使用。\n第一個讓我卡很久的問題是：如何在 Cloudflare Workers 裡呼叫 Firebase Cloud Messaging 發送推播通知。\n正常做法是用 firebase-admin SDK。但 firebase-admin 依賴 Node.js 的 https 和 crypto 模組，在 Workers 環境不能用。\n解法最後是：用 Web Crypto API 手動簽發 JWT（JSON Web Token），然後用這個 JWT 換取 FCM 的 OAuth2 Token，再用這個 Token 呼叫 FCM HTTP v1 API。\n整個流程手動實作，用的是 jose（一個支援 Web Standards 的 JWT 函式庫）。這讓我第一次真正理解了 JWT 的結構——不是「它是一個 token」，而是「它是一個被簽名的 JSON，任何人都能驗證簽名是否有效」。\n第一個讓我真的有「這是產品」感覺的時刻 有一天，我把 App 裝在手機上，帶著它去一家我常去的日式餐廳。\n用 CameraX 實時掃描模式——App 打開相機，鏡頭對著菜單，系統自動偵測文字區域並框選，輕觸快門，三秒後螢幕上出現一份清楚的點餐介面：每道菜有中文名稱、日文原名、價格，以及一個寫著「含芝麻」的小標籤。\n我點了一道菜，把 App 遞給同行的朋友看，他說：「這比 Google Translate 好用多了。」\n這句話讓我決定繼續做下去。\n這個系列要記錄什麼 接下來幾篇，我會寫 MaiNeu 開發過程中真實遇到的困難：\nCompose 的深坑：協程取消機制如何讓 UI 卡死、LaunchedEffect 的 key 語義、FlowRow 的版面問題 後端從零開始：Cloudflare Workers 的限制、Gemini API 地區問題、D1 database 的 migration 管理 安全工程：從「不要 hardcode 密碼」到真正實作 HMAC 請求簽名 CI/CD 與三層環境：GitHub Actions 的設計、環境隔離為什麼重要 Auth 的血淚：JWT、Session 管理、OAuth Fusion 的邊界案例 AI 工作流設計：如何從「用 AI 寫代碼」進化到「設計 AI 的工作軌道」 Phase 2 的故事：一個功能從設計到「暫不做」的全過程 這些都是真實發生的事，有具體的 bug、具體的解法、具體的反思。\n不是教程，而是一份工程日誌。\n下一篇：Compose 深坑錄——我在 Jetpack Compose 踩過的那些坑\n","permalink":"https://blog.maineu.com/tech/01-why-maineu/","summary":"\u003ch1 id=\"為什麼做-maineu一個-android-工程師的-side-project-起點\"\u003e為什麼做 MaiNeu？一個 Android 工程師的 Side Project 起點\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第一篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"那頓飯的挫折\"\u003e那頓飯的挫折\u003c/h2\u003e\n\u003cp\u003e事情的起點是一頓普通的旅遊晚餐。\u003c/p\u003e\n\u003cp\u003e菜單是日文的。我打開 Google Translate 的相機功能，對著菜單掃，螢幕上出現一堆漂浮的中文字——「炭火燒烤特選和牛」、「季節野菜天婦羅」——但這些字是貼在原始日文上方的，版面混亂，根本沒辦法看清楚哪道菜是什麼價格，有沒有我不能吃的成分。\u003c/p\u003e","title":"為什麼做 MaiNeu？一個 Android 工程師的 Side Project 起點"},{"content":"Compose 深坑錄——我在 Jetpack Compose 踩過的那些坑 MaiNeu 開發旅程 第二篇\n「我以為我會了」 Jetpack Compose 我在做 MaiNeu 之前就用過。幾個小型的 side project，感覺還行——聲明式 UI 很直覺，remember 和 State 的概念也不難懂。\n然後 MaiNeu 讓我知道，「用過」和「真正理解」之間有多遠。\n這篇記錄我在 MaiNeu 裡真實踩過的 Compose 和 Coroutines 坑——每一個都是真實的 bug、真實的卡關、真實的「啊原來是這樣」。\n坑一：LaunchedEffect 的 key，我根本沒有真正理解 症狀 MaiNeu 有一個菜單解析流程：用戶拍完照片，App 開始解析，中間有 loading state，解析完成後顯示結果。有一段時間，這個流程出現了一個奇怪的 bug：\n用戶快速連拍兩張照片，第一張的解析結果會覆蓋第二張的 loading state，UI 最後顯示的是第一張的結果，但狀態欄顯示「解析中」。\n原因 我的代碼大概長這樣：\n@Composable fun ParsingScreen(photoPath: String, ...) { LaunchedEffect(Unit) { // 問題在這裡 viewModel.startParsing(photoPath) } } LaunchedEffect(Unit) 的意思是：這個 Effect 只在 Composable 第一次進入 composition 時執行一次，之後即使 photoPath 改變，它也不會重新執行。\n但我以為 Unit 是「總是執行」——這完全搞反了。\n正確理解 LaunchedEffect 的 key 語義是：\nkey 不變 → Effect 不重啟（舊的協程繼續跑） key 改變 → 取消舊協程 + 啟動新協程 Unit 永遠不變，所以 LaunchedEffect(Unit) 只會執行一次。\n正確寫法應該是：\nLaunchedEffect(photoPath) { // photoPath 改變時，取消舊解析、開始新解析 viewModel.startParsing(photoPath) } 這樣每次 photoPath 改變，LaunchedEffect 就會自動取消前一個正在進行的解析協程，並重新開始。不需要手動管理協程的 cancel——Compose 幫你做了。\n反思 這個坑讓我真正理解了「key 改變 = cancellation + 重啟」不是文件裡的一句話，而是一個必須用身體記住的行為規則。後來我把這條規則寫進了 MaiNeu 的 Invariants 文件（INV-COR-003），防止以後再犯。\n坑二：CancellationException 必須 rethrow 症狀 這是一個更隱藏、更危險的 bug。\nMaiNeu 的解析流程用 Kotlin Coroutines 寫，中間有多個 suspend 呼叫：上傳圖片、呼叫 API、解析 JSON。我用了 runCatching 包起來處理錯誤：\nviewModelScope.launch { val result = runCatching { apiClient.parseMenu(photoBytes) } result.fold( onSuccess = { data -\u0026gt; updateUiWithResult(data) }, onFailure = { error -\u0026gt; updateUiWithError(error) } ) } 問題：用戶離開頁面時，UI 會卡在 loading 狀態。\n原因 當用戶離開頁面，viewModelScope 被取消，parseMenu 的協程拋出 CancellationException。這個 exception 進入了 runCatching 的 catch 路徑，然後被 result.fold { onFailure = { error -\u0026gt; ... } } 當成普通錯誤處理。\nonFailure 裡，我把 error 顯示成一個錯誤訊息，然後把 isLoading 設回 false。\n但問題是：協程的取消機制依賴 CancellationException 的傳播。如果你在 onFailure 裡把它吃掉（當作普通錯誤處理），協程不知道自己被取消了，會繼續執行後續邏輯。\n正確寫法 result.fold( onSuccess = { data -\u0026gt; updateUiWithResult(data) }, onFailure = { error -\u0026gt; // CancellationException 必須重新拋出，讓協程正確取消 if (error is CancellationException) throw error updateUiWithError(error) } ) 或者更安全的模式：\nviewModelScope.launch { try { val data = apiClient.parseMenu(photoBytes) updateUiWithResult(data) } catch (e: CancellationException) { throw e // 永遠不要吞掉這個 } catch (e: Exception) { updateUiWithError(e) } } 反思 這個 bug 最危險的地方是：它不一定表現為 crash。它可能只是 UI 狀態不對、loading 消不掉、或者記憶體洩漏。協程的取消機制是整個 Kotlin Coroutines 設計裡最精妙、也最容易誤用的部分。CancellationException 必須 rethrow——這條規則後來成了 MaiNeu 最重要的 Invariant 之一（INV-COR-001）。\n坑三：@Volatile 不觸發 Recompose 症狀 我在 ViewModel 裡有一個 @Volatile 的 Boolean 欄位，用來控制某個 UI 元素的顯示。我在另一個地方更新了這個值，但 UI 沒有重新渲染。\n原因 Compose 的 recomposition 機制依賴 Compose 能觀察的狀態——也就是 State\u0026lt;T\u0026gt;、StateFlow 或 MutableState。@Volatile 只是一個 JVM 關鍵字，確保多執行緒的可見性，但 Compose 系統根本不知道這個值改變了。\n正確做法 // ❌ 不要這樣 @Volatile var isFeatureEnabled = false // ✅ 應該這樣 private val _isFeatureEnabled = MutableStateFlow(false) val isFeatureEnabled = _isFeatureEnabled.asStateFlow() 在 Composable 裡：\nval isFeatureEnabled by viewModel.isFeatureEnabled.collectAsStateWithLifecycle() 坑四：FlowRow + weight(1f) 的版面炸裂 症狀 MaiNeu 的菜單解析結果頁面，每道菜顯示為一個卡片。我想用 FlowRow 讓卡片自動換行，每張卡片設定 weight(1f) 讓它們等寬。結果：\n所有卡片都佔據整行寬度，完全沒有並排。\n原因 FlowRow 的換行決策是在 intrinsic size measurement 階段做的，這個階段它還不知道 weight 會怎麼分配空間。所以它看到每個 item 的「本來大小」，決定要不要換行，但 weight(1f) 的效果要到 layout 階段才生效——這時候換行已經決定好了。\n解法 // ❌ FlowRow + weight 組合不可靠 FlowRow { items.forEach { item -\u0026gt; MenuItemCard(modifier = Modifier.weight(1f)) } } // ✅ 手動分行，行為可靠 val chunks = items.chunked(2) Column { chunks.forEach { rowItems -\u0026gt; Row { rowItems.forEach { item -\u0026gt; MenuItemCard(modifier = Modifier.weight(1f)) } if (rowItems.size == 1) { Spacer(modifier = Modifier.weight(1f)) } } } } 坑五：AnimatedContent 裡的 remember 會重置 當 AnimatedContent 切換 content，原本的 Composable 會離開 composition，remember 的值也跟著消失。如果你希望 state 跨越 Screen 切換保留，需要把 state 提升到 ViewModel，或使用 rememberSaveable。\n坑六：painterResource 不支援 adaptive icon IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported mipmap-anydpi-v26 下的 adaptive icon XML 不是可繪製資源。解法：用 ic_launcher_foreground（具體的 PNG）加上 CircleShape clip。\n坑七：PaddingValues 的三個 overload 不能混用 // ❌ 這會編譯錯誤 ContentPadding(horizontal = 16.dp, top = 8.dp, bottom = 12.dp) // ✅ horizontal 對稱但 top/bottom 不同時，用 4 參數版本 PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 12.dp) 一個通用的教訓 這些 bug 在教程裡根本不會出現，因為教程的場景太簡單了。\n真實產品才是最好的老師——不是因為它更難，而是因為它有真實的邊界案例：用戶會快速操作、網路會斷線、螢幕會旋轉、用戶會同時有多個頁面開著。\n每一個 bug 都讓我對 Compose 和 Coroutines 的理解再深一層。現在這些規則不是我背的，而是我用身體記住的。\n下一篇：後端從零開始——一個 Android 工程師如何讀懂 Cloudflare Workers\n","permalink":"https://blog.maineu.com/tech/02-compose-deep-dives/","summary":"\u003ch1 id=\"compose-深坑錄我在-jetpack-compose-踩過的那些坑\"\u003eCompose 深坑錄——我在 Jetpack Compose 踩過的那些坑\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第二篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"我以為我會了\"\u003e「我以為我會了」\u003c/h2\u003e\n\u003cp\u003eJetpack Compose 我在做 MaiNeu 之前就用過。幾個小型的 side project，感覺還行——聲明式 UI 很直覺，\u003ccode\u003eremember\u003c/code\u003e 和 \u003ccode\u003eState\u003c/code\u003e 的概念也不難懂。\u003c/p\u003e","title":"Compose 深坑錄——我在 Jetpack Compose 踩過的那些坑"},{"content":"當 AI 說「沒關係，我來解釋」——用 AI 學會我以前不懂的東西 用 AI 把想法變成真的 — 第二篇\n我最怕的那句話 做 MaiNeu 的前幾個月，我最害怕的不是 bug，不是技術問題，而是某一種感覺——\n打開一個新的領域，看到一堆陌生的詞：「JWT」、「OAuth」、「WebSocket」、「HMAC」、「migration」……\n這些詞我聽過幾個，但幾乎不知道它們具體是什麼。更可怕的是，搜尋其中一個詞，解釋裡又出現更多我看不懂的詞，就像打開一扇門之後發現裡面還有十扇門。\n但做 MaiNeu 的時候，這些我「跳過」的部分都是我必須做的部分。沒有其他人。\n第一次把 AI 當「老師」用 MaiNeu 需要一個帳號系統——讓用戶登入、登出、記住偏好設定。我知道「登入」是什麼，但我完全不知道後端要怎麼「記住」用戶是誰。\n畢竟，網路是無狀態的——你打開一個網頁，伺服器接收你的請求，回傳資料，然後就結束了。下一次你打開同一個頁面，伺服器完全不知道你是誰。\n那「登入」是怎麼讓伺服器記住你的？\n我問 Claude 這個問題，它給了我一個類比：\n「想像你去一個主題樂園。入口驗票後，工作人員給你一個手環——不是用來記住你的名字，而是讓你在樂園裡可以自由進出各個遊樂設施，不用每次都回入口重新驗票。這個手環就是 JWT（JSON Web Token）。」\n這個類比讓我一下子懂了 JWT 的概念：它是一個「你已經通過驗證」的證明，你每次發送請求時把它帶上，伺服器看到它就知道「好，這個人是合法的用戶」。\n然後我問了下一個問題：「那這個手環（JWT）可以被偽造嗎？」\nClaude 解釋了簽名機制：JWT 裡有一段用秘密金鑰加密的「簽名」，只有知道這個金鑰的伺服器才能驗證簽名是否真實。就算有人知道手環長什麼樣，也沒辦法偽造一個讓伺服器相信的假手環。\n我花了大概一個小時問問題、讀解釋，真正理解了一個我原本完全不懂的概念。\n我學到的「問問題的藝術」 在使用 AI 一段時間之後，我發現有些問法比另一些問法有用得多。\n沒有用的問法：「幫我做 X」 比如說：「幫我做一個帳號系統。」\nAI 確實可以給你一堆代碼，但你不理解這些代碼的時候，兩件事會發生：第一，你沒辦法改它；第二，當出錯的時候，你完全不知道問題在哪。\n這就像叫人幫你解一道數學題，但你不看解題過程——下次碰到同樣類型的題，你還是不會。\n有用的問法：「解釋 X 是什麼，我為什麼需要它」 比如說：「我在做帳號系統，我看到 JWT 這個詞，可以用一個非技術的方式解釋它是什麼嗎？為什麼要用它？」\n這樣問，你得到的是理解，不只是代碼。\n更有用的問法：「我理解了 X，但我不確定 Y 的部分，你能幫我想想嗎？」 比如說：「我理解了 JWT 的概念，但我在想：如果用戶的 Token 過期了，App 應該自動幫他換一個新的，還是叫他重新登入？這個設計有什麼 trade-off？」\n這種問法，你是在和 AI 一起「想」這個問題，而不是讓 AI 「幫你做」。你從這個過程中得到的，是判斷能力，不只是答案。\n那些「突然懂了」的時刻 有一次我在處理安全問題。有人提醒我：「你的 App 和後端的通訊方式可以被攔截重複使用——攻擊者攔截了一個合法請求，可以一模一樣地重發，後端分不清真假。」\n我問 Claude：「這個問題的名字叫什麼？怎麼解決？」\n它解釋了「Replay Attack（重放攻擊）」，然後解釋了解法：每個請求都要包含一個「這個請求的有效時間窗口」（時間戳記）和一個「這個請求只能使用一次」的隨機字串。後端驗證這兩個東西，就能確認這不是一個被重複使用的請求。\nClaude 用了一個類比：「想像你在便利超商用一次性密碼取貨。密碼有效時間是 10 分鐘，而且只能用一次——即使有人看到了你的密碼，他在 10 分鐘後、或者密碼被用過之後，根本沒辦法再用。」\n這個類比讓我立刻明白了原理。我不只是「知道怎麼做」，我理解了「為什麼要這樣做」。\n「為什麼」比「怎麼做」重要得多。\n當你理解了「為什麼」，下次遇到類似問題，你有能力自己判斷。當你只知道「怎麼做」，一旦情況稍微不同，你就又回到「不知道」的狀態。\nAI 作為「跨領域翻譯機」 做 MaiNeu 讓我理解了一件事：很多技術概念，其實是可以用日常生活的語言解釋的。困難的是「你不知道不知道什麼」——你沒有任何參考點，所以不知道從哪裡開始。\nAI 工具讓這個「起點」的尋找成本降低了很多。你可以說：「我完全不懂 X，從最基本的地方開始解釋給我聽，假設我沒有任何背景知識。」\n然後，如果解釋裡有你不懂的詞，你可以繼續問：「你剛才說的 Y，能再解釋一下嗎？」\n就這樣一層一層往下問，直到你真正理解為止。\n這個過程，在 Google 搜尋的時代是非常困難的：你需要知道要搜尋什麼詞，搜尋到的結果可能太深（假設你有背景知識）或太淺（只說了概念，沒有說怎麼用）。\nAI 可以根據你的問題和你的程度，即時調整解釋的深度。\n我現在知道的事 做 MaiNeu 到現在，我改變了我對「學習」的看法。\n以前，我覺得「學習」是一個線性的過程：你先讀完 A，才能讀 B，才能讀 C。\n現在，我覺得「學習」是一個帶著問題走進去的過程：你有一個真實的問題，你試著解決它，在解決的路上你遇到了你不懂的東西，你去問、去學、你理解了，你繼續前進。\nAI 工具讓「帶著問題走進去」這件事的成本大幅降低了。你不再需要先花幾個月讀完所有基礎知識才能動手——你可以邊做邊問邊學。\n「我還不懂，但我可以開始學著做。」\n下一篇：Bug 出現了——從恐懼到把錯誤當成學習地圖\n","permalink":"https://blog.maineu.com/general/02-learning-with-ai/","summary":"\u003ch1 id=\"當-ai-說沒關係我來解釋用-ai-學會我以前不懂的東西\"\u003e當 AI 說「沒關係，我來解釋」——用 AI 學會我以前不懂的東西\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003e用 AI 把想法變成真的 — 第二篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"我最怕的那句話\"\u003e我最怕的那句話\u003c/h2\u003e\n\u003cp\u003e做 MaiNeu 的前幾個月，我最害怕的不是 bug，不是技術問題，而是某一種感覺——\u003c/p\u003e","title":"當 AI 說「沒關係，我來解釋」——用 AI 學會我以前不懂的東西"},{"content":"Bug 出現了——從恐懼到把錯誤當成學習地圖 用 AI 把想法變成真的 — 第三篇\n那個讓我呆坐在椅子上的下午 做 MaiNeu 大概四個月後，有一個下午，App 突然出現了一個我完全看不懂的問題。\n用戶（當時只有我自己）打開 App，登入，然後做任何需要網路的操作，都失敗。錯誤訊息是一串英文：DEVICE_NOT_FOUND。\n我不知道這是什麼意思。我查了代碼、查了後端的設定，找不到任何地方有「DEVICE_NOT_FOUND」相關的設定。我嘗試登出再登入，一樣失敗。我重新安裝 App，還是一樣。\n我呆坐在椅子上大概二十分鐘，心裡有個聲音說：「我是不是做了一件我根本搞不定的事？」\nBug 是開發過程的一部分——但這不讓它不恐怖 在正式說怎麼解決問題之前，我想先說一件重要的事：\nBug（程式錯誤）是開發的正常部分，不是你哪裡做錯了的懲罰。\n不管是剛入門的新手，還是有十年經驗的資深工程師，每天的工作都包含「遇到問題」和「解決問題」。差別不在於「會不會遇到 bug」，而在於「遇到 bug 的時候，你的工具箱有什麼、你的心態是什麼」。\nAI 改變了這個方程式。\n把 bug 說清楚，AI 才能幫你 回到那個 DEVICE_NOT_FOUND 的問題。\n坐了二十分鐘之後，我決定把問題告訴 Claude。但在說之前，我先整理了我知道的資訊：\n問題什麼時候開始的（今天下午，在我做了一個特定的修改之後） 問題的症狀是什麼（登入成功，但所有 API 請求都回傳 DEVICE_NOT_FOUND） 我已經試過什麼（重新登入、重新安裝 App） 我沒有試過什麼（刪除後端資料庫的資料、檢查 Token 的內容） 把這些整理清楚，然後告訴 Claude：「我的 App 出現了這個問題，症狀是 X，我試過 Y，你覺得可能的原因是什麼？」\nClaude 的第一個問題是：「你說你今天做了一個修改——你修改了什麼？」\n我說：「我修改了登入流程，新增了一個匿名用戶的登入方式。」\nClaude 說：「我猜測問題可能在這裡——你新的登入請求有沒有帶上 deviceId（設備識別碼）？」\n我去查了代碼，然後——\n是的。我新增的那個登入方式，忘記帶上 deviceId。後端產生的 Token 裡沒有設備資訊，所以後續所有 API 請求都因為「找不到設備」而失敗。\n整個 debug 過程花了大概十五分鐘。\n如果沒有 AI，我可能要花好幾個小時，逐行檢查代碼，試圖找到可能的問題。\nAI debug 的真正價值：縮短「完全不知道從哪裡開始」的時間 我想說清楚 AI 在 debug 過程中真正有用的地方。\n它不是魔法。你告訴它問題，它不會直接說「你第 73 行的代碼有個括號缺了一個字元」。\n它真正有用的地方是：幫你生成假設。\n當你遇到一個完全陌生的問題，最困難的部分不是「修復」，而是「找到可能的方向」。你需要有幾個猜測，然後一個一個去驗證。\nAI 在這方面非常有用——它見過大量類似的問題，可以快速給你幾個「可能是 X 原因、可能是 Y 原因、可能是 Z 原因」的方向。\n你的工作，是帶著這些假設，去驗證哪個是真正的問題。\n有一種 bug，比 bug 本身更可怕 「我昨天改了 A，但今天 B 壞了，這兩件事怎麼可能有關係？」\n有一次，我修改了 App 的「偏好設定同步」功能（讓你換了新手機之後，設定可以自動還原）。修完之後，App 看起來正常。但過了幾天，用戶的 App 語言設定一直被重置——每次打開 App，App 的界面語言都回到了預設值。\n我完全沒想到這兩件事有關係。\n告訴 Claude 之後，它問：「你的偏好設定同步，是把雲端的設定覆蓋到本地，還是合併？」\n我說：「應該是覆蓋吧——把雲端的資料下載回來，替換本地的設定。」\nClaude 說：「問題可能在這裡。你的雲端資料只存了部分設定（比如翻譯語言偏好），但不包含 App 界面語言——App 界面語言只存在本機。如果你把雲端資料整個覆蓋本地，那些沒有存在雲端的設定就會被刪掉，回到預設值。」\n這個解釋讓我瞬間明白了問題所在，而且明白了為什麼這兩件看起來完全不相關的事有關係。\n把每一個 bug 變成「你多懂了一件事」 做 MaiNeu 以前，我對 bug 的態度是：找到問題，修掉，繼續。\n現在，我對每個 bug 的處理方式多了一個步驟：理解為什麼這個問題會發生。\n不只是「第 73 行有一個字錯了」，而是「這個錯誤的根本原因是 X，要完全避免它，應該在設計上做 Y。」\n這個習慣讓我的進步速度快了很多。因為問題的根本原因往往不是「這段代碼有 bug」，而是「我對某個系統的理解是錯的」。修掉了那段代碼，不等於修掉了錯誤的理解——如果不去弄清楚，下次還會犯同樣類型的錯。\nAI 在這方面很有用：當你問「為什麼這個問題會發生」而不只是「怎麼修」，你得到的答案會讓你理解更多，而不只是解決一個問題。\n給非技術讀者的類比 如果你不是工程師，可能很難理解「程式 bug」是一種什麼樣的感受。\n想像你在搭積木，你要搭一座高塔。搭了一大半之後，你發現底部的某一塊積木歪掉了，整座塔開始傾斜。\nBug 就是那塊歪掉的積木。有時候它很明顯（塔已經倒了），有時候它很隱藏（塔看起來好好的，但某個角度你才看到它在傾斜）。\nAI 就像一個幫你看塔的朋友——你描述塔的狀態，它幫你猜「可能是哪一塊歪了」，讓你不用每一塊都試。\n但把那塊積木扶正，還是需要你自己做。\n那個下午的後記 DEVICE_NOT_FOUND 的問題修好之後，我在筆記裡寫下了這個教訓：「新增任何登入方式，必須確保請求帶有 deviceId。」\n這條筆記後來存進了 MaiNeu 的錯誤記錄檔（我叫它 ERRORS.md），裡面收集了所有我踩過的坑，讓我以後不會重複犯同樣的錯。\n目前這個記錄檔有 81 條記錄。\n每一條都是一個下午的挫折、一段對話、一個「啊，原來是這樣」的時刻。\n這 81 條，是我做 MaiNeu 這一年裡，比任何教程都更真實的學習記錄。\n下一篇：我是產品經理、設計師、工程師、測試員——一個人做產品的現實\n","permalink":"https://blog.maineu.com/general/03-dealing-with-bugs/","summary":"\u003ch1 id=\"bug-出現了從恐懼到把錯誤當成學習地圖\"\u003eBug 出現了——從恐懼到把錯誤當成學習地圖\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003e用 AI 把想法變成真的 — 第三篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"那個讓我呆坐在椅子上的下午\"\u003e那個讓我呆坐在椅子上的下午\u003c/h2\u003e\n\u003cp\u003e做 MaiNeu 大概四個月後，有一個下午，App 突然出現了一個我完全看不懂的問題。\u003c/p\u003e","title":"Bug 出現了——從恐懼到把錯誤當成學習地圖"},{"content":"後端從零開始——一個 Android 工程師如何讀懂 Cloudflare Workers MaiNeu 開發旅程 第三篇\n後端，那個「別人負責的東西」 在做 MaiNeu 之前，我對後端的理解大概是：\n「有個 server 在某個地方，它會接收請求，然後回傳 JSON。」\n這個理解對於一個純前端工程師來說已經夠了——你只需要知道怎麼呼叫 API，不需要知道 API 的背後發生了什麼。\n但 MaiNeu 只有我一個人，沒有「後端工程師」這個角色。API 要自己寫，資料庫要自己設計，部署要自己管。\n為什麼選 Cloudflare Workers 選項 評估 Firebase Cloud Functions 熟悉，但冷啟動慢，設定繁瑣 AWS Lambda 業界標準，但學習曲線陡，免費額度少 Cloudflare Workers 幾乎沒有冷啟動，全球邊緣節點，免費每天 10 萬次請求 MaiNeu 的核心使用場景是「拍照 → 立即翻譯」，延遲對用戶體驗影響很大。選 Cloudflare Workers 之後，這個決定帶來了幾個意外的坑。\nGemini API 地區限制：Smart Placement 的陷阱 某些用戶的菜單解析請求一直失敗，從錯誤日誌來看是 Gemini API 回傳 User location is not supported for the API use。\n但我的 API Key 是美國帳號申請的，為什麼有地區問題？\n根本原因 Cloudflare Smart Placement 會把你的 Worker 部署到「對 upstream 延遲最低」的地區。用戶在亞洲時，Cloudflare 可能選擇香港（HKG）或新加坡（SIN）節點——而 Gemini API 在這些地區有限制。\n解法 [placement] mode = \u0026#34;smart\u0026#34; hint = \u0026#34;wnam\u0026#34; # 強制使用北美西區 關鍵陷阱：頂層的 [placement] 不會被 [env.*] 繼承。每個環境都要獨立宣告：\n[env.staging.placement] mode = \u0026#34;smart\u0026#34; hint = \u0026#34;wnam\u0026#34; [env.production.placement] mode = \u0026#34;smart\u0026#34; hint = \u0026#34;wnam\u0026#34; 我以為頂層設定就夠了，結果 production 部署後又炸了一次。這個 bug 花了好幾個小時才找到。\nD1 資料庫：Migration 失敗後別重試 Apply Cloudflare D1 是底層 SQLite 的資料庫服務。wrangler d1 migrations apply 執行 migration，但如果中途失敗（例如欄位名稱重複），Wrangler 不會自動回滾。\n問題：migration 被標記為「已執行」，但資料庫是半套用的狀態。再次 apply 報「migration 已套用」，但資料庫其實不完整。\n解法：\n# ❌ 不要重試 apply wrangler d1 migrations apply DB_NAME # ✅ 用 execute 手動補跑剩餘的 SQL wrangler d1 execute DB_NAME --command \u0026#34;ALTER TABLE users ADD COLUMN new_field TEXT\u0026#34; 這條規則後來寫進了 MaiNeu 開發規範：D1 migration 失敗時，改用 execute --command 手動修復，永遠不要重試 apply。\nGemini API 的 KeyPool 策略 Gemini API 有 Rate Limit，免費 tier 每天有解析次數上限。解法是維護多個 API Key，Round-robin 輪流使用：\nclass KeyPool { private keys: string[]; private cooldowns: Map\u0026lt;string, number\u0026gt; = new Map(); private currentIndex = 0; getNextKey(): string | null { const now = Date.now(); for (let i = 0; i \u0026lt; this.keys.length; i++) { const idx = (this.currentIndex + i) % this.keys.length; const key = this.keys[idx]; const cooldownUntil = this.cooldowns.get(key) ?? 0; if (now \u0026gt; cooldownUntil) { this.currentIndex = (idx + 1) % this.keys.length; return key; } } return null; // 所有 key 都在冷卻中 } markCooling(key: string, retryAfterMs: number) { this.cooldowns.set(key, Date.now() + retryAfterMs); } } 當一個 Key 被 rate limit（HTTP 429），把它放入指數退避冷卻，自動切換到下一個 Key。API 容量翻了三倍，代碼複雜度幾乎沒增加。\nJWT 不是一個魔法 token base64(header) . base64(payload) . base64url(signature) JWT 是一個被簽名的 JSON。任何人都能 decode payload，但只有知道 secret 的人才能驗證 signature 是否有效。\n這意味著 JWT 裡的 payload 不能放敏感資訊。MaiNeu 用雙 Token 系統：Access Token（30 分鐘效期）+ Refresh Token（7 天效期）。\nTiming-safe 比較 // ❌ 普通字串比較：易受 Timing Attack if (requestSecret === expectedSecret) { ... } // ✅ Cloudflare Workers 提供 timingSafeEqual const encoder = new TextEncoder(); const a = encoder.encode(requestSecret); const b = encoder.encode(expectedSecret); if (a.length !== b.length) return false; const result = await crypto.subtle.timingSafeEqual(a, b); 攻擊者可以透過測量「比較時間」猜測 secret 內容。Timing-safe 比較確保不論字元是否相同，比較時間都一樣。\n第三方 API 整合：永遠打真實 endpoint Frankfurter 匯率 API 的文件描述 v1 格式，但我呼叫的是 v2，response 格式完全不同。我花了六天才發現——因為 feature flag 讓 UI 入口隱形，根本沒有真實流量打到這個路徑。\n規矩：任何第三方 API 整合，動工前必須用 curl 打至少一個真實 endpoint，把 raw response 存起來。不要相信只看過文件沒打過的 endpoint。\n後端讓我的 Android 開發思維變了 做後端之後，看 API 的角度完全不同了：\n這個 endpoint 的 rate limit 是什麼？超了，Android 端怎麼處理？ 後端回傳 null 欄位的情況什麼時候發生？ 後端 migration 改了欄位名稱，Android 的 @SerialName 需不需要更新？ 後端不再是黑箱，而是另一個我能看懂的系統。\n下一篇：安全工程啟蒙——從「不要 hardcode 密碼」到 5 層防禦架構\n","permalink":"https://blog.maineu.com/tech/03-backend-from-scratch/","summary":"\u003ch1 id=\"後端從零開始一個-android-工程師如何讀懂-cloudflare-workers\"\u003e後端從零開始——一個 Android 工程師如何讀懂 Cloudflare Workers\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第三篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"後端那個別人負責的東西\"\u003e後端，那個「別人負責的東西」\u003c/h2\u003e\n\u003cp\u003e在做 MaiNeu 之前，我對後端的理解大概是：\u003c/p\u003e\n\u003cp\u003e「有個 server 在某個地方，它會接收請求，然後回傳 JSON。」\u003c/p\u003e\n\u003cp\u003e這個理解對於一個純前端工程師來說已經夠了——你只需要知道怎麼呼叫 API，不需要知道 API 的背後發生了什麼。\u003c/p\u003e","title":"後端從零開始——一個 Android 工程師如何讀懂 Cloudflare Workers"},{"content":"安全工程啟蒙——從「不要 hardcode 密碼」到 5 層防禦架構 MaiNeu 開發旅程 第四篇\n我對安全的原始認知 做 MaiNeu 之前，我對安全工程的認知大概是：\n不要 hardcode API Key 用 HTTPS 密碼要 hash 存儲 ……然後就沒了 做了 MaiNeu 之後，我跑了一次 OWASP 2025 安全審計，發現了 15 個問題。這個過程讓我對安全設計的理解從「幾條規則」變成了「系統性的防禦思維」。\nApp Secret + HMAC：防 Replay Attack MaiNeu 最初的 API 設計是在每個請求的 header 裡帶一個 X-App-Secret——一個只有 App 和後端知道的字串。感覺足夠了。\n然後有人問我：如果有人攔截了一個合法請求，然後完整複製這個請求重新發送（Replay Attack），你怎麼辦？\n我沒有好答案。\nReplay Attack 是什麼 攻擊者攔截一個合法的 HTTP 請求（header + body），把這個請求一模一樣地重新發送給後端。對後端來說，這個請求看起來完全合法——secret 是對的，body 是合理的 API 請求。\nHMAC 請求簽名 解法是把請求的內容、時間戳記、和一次性隨機字串一起 hash，產生唯一的簽名：\nSignature = HMAC-SHA256( secret, timestamp + nonce + method + path + bodyHash ) 三個元素缺一不可：\n元素 防禦目標 timestamp 後端只接受 ±5 分鐘內的請求，過期直接拒絕 nonce 一次性字串，存在 Cloudflare KV，同一個 nonce 不能使用兩次 bodyHash SHA-256 of body，確保請求內容沒有被竄改 理解 HMAC 設計的邏輯之後，安全設計從「遵守規則」變成了「理解威脅模型」。\nemail_verified 的 Google/Apple 型別陷阱 症狀 Google 回傳：\n{ \u0026#34;email\u0026#34;: \u0026#34;user@gmail.com\u0026#34;, \u0026#34;email_verified\u0026#34;: true } Apple 回傳：\n{ \u0026#34;email\u0026#34;: \u0026#34;user@privaterelay.appleid.com\u0026#34;, \u0026#34;email_verified\u0026#34;: \u0026#34;true\u0026#34; } Google 的是 boolean，Apple 的是字串 \u0026quot;true\u0026quot;。\n如果你用 if (emailVerified === true)（嚴格比較），Apple 的就會被當成未驗證，拒絕登入。\n為什麼 email_verified 很重要 沒有驗證的 email，不能用來確認用戶身份。如果攻擊者能用任意 email 創建 OAuth 帳號，然後嘗試和現有帳號合併，可能造成帳號劫持。後端規則：email_verified 為 false 的 OAuth 登入，不允許和現有帳號合併。\n修法 function parseEmailVerified(value: boolean | string): boolean { if (typeof value === \u0026#39;boolean\u0026#39;) return value; if (typeof value === \u0026#39;string\u0026#39;) return value === \u0026#39;true\u0026#39;; return false; // 未知格式，保守處理 } Auth Retry Storm：Mutex + Cooldown 症狀 App 啟動時，幾秒內大量認證請求打到後端——因為 4 個 Manager 同時初始化，同時發現 Token 無效，同時呼叫 authenticate()。\n解法 class AuthManager { private val authMutex = Mutex() private var cooldownUntil: Long = 0 suspend fun getValidToken(): String { // 冷卻期內直接拒絕，不打 API if (System.currentTimeMillis() \u0026lt; cooldownUntil) { throw MenuApiException.RateLimited(retryAfterMs = 5000) } // Mutex 確保同一時間只有一個 authenticate() 在跑 return authMutex.withLock { val token = tokenStorage.getAccessToken() if (token != null \u0026amp;\u0026amp; !token.isExpired()) { token.value // 已有有效 token，直接回傳 } else { authenticate() } } } } 第一個搶到 Mutex 的去認證，其他三個等待。認證完成後，剩下三個進去發現 Token 已有效，直接回傳。authenticate() 最多只跑一次。\nSocketTimeoutException 不等於「無網路」 SocketTimeoutException 的含義是：連接建立了，但伺服器在超時時間內沒有回應。可能是伺服器慢、後端處理時間長、或網路擁塞——不是「沒有網路」。\n把它包含在「無網路」判斷裡，會誤判「有網路但後端慢」的情況，顯示不正確的離線提示。\n// ✅ 正確：SocketTimeoutException 屬於「伺服器錯誤」，不是「無網路」 fun isNetworkError(e: Throwable): Boolean = e is UnknownHostException || e is ConnectException || e.message?.contains(\u0026#34;Unable to resolve host\u0026#34;) == true // SocketTimeoutException 不在這裡 OWASP 審計：讓安全問題變得可見 系統性地把 OWASP Top 10 逐項對照自己的代碼，我發現了 15 個問題：\n問題 嚴重性 狀態 API Key hardcoded in local config files Critical ✅ 已修 缺少 HMAC Request Signing High ✅ 已修 日誌輸出包含 Token 子字串 High ✅ 已修 缺少 Play Integrity 裝置驗證 Medium ✅ 已修 OAuth email_verified 未處理 Medium ✅ 已修 Auth 請求缺少 Rate Limit Medium ✅ 已修 重要的不是「修了幾個」，而是做了審計之後，安全問題變得可見了。\n安全設計的核心思維 安全設計不是「遵守規則清單」，而是「理解攻擊者的視角」。\n每一條安全規則背後都有一個具體的威脅：\n規則 防禦的威脅 HMAC 簽名 Replay Attack email_verified 檢查 帳號劫持 Timing-safe 比較 Timing Attack Nonce 防重放 Token 複製 當你理解了威脅，你就能在遇到新場景時做出正確的判斷，而不是死背規則。\n下一篇：一個人維護四個環境——GitHub Actions CI/CD 實戰\n","permalink":"https://blog.maineu.com/tech/04-security-engineering/","summary":"\u003ch1 id=\"安全工程啟蒙從不要-hardcode-密碼到-5-層防禦架構\"\u003e安全工程啟蒙——從「不要 hardcode 密碼」到 5 層防禦架構\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第四篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"我對安全的原始認知\"\u003e我對安全的原始認知\u003c/h2\u003e\n\u003cp\u003e做 MaiNeu 之前，我對安全工程的認知大概是：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e不要 hardcode API Key\u003c/li\u003e\n\u003cli\u003e用 HTTPS\u003c/li\u003e\n\u003cli\u003e密碼要 hash 存儲\u003c/li\u003e\n\u003cli\u003e……然後就沒了\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e做了 MaiNeu 之後，我跑了一次 OWASP 2025 安全審計，發現了 15 個問題。這個過程讓我對安全設計的理解從「幾條規則」變成了「系統性的防禦思維」。\u003c/p\u003e","title":"安全工程啟蒙——從「不要 hardcode 密碼」到 5 層防禦架構"},{"content":"我是產品經理、設計師、工程師、測試員——一個人做產品的現實 用 AI 把想法變成真的 — 第四篇\n有一天我意識到，我在扮演很多角色 做 MaiNeu 幾個月之後，有一天我在思考一個問題：「我應該先做分享功能，還是先做過敏原警示？」\n我在考慮這個問題的時候，突然意識到：我正在做一個「產品經理」在做的事。\n然後我想了一下，從 MaiNeu 開始到現在，我扮演過的角色：\n產品經理：決定做什麼、不做什麼、先做哪個 設計師：決定 App 長什麼樣子、用戶操作流程是什麼 前端工程師（Android）：實作 App 後端工程師：實作伺服器 安全工程師：確保資料和用戶不被攻擊 測試員：找自己的 bug 行銷：想怎麼讓人知道這個 App 在一般的科技公司，這些角色是不同的人——而且每個角色都是全職工作。\n我一個人，同時扮演全部。\nAI 在「產品決策」這件事上的角色 「我應該先做什麼功能？」這個問題，是產品開發裡最難的問題之一。\n技術問題通常有客觀的對錯——代碼要麼能跑要麼不能跑。但產品決策是主觀的：「哪個功能對用戶更重要」取決於你的用戶是誰、他們的使用場景是什麼。\n我用 AI 幫我做產品決策的方式，不是問「你覺得我應該做哪個」，而是問「幫我分析這兩個功能的 trade-off」。\n比如，我在猶豫要不要做「分享連結」功能，還是先完善「過敏原警示」。\n我告訴 Claude 兩個功能的描述，然後問：「如果你是一個第一次使用這個 App 的旅行者，你覺得哪個功能對你的當下需求更重要？」\nClaude 扮演了那個旅行者的角色，說：「分享連結對我更立即有用——和我同行的三個朋友沒有這個 App，但我們都想點餐。如果我能把掃好的菜單傳給他們看，這個 App 馬上就解決了一個具體的問題。」\n這個角度讓我做出了決定：先做分享連結。\n但我要強調：AI 只是給了我一個視角，最終決定還是我做的。 AI 不知道我的用戶真實想什麼，它的角色扮演是基於推測。真正的答案只有讓真實用戶用了之後才知道。\n我是自己的測試員——也是最糟糕的測試員 產品開發裡有一個著名的問題：你是最了解自己產品的人，所以你也是最差的測試員。\n你了解 App 的操作流程，所以你不會按到「不應該按的按鈕」。你知道哪些功能還沒做完，所以你不會去碰那些地方。\n真實用戶不知道這些。他們會按任何按鈕，輸入任何東西，在你完全沒有預料到的情境下使用你的 App。\n我在做 MaiNeu 的時候，碰到了一個讓我很難堪的情況。\n我做了一個「翻譯語言偏好」的設定——讓用戶可以選擇想要把菜單翻譯成哪個語言。我測試了中文、英文、日文，都沒問題。\n然後有一次，一個朋友用了我的 App，他選了「泰文」，App 直接當機。\n我事後才發現：我的代碼只處理了我測試過的幾個語言，對「泰文」完全沒有處理——碰到它就崩潰了。\n我自己永遠不會測試到這個問題，因為我不選泰文。\nAI 工具幫我想到了一些我沒有想到的邊界案例：「你的語言偏好設定，用戶如果選了你沒有測試過的語言會發生什麼？」\n但 AI 幫我想到的邊界案例，還是沒有辦法替代真實用戶的使用。你的 App 必須讓真實的人用，然後聽他們說出什麼壞了。\n設計這件事：「夠用」和「好看」之間 我不是設計師。我的美感大概是普通水平——我能分辨「這個很醜」，但我不一定知道「怎麼讓它變好看」。\n有一次，我做了一個訂閱頁面（讓用戶選擇要不要付費升級），自己看了覺得「好像還行」。但我問 Claude：「如果你是一個剛到這個頁面的用戶，你第一眼看到什麼？你知道你應該做什麼嗎？」\nClaude 的回答讓我重新思考：「我第一眼看到三個方案，但我不知道哪個是『推薦』的，也不知道選了之後會發生什麼。我找不到一個清楚的行動指引。」\n這個反饋讓我加了一個「最多人選擇」的標籤在推薦方案上，加了一個「選完會馬上扣款，可以隨時取消」的說明。\nAI 幫我站在用戶的角度看我的設計。 這不是說 AI 懂設計——它不一定比你懂。但它能扮演一個「什麼都不知道的新用戶」，讓你看到你自己視而不見的問題。\n一個人做產品，最消耗你的不是技術 做 MaiNeu 快一年，我最大的體會是：\n一個人做產品，最消耗你的不是「不知道怎麼做」，而是「不知道下一步應該做什麼」。\n技術問題有答案。你不知道怎麼做，你可以問，可以查，答案存在某個地方。花時間找到它，問題就解決了。\n但「下一步做什麼」沒有客觀答案。你的時間和精力是有限的，每件你「選擇做」的事背後，都有你「選擇不做」的事。\n真正讓我在做決策的時候有方向的，是我在做 MaiNeu 之前問自己的一個問題：\n「如果這個 App 只有一個功能，這一個功能必須是什麼，才會讓人說『這個 App 值得裝』？」\n對 MaiNeu 來說，答案是：掃描菜單、翻譯、用清楚的格式呈現。\n這個核心功能，是我在做每一個決策時的錨——「這件事有沒有讓這個核心功能變得更好或更穩？」如果沒有，暫時不做。\nAI 時代的「一個人團隊」 在 AI 工具的幫助下，一個有強烈動機的人，可以做到比以前多得多的事。\n但有些事，AI 替代不了：\n你對自己想法的清晰度——你必須能把你想做的事說清楚 你的判斷力——最終的決定你要自己做 你面對失敗的心態——事情出錯的時候，你要繼續 這些不是「技術」，是人的能力。\n下一篇：在 AI 時代，你的「想法」才是最貴的東西（完結篇）\n","permalink":"https://blog.maineu.com/general/04-one-person-team/","summary":"\u003ch1 id=\"我是產品經理設計師工程師測試員一個人做產品的現實\"\u003e我是產品經理、設計師、工程師、測試員——一個人做產品的現實\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003e用 AI 把想法變成真的 — 第四篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"有一天我意識到我在扮演很多角色\"\u003e有一天我意識到，我在扮演很多角色\u003c/h2\u003e\n\u003cp\u003e做 MaiNeu 幾個月之後，有一天我在思考一個問題：「我應該先做分享功能，還是先做過敏原警示？」\u003c/p\u003e\n\u003cp\u003e我在考慮這個問題的時候，突然意識到：\u003cstrong\u003e我正在做一個「產品經理」在做的事。\u003c/strong\u003e\u003c/p\u003e","title":"我是產品經理、設計師、工程師、測試員——一個人做產品的現實"},{"content":"一個人維護四個環境——GitHub Actions CI/CD 實戰 MaiNeu 開發旅程 第五篇\n從「知道 CI 是什麼」到「設計三層環境晉升體系」 在做 MaiNeu 之前，我對 CI/CD 的認知是：「有個系統在每次 push 之後自動跑測試，公司有人在管。」\nMaiNeu 只有我一個人，沒有 DevOps 工程師，我必須自己設計、配置、維護。\n為什麼需要多個環境 第一次「改壞 production」讓我理解了這件事。我在修一個 API 的 response 格式，本地測試沒問題，推上去之後某個邊界案例在線上出現，破壞了現有用戶的資料呈現。花了一個小時 rollback，又花了兩個小時修正再部署。\n那一個小時，App 是壞的。\n三層環境架構 開發（本地 / Dev flavor） ↓ push → 自動部署 UT（Unit Test 環境，api-ut.maineu.com） ↓ 手動核准 + 確認測試通過 Staging（api-staging.maineu.com） ↓ 手動核准 + 完整驗收 Production（api.maineu.com） 環境 用途 UT 開發沙盒，資料可以隨意重設 Staging 盡量接近 production，用來做最終驗收 Production 真實用戶，只有充分測試後才能推進 最重要的原則：每個環境獨立的資料庫 如果 UT 和 Production 共用同一個 D1 database，在 UT 環境做的測試（寫入、刪除、改動 schema）就會直接影響 Production 的資料。更糟糕的是，資料只是靜靜地被污染，沒有任何報錯。\n每個環境獨立 database 有代價：需要在每個環境分別執行 migration。但這個代價遠小於「production 資料被污染」的風險。\nGitHub Actions 工作流設計 工作流 觸發條件 作用 backend-deploy.yml push 到 dev/staging/main 自動部署後端到對應環境 backend-promote.yml 手動 dispatch 把特定 commit 晉升到下一環境 backend-ci.yml Workers 路徑有變更的 PR 跑測試 + TypeScript 型別檢查 + ESLint claude-pr-review.yml PR 開啟或同步 Claude AI 自動代碼審查 backend-promote：跨環境晉升必須指定 commit SHA 如果你只是「把 staging 分支更新到最新」，你可能晉升了一個在 UT 還沒完整測試的版本。\n解法：backend-promote.yml 要求指定 commit SHA：\non: workflow_dispatch: inputs: commit_sha: description: \u0026#39;Exact commit SHA to promote (get from UT deployment log)\u0026#39; required: true target_environment: type: choice options: [staging, production] 晉升時必須指定「我要晉升的是哪個 commit」，強制你確認 UT 日誌。\n踩過的 CI/CD 坑 坑一：PR 沒有指定 --base，被合進了錯的分支 # ❌ 讓 GitHub 猜預設 base gh pr create --title \u0026#34;feat: menu parsing\u0026#34; # ✅ 明確指定 base 分支 gh pr create --title \u0026#34;feat: menu parsing\u0026#34; --base main 有一次沒有指定 --base main，GitHub 把 PR 的 base 設成了另一個 feature branch。PR merge 之後，代碼進了別的 feature branch 而不是 main。這個 mistake 花了一個下午修正。\n坑二：commit 誤落 master 沒有確認當前分支，在 master 上直接 commit——這個 mistake 發生了不只一次。\n解法：每次 commit 前跑 git branch --show-current。這成了 MaiNeu 的 Invariant（INV-GIT-001）：每次 commit 前必須確認當前分支，PR merge 操作後尤其要確認。\n坑三：feature branch 從另一個 feature branch 開出 # ❌ 在 feature/a 分支上 git checkout -b feature/b # 從 feature/a 開出！ # ✅ 永遠從 master 開新 branch git checkout master \u0026amp;\u0026amp; git pull git checkout -b feature/xxx PR diff 包含了 feature/a 的所有改動，review 非常混亂。這成了 INV-GIT-005。\nClaude PR Review：AI 進入 CI/CD 流水線 每次有 PR 開啟或更新，claude-pr-review.yml 自動觸發，呼叫 Claude Code Action，把審查結果貼在 PR comment 裡：\n## Code Review Report ### Critical Issues (必須修復) ... ### Warnings (建議修復) ... 有幾次，這個自動審查發現了我自己 review 時沒有注意到的問題。但也有 false positive：AI 有時會把「symlink 存在但 ls 沒顯示」誤報為「檔案不存在」。\n教訓：reviewer 的 Block 級問題，接受前必須人工驗證。自動審查是輔助，不是替代人類判斷。\nDevOps 對我的意義 做 CI/CD 的最大收穫，不是「學了幾個 YAML 設定」，而是建立了工程嚴謹性的習慣：\n每個改動都有 PR，都有審查記錄 每個環境的狀態都是可追溯的（哪個 commit 在哪個環境） 每次到 production 的晉升都有人工確認 一個人維護四個環境，聽起來很累。但有了 CI/CD 框架，大部分工作是自動的——我只需要做決策。\n下一篇：Auth 的那些坑——JWT、Session 管理、OAuth Fusion 的血淚教訓\n","permalink":"https://blog.maineu.com/tech/05-cicd-github-actions/","summary":"\u003ch1 id=\"一個人維護四個環境github-actions-cicd-實戰\"\u003e一個人維護四個環境——GitHub Actions CI/CD 實戰\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第五篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"從知道-ci-是什麼到設計三層環境晉升體系\"\u003e從「知道 CI 是什麼」到「設計三層環境晉升體系」\u003c/h2\u003e\n\u003cp\u003e在做 MaiNeu 之前，我對 CI/CD 的認知是：「有個系統在每次 push 之後自動跑測試，公司有人在管。」\u003c/p\u003e","title":"一個人維護四個環境——GitHub Actions CI/CD 實戰"},{"content":"在 AI 時代，你的「想法」才是最貴的東西 用 AI 把想法變成真的 — 第五篇（完結篇）\n在說最後一件事之前，先說一個失敗 MaiNeu 到現在還沒有上線。\n這是我必須誠實說的事。我做了快一年，花了很多時間、學了很多東西，但 App 還沒有真正到達一般用戶的手機裡。\n還有很多事沒有完成：Google Play 的上架審查、Apple 帳號的相關設定、付費系統的最後測試……這些不是技術上的難題，更多是「需要時間做完所有步驟」的流程問題。\n我說這件事，是因為這個系列的主題是「真實的故事」。\n「我做了一年，App 快要完成了」——這是真的。 「App 已經上線，有很多用戶」——這目前還不是真的。\n但我想說的重點，不是「做完了沒有」。\n關於「做完」這件事 有個問題我被問過幾次：「你做了這麼久，為什麼還沒有上線？」\n每次被問到這個，我都需要停下來思考一下才能回答。因為這個問題背後有一個假設：上線是終點。\n但做 MaiNeu 的過程讓我理解了：做產品沒有「做完」，只有「現在這個版本夠不夠好，可不可以給用戶用」。\nApp 可以一直改進、一直新增功能、一直修 bug。「上線」只是一個里程碑，不是終點。\n更重要的是，很多在「做完之前」讓我猶豫的事——比如「帳號系統是不是要再完整一點」、「設計要不要再好看一點」——其實只有讓真實用戶用了才能知道答案。\n這是做產品最反直覺的事之一：你花越多時間在上線之前把產品「做完美」，你得到真實反饋的時間就越晚。\n我知道了這件事，但要真正接受它，還是很難。\nAI 改變了什麼——以及沒有改變什麼 做完這個系列，我想用一個很直接的方式說清楚 AI 工具改變了什麼，以及沒有改變什麼。\n改變了的 學習的門檻。\n以前，「我不懂 X」意味著我需要先找到一本書、花幾個月讀完基礎知識、才能開始嘗試。現在，「我不懂 X」意味著我可以帶著真實的問題，一邊做一邊學。\n一個人能做的事的範圍。\n以前，一個人能做的事受限於「你懂哪些領域」。現在，受限於「你願意花多少時間學習和嘗試」。這是一個本質上的改變。\n卡住的時候的選擇。\n以前，當你完全不知道問題在哪裡的時候，選項是：繼續猜、找一個懂的朋友問、或者放棄。現在多了一個選項：告訴 AI 你的問題，讓它幫你產生一些假設。\n沒有改變的 你需要有一個想法，然後把它說清楚。\nAI 不會給你想法。它可以幫你分析、幫你實作、幫你想問題——但「你想解決什麼問題、為什麼這個問題值得解決」，是你必須自己知道的。\n這聽起來很簡單，但很多人在這一步就卡住了。不是因為沒有想法，而是沒有辦法把想法說清楚。\n你需要判斷力。\nAI 給的答案不一定對。它可能給你過時的資訊，可能在不了解你的情況下給出不適合你的建議，可能在你問「這個設計好嗎」的時候說「不錯」——因為它不知道你的用戶是誰。\n帶著批判性思考使用 AI，才能真正受益於它。\n你需要面對失敗的心態。\n事情會出錯。功能不如預期，用戶不買單，花了一個星期做完的東西被你自己否定掉……這些不會因為有 AI 而消失。\n能繼續做下去的不是技術，是心態。\n如果你有一個想法，現在是最好的時機 我做 MaiNeu 之前，有一個對自己不誠實的地方：\n我告訴自己「等我更懂後端、等我有更多時間、等我想清楚了」，然後就沒有然後了。\n這個「等」，是大多數想法沒有變成真的的主要原因。不是「不可能做到」，而是「還沒開始做」。\n有了 AI 工具之後，「我還不夠懂」這個理由的含金量大幅降低了。你不需要等到懂了才開始——你可以在開始的過程中慢慢懂。\n現在是最好的時機。不是因為 AI 幫你做所有事，而是因為「從不懂到開始做」的成本從來沒有這麼低過。\n一個你可以今天就做的事 如果這個系列讓你對「用 AI 把想法變成真的」有一點點興趣，我有一個建議：\n今天，把你腦子裡的一個想法，用三段話寫下來：\n你想解決的問題是什麼？（「我每次去日本餐廳都不知道怎麼點餐」） 誰有這個問題？（「像我一樣在亞洲旅行但不懂當地語言的人」） 你想怎麼解決它？（「一個 App，拍照就能翻譯菜單，用清楚的格式呈現」） 寫完之後，把這三段話貼給 Claude（或你用的任何 AI 工具），問它：「根據這個想法，如果我想開始驗證這個問題真的存在，我應該先做什麼？」\n然後看它怎麼回答。\n你不需要馬上知道怎麼寫程式、怎麼做設計、怎麼找用戶。你只需要有一個想法，然後開始問問題。\n剩下的，我們邊做邊學。\nMaiNeu 之後 這個系列寫的是做 MaiNeu 的故事，但 MaiNeu 對我來說代表的是一個更大的東西：\n「我有一個想法，我把它做出來了。」\n不管最後有多少用戶、有沒有賺錢、App 最終是不是成功，「我把一個當初只存在腦子裡的東西，變成了一個真實存在的 App」——這件事本身有它自己的價值。\n在 AI 工具出現之前，這件事對我來說需要一個團隊，需要更多資源，需要我在更多不懂的地方長時間卡關。\nAI 工具不是讓我成為了超人。但它讓我在做一件以前「一個人做不到」的事的時候，有了夠多的支撐，讓我能繼續往前走。\n這對我來說，就夠了。\n寫給下一個「一個人有一個想法」的你 不管你是上班族、學生、主婦、退休老師、還是任何其他身份——\n如果你有一個「讓某件事變得更好」的想法：\n你不需要等。 你不需要先懂所有技術。 你不需要一個完美的計劃。\n你需要的，是把你的想法說清楚，然後開始問問題。\nAI 工具會幫你找方向。剩下的路，你一步一步走。\n在 AI 時代，你的想法，才是最貴的東西。\n感謝你讀完這個系列。如果你有任何問題，或者想分享你的想法，歡迎留言。\n系列回顧 篇 主題 第一篇 我只是想做一個 App，但我一個人 第二篇 當 AI 說「沒關係，我來解釋」 第三篇 Bug 出現了——從恐懼到把錯誤當成學習地圖 第四篇 我是產品經理、設計師、工程師、測試員 第五篇 在 AI 時代，你的「想法」才是最貴的東西（你在這裡） ","permalink":"https://blog.maineu.com/general/05-ideas-in-ai-age/","summary":"\u003ch1 id=\"在-ai-時代你的想法才是最貴的東西\"\u003e在 AI 時代，你的「想法」才是最貴的東西\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003e用 AI 把想法變成真的 — 第五篇（完結篇）\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"在說最後一件事之前先說一個失敗\"\u003e在說最後一件事之前，先說一個失敗\u003c/h2\u003e\n\u003cp\u003eMaiNeu 到現在還沒有上線。\u003c/p\u003e\n\u003cp\u003e這是我必須誠實說的事。我做了快一年，花了很多時間、學了很多東西，但 App 還沒有真正到達一般用戶的手機裡。\u003c/p\u003e","title":"在 AI 時代，你的「想法」才是最貴的東西"},{"content":"Auth 的那些坑——JWT、Session 管理、OAuth Fusion 的血淚教訓 MaiNeu 開發旅程 第六篇\nAuthentication：看起來簡單，實際上是地雷區 Authentication 是每個 App 都必須做的功能，理論上也是最成熟的——OAuth 2.0、JWT、Session 管理，業界有一大堆標準做法。\n然而，MaiNeu 的 Auth 系統前後迭代了至少六個月，踩了無數坑。\n不是因為原理不懂，而是因為邊界案例無窮無盡：用戶的設備重開機、Token 在 App 執行中間過期、同一個 Email 用不同方式登入、網路在 Auth 流程中途斷線……\n坑一：AuthManager Singleton，restoreSession 忘了重置 state 症狀 用戶登出之後，重新打開 App，登入頁面會閃一下然後直接跳到主頁面——即使用戶沒有輸入任何帳號密碼。\n根本原因 AuthManager 是 Koin singleton，有一個 StateFlow _authState 記錄認證狀態。\n用戶登出時，我清除了 Token Storage（刪掉 EncryptedSharedPreferences 裡的 token），但忘了重置 _authState。\n結果：下次打開 App，restoreSession() 去 Token Storage 找 token，沒找到，但 _authState.value.isLoggedIn 還是 true（上次登入時設的）。LaunchActivity 看到 isLoggedIn=true，直接跳到主頁面。\n正確做法 suspend fun restoreSession() { val token = tokenStorage.getAccessToken() if (token == null || token.isExpired()) { // 必須重置 state！不能只靠 Token Storage 為空 _authState.update { AuthState.empty() } return } // ... } 教訓：singleton 的 in-memory state 和 Storage 必須同步。任何 logout 或 session 清除操作，必須同時清除兩個地方。\n坑二：navigateBasedOnAuthStatus 判斷邏輯順序錯誤 症狀 某些 returning user（已登入的用戶重新打開 App）會被送到登入頁面，然後卡在那裡。\n根本原因 // ❌ 錯誤的判斷順序 fun navigateBasedOnAuthStatus() { if (!hasRegisteredAccount()) { navigate(LoginActivity) // 只檢查「是否有過帳號」 return } navigate(MainActivity) // 有帳號 → 主頁面，但 token 可能過期！ } hasRegisteredAccount() 只檢查「這個設備曾經有過帳號」，不檢查「token 是否有效」。Returning user 的 token 可能過期（超過 7 天沒打開 App）。\n// ✅ 先檢查 token 是否有效 fun navigateBasedOnAuthStatus() { val token = tokenStorage.getAccessToken() if (token != null \u0026amp;\u0026amp; !token.isExpired()) { navigate(MainActivity) // Token 有效，進主頁面 return } if (hasRegisteredAccount()) { navigate(LoginActivity, rememberedEmail = true) // 有帳號但 token 過期 return } navigate(OnboardingActivity) // 新用戶 } 坑三：所有 Auth 請求必須帶 deviceId 症狀 用戶成功登入，但之後所有 API 請求都回傳 DEVICE_NOT_FOUND 錯誤。\n根本原因 MaiNeu 的 Token 和 deviceId 綁定。我新增一個 Auth 端點時，忘記在 AuthRequest 裡加上 deviceId。後端產生的 Token 裡 deviceId 是空字串，之後所有 API 請求都失敗。\n// ❌ 漏了 deviceId data class AnonymousAuthRequest(val appVersion: String, val platform: String) // ✅ 必須帶 deviceId data class AnonymousAuthRequest( val deviceId: String, val appVersion: String, val platform: String ) 教訓：所有 Auth 請求（Login、Register、Anonymous、OAuth）都必須帶 deviceId——這是 Invariant，不是可選的欄位。\n坑四：clearFusion() 漏了清除 error state 症狀 用戶嘗試 Google OAuth 融合帳號，點取消後繼續其他操作，突然出現舊的 fusion 失敗錯誤訊息，出現在完全不相關的頁面上。\n根本原因 // ❌ 漏了清除 error fun clearFusion() { _authState.update { state -\u0026gt; state.copy(fusionToken = null) } } // ✅ 同時清除 error fun clearFusion() { _authState.update { state -\u0026gt; state.copy(fusionToken = null, error = null) } } 教訓：清除流程狀態時，必須清除所有相關的 sub-state，包括 error。\n坑五：link 帳號失敗是「預期行為」，不是錯誤 症狀 登入流程短暫顯示一個錯誤訊息然後馬上消失，讓用戶困惑。\n根本原因 linkEmailAccount 失敗（因為 Email 已存在）是 fallthrough 邏輯，是「預期的失敗路徑」。但我設了 error 狀態，UI 短暫顯示錯誤訊息，然後登入成功清除了它。\n// ❌ link 失敗時設了 error（但這是預期行為） onFailure { error -\u0026gt; _authState.update { it.copy(isLoading = false, error = error.message) } } // ✅ link 失敗是預期行為，不設 error onFailure { error -\u0026gt; _authState.update { it.copy(isLoading = false) } // 繼續 fallthrough 到登入流程 } 教訓：「失敗」不等於「錯誤」。區分「預期的失敗路徑」（user 不需要知道）和「真正的錯誤」（需要顯示）。\n坑六：applyCloudData 覆蓋本地偏好設定 症狀 用戶的 App 語言在每次啟動後被重置。\n根本原因 applyCloudData() 把整個 UserPreferences 物件用雲端版本覆蓋，包括了 appLanguage（App 界面語言）這個從未同步到後端的設定。\n// ❌ 整個覆蓋，包括只存本機的設定 fun applyCloudData(cloudPrefs: UserPreferences) { _prefs = cloudPrefs } // ✅ 只 merge 雲端同步的欄位 fun applyCloudData(cloudPrefs: UserPreferences) { _prefs = _prefs.copy( targetLanguage = cloudPrefs.targetLanguage, resultLayout = cloudPrefs.resultLayout // 其他欄位保留本地值 ) } 教訓：設計「雲端同步」功能時，必須明確定義哪些欄位是雲端同步的，哪些是本機獨立的——這個邊界要在 data model 層定義清楚。\nAuth 系統設計的核心洞察 Auth 不只是「登入/登出」，而是「管理信任關係的整個生命週期」。\n六個月的 Auth 開發讓我學到：好的 Auth 系統的共同特點是每個決定都有明確的理由——不是「這樣寫感覺對」，而是「這樣設計是因為我們預期這個場景的用戶行為是 X，所以用 Y 策略處理」。\n下一篇：設計 AI 工作流——14 個 Agent 組成的虛擬開發團隊\n","permalink":"https://blog.maineu.com/tech/06-auth-jwt-oauth/","summary":"\u003ch1 id=\"auth-的那些坑jwtsession-管理oauth-fusion-的血淚教訓\"\u003eAuth 的那些坑——JWT、Session 管理、OAuth Fusion 的血淚教訓\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第六篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"authentication看起來簡單實際上是地雷區\"\u003eAuthentication：看起來簡單，實際上是地雷區\u003c/h2\u003e\n\u003cp\u003eAuthentication 是每個 App 都必須做的功能，理論上也是最成熟的——OAuth 2.0、JWT、Session 管理，業界有一大堆標準做法。\u003c/p\u003e","title":"Auth 的那些坑——JWT、Session 管理、OAuth Fusion 的血淚教訓"},{"content":"設計 AI 工作流——14 個 Agent 組成的虛擬開發團隊 MaiNeu 開發旅程 第七篇\n從「幫我寫代碼」到「設計工作軌道」 2026 年初開始做 MaiNeu 的時候，我使用 AI 的方式很初級：\n「幫我寫一個 ViewModel，需要管理這些狀態。」\n然後 Claude 給我一段代碼，我貼上去，繼續下一個任務。AI 是一個非常快的代碼補全工具。\n五個月後，MaiNeu 的 AI 工作流長這樣：\n.claude/ ├── agents/ # 14 個角色分明的 AI agent ├── skills/ # 28 個可複用的工作流 skill ├── hooks/ # 5 個自動化守衛 ├── protocols/ # 4 個標準化流程 └── rules/ # 5 個強制約束 第一個問題：跨 session 的一致性 MaiNeu 的代碼庫在三個月後變得相當大。有一天，我開始一個新的 session，試圖告訴 Claude 當前的狀態：「上週我做了 X，現在要做 Y，記得 Z 這個限制。」\n然後我意識到：Claude 沒有「記憶」——每個 session 都是全新的。\n如果 AI 每次都要從零開始理解上下文，跨 session 的長期工作就非常低效。 更糟糕的是，這次 session 的 AI 和上次的 AI 可能做出互相衝突的架構決策。\n解法一：ExecPlan 把跨 session 的工作寫成一份結構化的計劃文件，存進 repo，讓每個 session 的 AI 都從這份計劃出發：\n## Goal [這個任務要達成什麼] ## Context [為什麼要做這個，背景是什麼] ## Constraints [不能做什麼，有什麼限制] ## Step-by-step 1. [步驟一] 2. [步驟二] ## Verification [如何確認完成，測試什麼] ## Progress Log [已完成的步驟] ## Decision Log [過程中做了什麼決定，為什麼] ExecPlan 有一個 10 階段的 lifecycle：\nPROPOSED → PLANNED → APPROVED → IN_PROGRESS → VERIFYING → REVIEWING → DONE → BLOCKED / REJECTED 不同 session 的 AI 都在同一份計劃的軌道上跑，不會各自做各自的事。\n第二個問題：「一個 AI 做所有事」 有了 ExecPlan，跨 session 一致性解決了。但另一個問題出現了：\n一個 session 的 AI 需要先寫 code，然後做 review，然後更新文件——每個角色需要不同的視角。結果是 AI 在自己 review 自己的代碼，這不比沒有 review 好多少。\n解法二：角色分明的 Agent 體系 把不同的角色拆成不同的 Agent，每個 Agent 有自己的視角：\nAgent 視角 不做什麼 pm 用戶需求、商業邏輯 不寫代碼 architect 系統設計、模組邊界 不關心 UI 細節 code-reviewer 規範符合性、潛在 bug 不關心業務需求 security-reviewer 攻擊面、安全漏洞 不關心 UX qa-engineer 邊界案例、可測試性 不實作功能 當一個功能完成後：\ncode-reviewer 審查是否符合規範 security-reviewer 審查是否有安全問題 qa-engineer 審查是否有遺漏的測試案例 三個 Agent 獨立審查，各自從自己的角度發現問題。\n解法三：Hook——防止 AI 犯不可逆的錯誤 AI 可能犯非常基本的錯誤——在 master 分支上直接 commit、刪除生產環境資料庫。它沒有「後果意識」。\npre-tool-use-guard.py 在 AI 執行任何 Bash 命令之前攔截：\nDANGEROUS_PATTERNS = [ (r\u0026#39;git push.*(--force|-f)\u0026#39;, \u0026#34;Force push is blocked\u0026#34;), (r\u0026#39;git commit.*\u0026#39;, lambda cmd: check_current_branch()), (r\u0026#39;cat.*\\.env\u0026#39;, \u0026#34;Reading .env files is blocked\u0026#34;), (r\u0026#39;curl.*\\|.*sh\u0026#39;, \u0026#34;Remote execution is blocked\u0026#34;), ] 有趣的是：第一版的 guard 在 security review 之後發現了 10 個以上的 bypass 路徑：\n--force 被擋了，但 +master（git push origin +master）沒有 cat .env 被擋了，但 source .env 沒有 修完之後，deny pattern 從 10 條膨脹到約 60 條。\n安全設計原則：安全規則的有效性等於「最寬鬆的 bypass 路徑」。有 hook 但有 bypass 的 hook，比沒有 hook 更危險——因為它讓你以為安全了。\n解法四：Skill——把工作流封裝成可複用命令 MaiNeu 有 28 個 Skill，最有代表性的是 /feature-pipeline：\n/feature-pipeline \u0026lt;feature名稱\u0026gt; 觸發順序： 1. pm agent 分析需求 2. architect agent 設計系統 3. 開發（實作代碼） 4. code-reviewer agent 審查 5. security-reviewer agent 安全審查 6. 更新文件 一個命令，觸發整條功能開發流水線。每個環節有明確的輸入和輸出，交接用 [HANDOFF] 標記。\n解法五：Invariants——把規則變成機械化驗證 不是：「記得 CancellationException 要 rethrow」（靠記憶，會忘）\n而是：「INV-COR-001：所有 catch (e: Exception) 區塊前必須有 catch (e: CancellationException) { throw e }。違反此規則的 PR 不得 merge。」\nInvariant 可以被 code-reviewer agent 機械化檢查。不需要靠「有經驗的工程師記得這條規則」，而是靠「自動化工具每次都會檢查」。\nMaiNeu 有 13 類、77+ 條 Invariant，覆蓋從協程安全到 Git 操作的各個面向。\n設計 AI 工作流的本質認知 AI 最擅長的是「執行定義好的流程」。人最擅長的是「定義流程和判斷邊界案例」。\n早期模式：我描述問題 → AI 想辦法解決 → 我評估結果\nHarness Engineering 模式：我定義流程（ExecPlan + Invariants + Skills）→ AI 在流程上執行 → 流程本身保證品質\n兩者的差異不只是效率，而是可靠性。當 AI 在定義好的軌道上跑，它的輸出是可預期的、可審查的、可追溯的。\n一個反直覺的發現 Hook 的第一版永遠是不夠的。\n這不是因為設計不認真，而是「守衛」和「攻擊」本質上是對抗性的——你只能堵住你能想到的 bypass，但實際上的 bypass 路徑比你能想到的多。\n解法不是「設計出完美的第一版」，而是接受「第一版會有漏洞，要有機制讓漏洞被發現並修補」。\n下一篇：Phase 2 的故事——從 WebSocket 到「暫不做」的產品決策\n","permalink":"https://blog.maineu.com/tech/07-ai-workflow-harness/","summary":"\u003ch1 id=\"設計-ai-工作流14-個-agent-組成的虛擬開發團隊\"\u003e設計 AI 工作流——14 個 Agent 組成的虛擬開發團隊\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第七篇\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"從幫我寫代碼到設計工作軌道\"\u003e從「幫我寫代碼」到「設計工作軌道」\u003c/h2\u003e\n\u003cp\u003e2026 年初開始做 MaiNeu 的時候，我使用 AI 的方式很初級：\u003c/p\u003e\n\u003cp\u003e「幫我寫一個 ViewModel，需要管理這些狀態。」\u003c/p\u003e","title":"設計 AI 工作流——14 個 Agent 組成的虛擬開發團隊"},{"content":"Phase 2 的故事——從 WebSocket 到「暫不做」的產品決策 MaiNeu 開發旅程 第八篇（完結篇）\nPhase 2 的起點 MaiNeu Phase 1 的核心：一個人，掃描菜單，看到翻譯結果。\n但我一直在想一個場景：一群朋友在餐廳，其中一個人掃了菜單，其他人怎麼一起看、一起點餐？\n現實中，大家輪流把手機傳來傳去。Phase 2 的設計目標：讓一個人掃描的菜單，能同時給同桌的所有人看，並且一起點餐。\nPhase 2-A：分享連結 設計 用戶掃描菜單 → 點「分享」 → 後端產生 share_code（6位字元，如 X7K2M9） → 生成分享連結 https://maineu.com/m/X7K2M9 → 用戶用 LINE / WhatsApp 傳給朋友 → 朋友打開連結，在瀏覽器看到菜單 連結過期時間：72 小時 安全角度：永久連結有風險，餐廳的菜單和價格會改變。 用戶角度：朋友可能隔天才點開，連結還能用嗎？\n決定：72 小時後過期。這個數字基於「用餐場景的時間框架」——用餐完之後 72 小時，這個菜單連結已經沒有意義了。\nFeature Flag 策略 Phase 2-A 實作完成後：先開 Flag 在 UT 環境測試，Staging 和 Production 的 Flag 暫時關閉。\n好處：代碼已在 production 環境跑，後端 API 可以先完整測試，之後要開放只需要 toggle flag。\n副作用：被 flag 隱藏的功能，很容易忘記測試。第三方 API 的 response 格式在 flag 關閉期間改變了，我六天後才發現（詳見第三篇）。\nPhase 2-B：共享點餐——雄心壯志的設計 後端：Durable Objects + WebSocket Cloudflare Workers 是無狀態的，但 WebSocket 需要「持久連接」。Cloudflare 的 Durable Objects 解決了這個矛盾：\n用戶 A 連接 WebSocket → OrderBroker (order_id=abc) 用戶 B 連接 WebSocket → OrderBroker (order_id=abc) 用戶 A 點了一道菜 → PUT /orders/abc/items/123 後端更新資料庫 → 發訊息給 OrderBroker OrderBroker 廣播給所有 WebSocket 客戶端 用戶 B 的 App 即時更新 後端實作完成：OrderBroker Durable Object、WebSocket endpoint /orders/:id/stream、kill switch SHARED_ORDER_WS_ENABLED（萬一撐不住，即時降級到 polling）。\nAndroid 端：可替換的 Repository 抽象 為了未來的 WebSocket 整合，特別設計了一個抽象：\n// 介面讓 P5（WebSocket）可以替換進來，不需要改動 UI 層 interface OrderRepository { fun observeSummary(): Flow\u0026lt;OrderSummary\u0026gt; } // P4 實作：用 Polling（每 5 秒查詢一次） class PollingOrderRepository : OrderRepository { override fun observeSummary(): Flow\u0026lt;OrderSummary\u0026gt; = flow { while (true) { val summary = apiClient.getOrderSummary(orderId) emit(summary) delay(5000) } } } // P5 計劃實作：換成 WebSocket，UI 不用改 class WebSocketOrderRepository : OrderRepository { override fun observeSummary(): Flow\u0026lt;OrderSummary\u0026gt; = channelFlow { // WebSocket 連接... } } UI 只依賴 OrderRepository 介面，未來把 PollingOrderRepository 換成 WebSocketOrderRepository，UI 零改動。\n「暫不做」的決定 Phase 2-B 架構設計做完之後，我問自己：\n「現在做這個值得嗎？」\n現實情況：\nMaiNeu 尚未正式上線，沒有真實用戶資料 Phase 1 的核心功能（菜單掃描、翻譯）還沒有在真實場景中大量驗證 用戶研究是空的——我從來沒有問過真實用戶「你希望和朋友分享菜單的方式是什麼」 這個功能完全基於我的直覺和假設。\n決定：P4（共享點餐 UI）+ P5（WebSocket）暫不做。 等 Phase 1 正式上線、有了真實用戶之後，再根據實際使用資料決定優先級。\n這個決定讓我省下了至少一個月的開發時間，把資源集中在「確保 Phase 1 夠好、能上線」。\n有意識的技術債 那個「暫不做」的 WebSocket 代碼，現在仍然在 production 後端跑著（kill switch 關閉）。OrderBroker 部署了，但沒有任何 Android 客戶端連接它。\n這是一個「有意識的技術債」——我知道這個代碼在那裡，知道它的成本，知道什麼時候要把它打開。\n技術債的問題不是「有沒有」，而是「有沒有意識到、有沒有管理」。\n把它寫在 ADR 裡：\n# ADR — Phase 2-B WebSocket 暫緩啟用 決定：WebSocket 後端已實作並部署（kill switch 關閉）。 Android P4/P5 暫不開發，等 Phase 1 上線後重新評估。 理由：沒有真實用戶資料佐證這個功能的需求強度。 重啟條件：Phase 1 上線後，如果有 \u0026gt;10% 的用戶從分享連結開啟、 且有用戶明確表達「想和同桌一起點餐」，則提升 Phase 2-B 優先級。 這個系列的反思 Phase 2 的故事讓我理解了：\n產品開發不只是「把功能做完」，而是「把對的功能在對的時候做完」。\n「做完」很容易。WebSocket 後端、Durable Objects、Android 端的 Repository 抽象——這些我都做了。\n「對的時候」更難。在沒有用戶、沒有資料的情況下，「這個功能用戶需要嗎」的答案只能是猜測。\n最健康的做法，是把「做了但暫時不開放」和「完全沒做」分開管理——前者是有意識的技術投資，後者是有意識的資源節約。都比「模糊地半做半不做」要好。\n系列完結。MaiNeu 還在開發中，還沒上線。接下來的故事——上線、第一批真實用戶的回饋、Phase 2-B 最終是否做——等它們發生之後再寫。\n","permalink":"https://blog.maineu.com/tech/08-phase2-story/","summary":"\u003ch1 id=\"phase-2-的故事從-websocket-到暫不做的產品決策\"\u003ePhase 2 的故事——從 WebSocket 到「暫不做」的產品決策\u003c/h1\u003e\n\u003cp\u003e\u003cem\u003eMaiNeu 開發旅程 第八篇（完結篇）\u003c/em\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"phase-2-的起點\"\u003ePhase 2 的起點\u003c/h2\u003e\n\u003cp\u003eMaiNeu Phase 1 的核心：一個人，掃描菜單，看到翻譯結果。\u003c/p\u003e\n\u003cp\u003e但我一直在想一個場景：\u003cstrong\u003e一群朋友在餐廳，其中一個人掃了菜單，其他人怎麼一起看、一起點餐？\u003c/strong\u003e\u003c/p\u003e","title":"Phase 2 的故事——從 WebSocket 到「暫不做」的產品決策"}]