iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

Part 3|第 7/30 篇
今日要做的事: 開好 Firebase 專案+Google 登入,讓「Verifier 已 ACCEPT 的餐點」能掛到固定 uid
今天要解決的目的: Part 2 契約通過後才持久化;每一筆從第一天就知道是誰的。

Day 5 收工那天我跟自己說:契約定了,再繼續在 AI Studio 裡玩辨識只會原地打轉。
可是一想到「資料要活過隔天」,第一個問題居然不是 Firestore——是:這是誰的?
AI Studio 很會看懂一張照片,卻不負責記住你昨天留下的東西。上雲端不是為了酷,是為了不要每天重貼同一份 Context。


今日任務卡

項目 內容
產出 Firebase 專案 dishflow、Google Auth、persistVerifiedMeal() 寫入閘門
工具 Firebase Console、Authentication、Firestore(測試模式)、本機 Vite demo
不使用 Gemini Runtime、Storage、正式敏感資料、同時開兩種登入
完成條件 未登入不能寫;非 ACCEPT 不能寫;路徑含目前 uid

本機程式:material/code/day6(金鑰只進 .env,已用 .gitignore 擋)。


1. 問題:格式驗完,還不知道資料屬於誰

Day 5 把進庫條件講清楚了:結構救完、語意 ACCEPT,才准寫。
可是那整個流程還活在 Playground——關掉分頁,時間序列就沒了。像做完一桌菜,沒人記得是誰點的。

真正持久化前要同時過兩道門:

https://ithelp.ithome.com.tw/upload/images/20260921/20121052H7KTawThFN.jpg

少了資料門,會保存「長得很合法的猜測」;
少了身份門,後面無法證明 pantry 與 meals 屬於同一個人。

今天只處理登入與 uid。跨帳號隔離是明天 Day 7 的 Rules——先別搶跑。


2. 設計:Auth 在前,持久化在最後

責任順序固定:

https://ithelp.ithome.com.tw/upload/images/20260921/20121052Hauu40Fzt4.jpg

我給自己寫的三條鐵律:

  1. Auth 不改 Part 2 契約——CONFIRM 還是不能寫,別想偷渡。
  2. 畫面上選誰不是授權——真正身份只能來自 Auth token,不是表單裡你自己填的 uid。
  3. v1 只開一種登入——今天選定 Google(步驟少,鐵人賽友善)。

資料一律掛在使用者子樹:

https://ithelp.ithome.com.tw/upload/images/20260921/201210520hGRN9NhOf.jpg

Rules 還沒證明隔離之前,只寫假資料fixture_meal_01)。真實過敏、真實照片——先別塞。


3. 實作:把專案開起來,再鎖寫入閘門

3.1 建立 Firebase 專案

建立流程裡 Console 會問兩件事:

  • Gemini in Firebase:Console 內建助手。我開了——不是把餐點 Vision 接到 Firebase,別搞混。
  • Google Analytics:今天先關掉,少一個設定面。

https://ithelp.ithome.com.tw/upload/images/20260921/201210525zJq7vvKI2.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052dUZHxaLSOf.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052Spki1QR0ZN.png

進總覽選 Web,暱稱 dishflow-web(Hosting 先不勾):

https://ithelp.ithome.com.tw/upload/images/20260921/20121052hLOA1XnYcW.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052rEtYHGu69I.png

接著會跳出完整 firebaseConfig。那頁我不截進文章——沒必要在鐵人賽全文廣播一輪 key。做法是丟進本機 .env,用 Vite 的 VITE_FIREBASE_* 注入(見第 3.5 節)。

3.2 只啟用 Google 登入

Authentication → 登入方式 → Google → 選支援 email → 啟用:

https://ithelp.ithome.com.tw/upload/images/20260921/20121052psT9VEUOKD.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052BlcRc3G3bC.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052yrz1HfZzdC.png

3.3 Firestore 先開測試模式(假資料用)

版本選 Standard。地區選單當下有點難搞,好像沒辦法選擇,不然我應該會選最近的亞洲區。不過選得到能用的就先建,畢竟 Rules 比地區重要
設定選 測試模式:警告寫得很清楚,30 天內誰拿到路徑都能讀寫。所以今天只放假 label。

https://ithelp.ithome.com.tw/upload/images/20260921/20121052ZjpUpPz6pF.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052PhLYTrw4wr.png

3.4 寫入閘門:兩行就擋掉大部分自找的麻煩

前端只認 auth.currentUser.uid,不接受表單或網址自稱:

async function persistVerifiedMeal(result, date) {
  const user = auth.currentUser;
  if (!user) throw new Error("AUTH_REQUIRED");
  if (result.decision !== "ACCEPT") throw new Error("MEAL_NOT_VERIFIED");

  return writeMeal({
    path: `users/${user.uid}/daily/${date}`,
    meal: result.verified_meal,
    verifier_decision: result.decision,
    model_raw_ref: result.model_raw_ref
  });
}

