系列:30 天用 Google AI 打造臺灣防災速報 App(Day 15/30)
第三週的目標是上線。前兩週的程式都在本機執行,9/25 起搬到雲端:示警的下載與整理、AI 摘要與分級、問答,都改成在 Google 的伺服器上自動執行。
今天談資料放在哪裡、誰可以讀寫。
這個系列用 Firebase 當 App 的後端。Firebase 是 Google 的 App 後端服務,這裡用到兩項:
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": "通過"}
欄位分成三組,寫入的時間點不同:
raw_id、title、summary、sender、raw_category、updated、expires、cap_link 照國家災害防救科技中心(NCDR)的資料;uid、group(六大類)、status(生效中是 active)是第一週的程式算出來的。每 10 分鐘同步一次。severity(等級)、counties(影響縣市)、county_levels(各縣市的等級)。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 裡做(只有幾十份)。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 |
uid,同一版不會重複;四種語言放同一份文件;存電文網址不存原文;縣市存行政區代碼。明天 Day 16:每 10 分鐘自動下載一次的排程,以及它上線第一天就寫了太多次資料庫的問題。