iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Build on Google AI

30 天用 Google AI 打造台灣防災速報 App系列 第 15 篇

Day 15|App 只能讀、不能寫:Firestore 資料模型與安全規則

  • 分享至 

  • xImage
  •  

系列:30 天用 Google AI 打造臺灣防災速報 App(Day 15/30)

第三週的目標是上線。前兩週的程式都在本機執行,9/25 起搬到雲端:示警的下載與整理、AI 摘要與分級、問答,都改成在 Google 的伺服器上自動執行。

今天談資料放在哪裡、誰可以讀寫。

雲端專案

這個系列用 Firebase 當 App 的後端。Firebase 是 Google 的 App 後端服務,這裡用到兩項:

  • Firestore:文件型資料庫。資料以「集合」與「文件」存放,每份文件是一組欄位,不需要事先定義資料表結構。
  • Cloud Functions:在雲端執行的函式,可以設定排程,或在資料庫有新文件時觸發。

Firebase 專案和 Gemini API 的金鑰放在同一個 Google Cloud 專案,費用看同一份帳單。Firestore 的位置選 asia-east1,也就是 Google Cloud 的臺灣(彰化)區域;位置建立後不能再改。

資料庫的設定和程式放在一起管理:

firebase.json            # 部署設定(Functions 用 Python 3.13)
firestore.rules          # 安全規則
firestore.indexes.json   # 索引(目前是空的)
functions/               # 雲端函式

八個集合

集合 內容 App 可以讀嗎 份數(9/26 12:36)
alerts 生效中的示警 可以 67
history 已結束的示警,保留 90 天 可以 827
earthquakes 中央氣象署的地震報告,保留 30 天 可以 22
meta 後端最近一次同步的時間與結果 可以 2
place_names 地震位置的地名譯名,翻過一次就重複使用 不行 20
private_config 後端自己用的設定 不行 1
rate_limits 問答與回報的使用次數計數 不行 3
answer_reports 使用者主動回報的問答,保存 90 天 不行 9/28 新增

meta 與 place_names 是後來加的:首頁要顯示「資料同步」的時間,後端每一輪同步結束時,把時間與成功與否寫進 meta;地震位置的地名翻成英日韓文之後存進 place_names,同一個地名下次直接沿用,譯名才不會每次不同。

9/28 又加了第八個集合 answer_reports。Android App 的上架平台 Google Play 規定,用 AI 產生內容的 App 要讓使用者在 App 內回報不妥的回答;使用者按下「回報這則回答」並送出時,那則問題、回答與回報原因存進這個集合,保存期限 90 天,到期後由 TTL 在 24 小時內刪除,App 不能讀。

首頁的示警清單只讀 alerts,同步時間另讀 meta/sync_status。已結束的示警收在 history,App 只在使用者展開「過去 24 小時已結束」時才讀。

一份示警長什麼樣子

以 9/25 中央氣象署發布的高溫示警為例,alerts 裡的文件實際有這些欄位(節錄)。第一個欄位 uid 是每一則示警的唯一識別碼:

uid: "CWA-Weather_heat_202609251754001#0b5892d2"
raw_id: "CWA-Weather_heat_202609251754001"
title: "高溫"
sender: "中央氣象署"
raw_category: "高溫"
group: "氣象"
summary: "天氣晴朗炎熱,明(26)日中午前後臺南市為橙色燈號,有連續出現36度高溫的機率,請加強注意。南投縣、屏東縣為黃色燈號,請注意。"
updated: "2026-09-25T17:54:17+08:00"
expires: "2026-09-26T17:00:00+08:00"
cap_link: "https://alerts.ncdr.nat.gov.tw/Capstorage/CWA/2026/heatWave/fifows_heat_202609251754.cap"
status: "active"
severity: "critical"
severity_source: "官方等級"
counties: ["tnn", "nan", "pif"]
county_levels: {"tnn": "critical", "nan": "warning", "pif": "warning"}
ai_summary: "中央氣象署發布高溫資訊,明(26)日中午前後,臺南市為橙色燈號……"
ai_summary_en: "The Central Weather Administration (CWA) has issued a high-temperature advisory. …"
ai_summary_ja: "中央気象署は高温情報を発表しました。…"
ai_summary_ko: "중앙기상서에서 고온 정보를 발표했습니다. …"
translation_check: {"en": "通過", "ja": "通過", "ko": "通過"}