就這兩行判斷:沒登入 → 丟;不是 ACCEPT → 丟。完整版在 src/persistVerifiedMeal.js(還會再檢查 path 裡的 uid 必須等於目前登入者)。

本機跑起來後:Google 登入(帳戶列已遮)→ 截短 uid → CONFIRM/REJECT 被擋 → ACCEPT 假資料才寫得進去:

https://ithelp.ithome.com.tw/upload/images/20260921/20121052rh0sADAf2A.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052pYqRxh82Z9.png

https://ithelp.ithome.com.tw/upload/images/20260921/20121052CZkuC4eE6p.png

登出時記得清掉 pending 草稿——不然會把上一位的餐點套到下一個 uid。那種 bug 很安靜,也很髒。

3.5 給想跟著做的人:資料夾、設定、怎麼跑

https://ithelp.ithome.com.tw/upload/images/20260921/20121052lj16CGtMAc.jpg

material/code/day6/
  .env                 ← 你的金鑰(勿上傳)
  .env.example         ← 公開範本
  .gitignore
  index.html           ← 按鈕頁
  package.json
  src/
    firebase.js              ← 初始化 Auth / Firestore
    persistVerifiedMeal.js   ← ★ 寫入閘門
    main.js                  ← 登入+按鈕事件

設定 .env(從 Console 的 Web 設定複製):

VITE_FIREBASE_API_KEY=你的_apiKey
VITE_FIREBASE_AUTH_DOMAIN=你的專案.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=你的專案id
VITE_FIREBASE_STORAGE_BUCKET=你的專案.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=數字
VITE_FIREBASE_APP_ID=1:數字:web:字串

也可以先複製範本再填:

cd material/code/day6
copy .env.example .env

啟動:

cd material/code/day6
npm install
npm run dev

瀏覽器打開 Vite 給的網址(通常是 http://localhost:5173),依序按:

  1. 未登入 →「寫入 ACCEPT」→ 應出現 AUTH_REQUIRED
  2. 「Google 登入」→ 畫面上出現截短 uid
  3. 「CONFIRM/REJECT」→ MEAL_NOT_VERIFIED
  4. 「ACCEPT 假資料」→ Firestore 出現 fixture_meal_01

程式碼簡單說明(三支檔就夠)

firebase.js — 從 .env 讀設定,初始化 authdb

import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID
};

export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);

persistVerifiedMeal.js — 核心閘門:

export async function persistVerifiedMeal(result, date) {
  const user = auth.currentUser;
  if (!user) throw new Error("AUTH_REQUIRED");
  if (result.decision !== "ACCEPT") throw new Error("MEAL_NOT_VERIFIED");
  // writeMeal:路徑必須是 users/{目前uid}/daily/{date}
  return writeMeal({ /* ... */ });
}

main.js — 這頁是測試台,不是產品 UI。

index.htmlmain.js 只做一件事:讓我用按鈕假裝 Verifier 給出 ACCEPT/CONFIRM/REJECT,看閘門會不會擋、假資料會不會寫進 Firestore。沒有拍照、沒有真正的 Day 5 Verifier——餐點內容是寫死的 fixture_meal_01

這頁在測什麼:

按鈕 在測什麼
Google 登入/登出 Auth 有沒有拿到 uid(畫面上只顯示截短)
寫入 ACCEPT 假資料 登入後該寫成功
嘗試寫入 CONFIRM/REJECT 該被擋 → MEAL_NOT_VERIFIED
未登入強制測 先登出再寫 → AUTH_REQUIRED
// material/code/day6/src/main.js
import {
  onAuthStateChanged,
  signInWithPopup,
  GoogleAuthProvider,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signOut
} from "firebase/auth";
import { auth } from "./firebase.js";
import { clearPendingDraft, persistVerifiedMeal } from "./persistVerifiedMeal.js";

const draftStore = { pendingMeal: null };

const el = {
  authStatus: document.getElementById("authStatus"),
  uidShort: document.getElementById("uidShort"),
  log: document.getElementById("log"),
  email: document.getElementById("email"),
  password: document.getElementById("password")
};

function today() {
  return new Date().toISOString().slice(0, 10);
}

function shortUid(uid) {
  if (!uid) return "—";
  return `${uid.slice(0, 6)}…${uid.slice(-4)}`;
}

function log(msg, kind = "") {
  const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
  el.log.textContent = `${line}\n${el.log.textContent}`;
  console[kind === "bad" ? "error" : "log"](msg);
}

// 測試用假餐點——不是 Vision 辨識結果
function fixtureMeal(label = "fixture_meal_01") {
  return {
    slot: "snack",
    label,
    food_source: "convenience_store",
    items: [{ name: "fixture bread", qty: 1, unit: "piece" }]
  };
}