欄位分成三組,寫入的時間點不同:

  1. 同步時寫入的欄位:raw_id、title、summary、sender、raw_category、updated、expires、cap_link 照國家災害防救科技中心(NCDR)的資料;uid、group(六大類)、status(生效中是 active)是第一週的程式算出來的。每 10 分鐘同步一次。
  2. 從官方電文取出的欄位:severity(等級)、counties(影響縣市)、county_levels(各縣市的等級)。
  3. AI 產生、程式核對的欄位:ai_summary(中文摘要,Day 8)、ai_summary_en/ja/ko(英日韓譯文,Day 13),以及 translation_check(譯文的數字與縣市核對結果)。

第 2、3 組在示警第一次寫入後,由另一個函式補上。severity_source 是「官方等級」,代表這個等級取自官方電文,沒有呼叫模型;電文沒有等級時才由模型依 Day 9 的規則判斷,這時會標成「AI 判定」。raw_category 是發布單位自己的分類(例如「高溫」),group 是我們歸納的六大類(例如「氣象」)。

五個設計決定

1. 生效中和已結束分開放。 第一次同步時,NCDR 的資料整理後有 800 多則示警,其中 731 則已經過期、22 則已結案,生效中的只有幾十則。NCDR 會把過期的示警保留好幾天。兩種資料的用法不同:生效中的示警,App 一開就要即時監聽;已結束的示警,只有使用者展開「過去 24 小時已結束」時才讀,而且要定期刪除。分開放之後,alerts 永遠只有生效中的示警,App 監聽的範圍很小;history 可以單獨設定自動刪除,兩個集合的用途也一看就懂。

history 第一次同步就放進了 797 則,之後只有新結束的示警才會加進來:9/25 20:40 到 9/26 02:53 這六個多小時,增加了 11 則。為了不讓它無限累積,設了 TTL(存活時間):Firestore 會依文件的 purge_at 欄位自動刪除到期的文件,已結束的示警保留 90 天。

2. 文件 ID 用 uid。 Day 5 發現 NCDR 的示警 id 不唯一:同一個 id 底下可能有好幾則內容不同的示警。uid 的寫法是「id#雜湊」,雜湊由 id、更新時間、摘要三者算出,取前 8 碼。同一則示警再寫一次,得到的 uid 相同,只會覆蓋同一份文件,不會多出一份。

Day 5 曾說正式存進資料庫時要改用完整的雜湊。實作時沒有改:uid 前面已經是 id,只有同一個 id 底下的示警才可能撞在一起,8 碼的雜湊有 16 的 8 次方、約 43 億種組合,已經足夠。

3. 四種語言放在同一份文件。 另一種做法是每種語言各存一份子文件。那樣切換語言時要多讀一次,App 也要同時監聽好幾個地方。放在同一份文件裡,一次讀取就拿到四種語言,切換語言只是換一個欄位顯示。代價是文件變大:9/26 凌晨測量了當時的 34 份文件,平均約 1.8 KB,最大的約 4.6 KB,離 Firestore 單份文件 1 MB 的上限很遠。

4. 不存原始電文,存電文網址。 原本打算把 CAP 電文整份存進資料庫。CAP 是國際通用的示警格式,內容用 XML(一種以標籤包住資料的文字格式)寫成。實作時改成只存 cap_link。App 需要的官方等級、影響縣市,已經在寫入時從電文取出存成欄位;要回頭核對原文時,用網址下載即可。

5. 縣市存代碼。 Day 10 的查詢工具用文字比對縣市,只寫區名、沒寫縣市名的示警會比對不到。CAP 電文裡有內政部的行政區代碼,所以 counties 改存代碼,例如臺南市是 tnn。同一則示警裡各縣市的等級可能不同,例如這則高溫示警,臺南是橙色燈號,南投、屏東是黃色;county_levels 記下每個縣市的等級,App 的地圖依此塗色。

縣市代碼要取對段落。一份 CAP 電文可能包含好幾則示警:衛生福利部把 19 家醫院的門診異動放在同一份電文,台灣自來水公司也常把好幾處停水放在同一份電文裡。第一版把整份電文的縣市套用到每一則示警上,於是新北市的醫院都多了桃園市,嘉義縣的停水多了臺南市。修正後,一份電文包含多段不同內容時,每一則只取和自己內容相同的那一段;對不到時,再從標題與摘要辨認縣市。9/26 補正時,當下生效中的 67 則裡有 40 則的縣市被改正,首頁的「桃園市」從 24 件變成 5 件。

還有一樣東西預設不存:使用者的問題。唯一的例外是使用者主動送出回報時,上面的 answer_reports 會存下那一則。問答的問題送到 Gemini 產生回答後,後端的日誌不記錄問題與回答的內容;每一題只記一行摘要,包括問題的分類、危機判定的來源、用到的查詢工具名稱、檢索分數與錯誤狀態。rate_limits 依來源 IP(裝置連線時使用的網路位址)計算問答與回報的次數,只存 IP 加鹽後的雜湊值,2 小時後到期,由 TTL 在到期後 24 小時內刪除;回報功能另外記一筆不含 IP 的每日總量,2 天後到期。雜湊是把資料轉成一串固定長度的代碼,無法直接反推回原值;加鹽是先混入一段只有後端知道的隨機字串再轉換,避免有人把所有 IP 逐一轉換後比對出原本的 IP。

這裡說的是資料庫。Cloud Functions 本身的請求日誌,仍會記錄每次請求的來源 IP 與瀏覽器資訊,依 Google Cloud 的預設保留 30 天;這部分不在 Firestore 裡,由日誌的保留期限管理。

索引:目前一個都沒建

Firestore 對單一欄位的查詢會自動建立索引,同時用好幾個欄位篩選並排序時,才需要另外建複合索引。目前的查詢都只用到一個欄位:

  • 首頁讀 alerts 全部,篩選與排序在 App 裡做(只有幾十份)。
  • 「過去 24 小時已結束」讀 history 裡 ended_at 在 24 小時內的文件。
  • 近期地震讀 earthquakes 裡 origin_time 在指定天數內的文件。

所以 firestore.indexes.json 目前是空的。之後的查詢需要複合索引時再加。

安全規則

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // 防災資訊公開可讀;所有寫入只能由後端(Admin SDK)進行
    match /alerts/{id} {
      allow read: if true;
      allow write: if false;
    }
    match /history/{id} {
      allow read: if true;
      allow write: if false;
    }
    match /earthquakes/{id} {
      allow read: if true;
      allow write: if false;
    }
    // 同步狀態(sync_status、quake_status),App 顯示「資料同步」時間用
    match /meta/{id} {
      allow read: if true;
      allow write: if false;
    }
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

原則只有一條:公開的防災資料可以讀,其他全部拒絕,寫入只能由後端進行。後端的 Cloud Functions 用 Admin SDK(伺服器端的管理套件)存取資料庫,不受這些規則限制。App 不需要登入,就算有人拆解 App、拿到 App 裡的 Firebase 專案設定,也無法通過安全規則修改示警。

最後一段 {document=**} 涵蓋所有沒有列出來的集合,所以 place_names、private_config、rate_limits 與後來加的 answer_reports 不需要各寫一條規則,新增集合時也預設拒絕。

規則部署之後,用一段測試程式,在沒有登入、不帶任何金鑰的狀態下,直接呼叫 Firestore 的 REST API 測了六件事。REST API 是用一般網址加上 GET、POST 這類 HTTP 方法來讀寫資料的介面,任何人都能從外部送出這種請求。App 在使用者手機上存取資料庫,適用的也是同樣的權限:

測試 結果
讀 alerts 成功
讀 private_config 403 PERMISSION_DENIED
讀 rate_limits 403 PERMISSION_DENIED
在 alerts 新增一則假示警 403 PERMISSION_DENIED
刪除 alerts 裡的示警 403 PERMISSION_DENIED
修改 history 裡示警的內容 403 PERMISSION_DENIED

今日小結

  • Firestore 放在臺灣機房,設定檔和程式一起管理。
  • 八個集合:生效中的示警、已結束的示警、地震報告、同步狀態四個公開唯讀,地名譯名、後端設定、使用次數計數、使用者回報不公開。
  • 生效中和已結束分開放;文件 ID 用 uid,同一版不會重複;四種語言放同一份文件;存電文網址不存原文;縣市存行政區代碼。
  • 使用者的問題預設不存,只有主動送出回報時例外;已結束的示警、使用次數計數與回報都設 TTL 自動刪除。
  • 目前的查詢都只用單一欄位,不需要複合索引。
  • 安全規則在未登入的狀態下實測:讀取公開示警成功,其餘五個未授權的讀寫測試全部被拒。

明天 Day 16:每 10 分鐘自動下載一次的排程,以及它上線第一天就寫了太多次資料庫的問題。


上一篇
Day 14|第二週回顧:四項 AI 功能做到哪裡,以及模型四次把規則當成內容的教訓
系列文
30 天用 Google AI 打造台灣防災速報 App 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言