onAuthStateChanged(auth, (user) => {
  if (user) {
    el.authStatus.textContent = "已登入";
    el.authStatus.className = "ok";
    el.uidShort.textContent = shortUid(user.uid);
    log(`auth ok · uid=${shortUid(user.uid)}`);
  } else {
    el.authStatus.textContent = "未登入";
    el.authStatus.className = "warn";
    el.uidShort.textContent = "—";
    clearPendingDraft(draftStore);
    log("signed out · pending draft cleared");
  }
});

document.getElementById("btnGoogle").addEventListener("click", async () => {
  try {
    await signInWithPopup(auth, new GoogleAuthProvider());
  } catch (e) {
    log(`Google 登入失敗:${e.code || e.message}`, "bad");
  }
});

document.getElementById("btnEmail").addEventListener("click", async () => {
  const email = el.email.value.trim();
  const password = el.password.value;
  if (!email || !password) {
    log("請填 email / password", "bad");
    return;
  }
  try {
    await signInWithEmailAndPassword(auth, email, password);
  } catch (e) {
    if (e.code === "auth/user-not-found" || e.code === "auth/invalid-credential") {
      try {
        await createUserWithEmailAndPassword(auth, email, password);
        log("已建立測試帳並登入");
        return;
      } catch (createErr) {
        log(`建立帳號失敗:${createErr.code || createErr.message}`, "bad");
        return;
      }
    }
    log(`Email 登入失敗:${e.code || e.message}`, "bad");
  }
});

document.getElementById("btnSignOut").addEventListener("click", async () => {
  draftStore.pendingMeal = fixtureMeal("should_not_reuse");
  await signOut(auth);
});

// 用按鈕假裝 Verifier 的 decision,專門測閘門
async function tryPersist(decision) {
  const result = {
    decision,
    verified_meal: fixtureMeal(),
    model_raw_ref: null
  };
  draftStore.pendingMeal = result;
  try {
    const out = await persistVerifiedMeal(result, today());
    log(`寫入成功 → ${out.path}`);
  } catch (e) {
    log(`寫入被擋:${e.message}`, "bad");
  }
}

document.getElementById("btnAccept").addEventListener("click", () => tryPersist("ACCEPT"));
document.getElementById("btnConfirm").addEventListener("click", () => tryPersist("CONFIRM"));
document.getElementById("btnReject").addEventListener("click", () => tryPersist("REJECT"));
document.getElementById("btnNoAuth").addEventListener("click", async () => {
  await signOut(auth);
  await tryPersist("ACCEPT");
});

完整專案在 material/code/day6。這頁=閘門測試台;真正接照片辨識是後面的事。


4. 驗證與翻車

測試 預期 實際
未登入呼叫 persistence AUTH_REQUIRED 通過
登入後 CONFIRM/REJECT MEAL_NOT_VERIFIED、不寫 通過
ACCEPT 假資料 寫到目前 uid 路徑 通過 → users/{uid}/daily/2026-09-21
登出清草稿 pending 不沿用 通過
Firestore 可見假資料 fixture_meal_01 通過(測試模式)

今天學到的三個坑:

  1. uid 當前端欄位傳——網址改成別人的 id,看起來成功,其實是自欺。
  2. 測試模式放真實資料——30 天內誰都能動,假 label 就夠。
  3. firebaseConfig 整段貼文章——Web apiKey 本來會進前端,但全文廣播沒必要;.env.gitignore 就好。

5. 今日結論

  1. 上 Firebase 是為了身份+跨日記憶,不是替一次性 Prompt 加雲端。
  2. 只有「已登入 + Verifier ACCEPT」能進持久化——本機已跑過。
  3. Firestore 還在測試模式;Rules 隔離,明天再證明

下一篇: 用 Firestore Rules+Emulator 證明 uid-A 真的讀不到 uid-B。


附錄 A:可持久化 envelope schema

{
  "type": "object",
  "properties": {
    "schema_version": { "type": "string", "const": "meal_verified_v1" },
    "owner_uid": { "type": "string", "minLength": 1 },
    "date": { "type": "string" },
    "verifier_decision": { "type": "string", "const": "ACCEPT" },
    "needs_confirmation": { "type": "boolean", "const": false },
    "verified_meal": { "type": "object" },
    "model_raw_ref": { "type": ["string", "null"] },
    "verified_at": { "type": "string" }
  },
  "required": [
    "schema_version",
    "owner_uid",
    "date",
    "verifier_decision",
    "needs_confirmation",
    "verified_meal",
    "model_raw_ref",
    "verified_at"
  ]
}

verified_meal 沿用 Day 5;這層只定義「何時可持久化」。

附錄 B:發文前金鑰檢查

項目 發文用圖
apiKey/完整 firebaseConfig
Web Client Secret
Gmail/帳戶列表 已遮
完整 uid 已省略/截短
project 名 dishflow 可公開

上一篇
[Day 5] 進庫前兩道閘門:壞了只救一次,看不懂就停
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言