iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Build on Google AI

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

Day 25|重大示警推播:只推最嚴重的,用更新鏈控制重複

  • 分享至 

  • xImage
  •  

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

示警列表要使用者自己打開才看得到。半夜的豪雨紅色警戒,需要 App 主動通知。今天用 Firebase 雲端通訊(Firebase Cloud Messaging,FCM,Google 的推播服務)做重大示警推播。

先說現況:Android 版 10/7 04:02 起已經正式推送。iOS 還收不到推播,最後一節列出還差什麼。

先決定推什麼

通知太多,使用者可能乾脆關掉通知,真正嚴重的那則也就收不到了。所以只推最嚴重的兩級,而且要不要推由官方等級決定:

條件 推不推
極重大(紅):官方等級 Extreme,或電文明寫紅色警戒 推
重大(橙):官方等級 Severe 推
重大(橙):官方沒給等級、由 AI 補判 不推,App 裡照常顯示
警戒、資訊 不推
演習、測試、解除的電文,或已過期 不推
地震 先不推

縣市由使用者自己選。通知的內文是 AI 摘要(Day 13),最多 200 字。

用更新鏈控制重複推播

這裡要注意 msgType 的判斷。CAP 電文(示警的標準格式,Day 2)有一個欄位 msgType,表示這份是新發布(Alert)、更新(Update)還是解除(Cancel)。直覺會寫「Update 就不推」,但我們抽查 30 份生效中的電文,有 12 份是 Update,其中中央氣象署的降雨、強風示警 6 份全是 Update。照「Update 不推」,這次抽查的 6 份氣象署降雨、強風示警都會被排除。

每份 Update 電文都會在 <references> 列出它更新的是哪幾份舊電文,串起來就是一條「更新鏈」。所以去重的單位改成更新鏈:

  • 同一條鏈裡,同一個縣市只推一次。
  • 更新電文新增了縣市(例如豪雨範圍擴大),新縣市等級達標就推。
  • 同一縣市從重大升到極重大,要不要再推做成設定,目前預設不推。
  • 抓不到 CAP 電文的識別碼時,退回用示警的 uid(系統給每則示警的識別碼)去重。

推過的紀錄寫在 Firestore 的 push_sent(只驗證不送出的期間,寫在另一個集合 push_sent_dryrun,以免之後切換時被當成已經推過)。判斷和紀錄放在同一個交易裡,先寫下「要推了」再送出,可以避免兩個執行個體同時各送一次。要注意這個順序的代價:寫下紀錄後如果送出失敗,同一則就可能漏推。所以個別訊息送出失敗時,隔 2 秒重送一次;仍然失敗,或整批發送出錯,就只撤回這份電文自己的紀錄,並在日誌記一筆 failed_released,之後再觸發時可以重推。另外有定期補推,重新處理還沒推成功的示警。

同一時段的多份電文:30 分鐘冷卻

更新鏈只合併「同一件事的更新」。另一種情況它擋不掉:同一縣市、同一分鐘,發出好幾份各自獨立的電文。

推播接上正式流程後,我們先只判斷、不送出,跑了 3.5 小時(10/3 14:40–18:10)。如果當時真的送了,屏東縣的使用者會在一個半小時內收到 7 則通知:17:10 同一分鐘有三份淹水警戒,18:00 又有兩份雷雨訊息在同一分鐘發布。

所以加了一條冷卻規則,先在只驗證不送出的期間跑過,再跟著正式送出一起上線:

  • 同一縣市、同一等級、同一類別,30 分鐘內只推第一則。
  • 極重大示警不受限制,每則都推。
  • 被擋下的示警照常顯示在 App 裡,只是不另外跳通知。

比對條件要包含「類別」。只比縣市和等級的話,剛推過雷雨,10 分鐘後的豪雨特報也會被擋掉,新出現的災害類型就收不到通知。用同一段資料模擬:14 則裡擋下 2 則,都是同一分鐘、同一類的重複電文;屏東從 7 則變成 5 則。

用主題送,後端不存推播識別碼

FCM 可以對單一裝置送,也可以對「主題」送:裝置訂閱主題,後端只要對主題發訊息。我們用主題,命名是 alert_{縣市}_{等級}_{語言},等級是 extreme 或 critical,例如 alert_tpe_extreme_zh。

我們的後端因此不用保存裝置的推播識別碼。訂閱由 App 直接向 FCM 登記,後端只知道「要對哪些主題送」。

一則示警常常涵蓋好幾個縣市。FCM 可以用條件一次送給多個主題(最多 5 個),所以後端組成這樣的訊息(簡化)。key 是合併同一則通知用的識別值,ttl 是通知最多可以延後多久送達,apns 開頭的是給 iOS 推播服務 APNs(Apple Push Notification service)的設定,下面都會說明:

messaging.Message(
    condition="'alert_tpe_critical_zh' in topics || 'alert_nwt_critical_zh' in topics",
    notification=messaging.Notification(title="重大示警", body=summary),
    data={"uid": uid, "title": "重大示警", "level": "critical"},
    android=messaging.AndroidConfig(
        priority="high", ttl=ttl,
        notification=messaging.AndroidNotification(tag=key, channel_id="major_alerts")),
    apns=messaging.APNSConfig(headers={"apns-collapse-id": key, "apns-priority": "10",
                                       "apns-expiration": str(expire_epoch)}),
)

兩個設定要注意:

  • 合併鍵以示警的 uid 為主。 Android 的 tag 和 iOS 的 apns-collapse-id 是系統合併通知用的識別值。一則示警分批送到不同縣市的主題,同一支手機如果同時訂了兩個縣市,系統只會留一則通知。apns-collapse-id 上限是 64 位元組,uid 太長時改用它的 SHA-256 雜湊前 32 碼。
  • 有效期限設到示警的到期時間。 Android 用 ttl,iOS 用 apns-expiration,都依示警的到期時間設定(示警沒寫到期時間就用 6 小時),手機離線太久就不會收到舊通知。程式把有效時間下限設成 5 分鐘,所以接近到期的示警,仍可能在到期後幾分鐘內送達。

App 端要注意的四件事

1. 使用者選了縣市才問通知權限。 不在一打開 App 就跳權限視窗;等使用者在設定頁選了縣市、知道通知是為了什麼,才請求權限。

2. Android 要先建好通知頻道。 後端指定的頻道 major_alerts,App 啟動時要先建立,重要性設成 HIGH,通知才會跳出橫幅。Android 13 以上還要取得通知權限(POST_NOTIFICATIONS)。

3. iOS 要先拿到 APNs 權杖才能訂閱。 iOS 的推播要經過 APNs。firebase_messaging 在還沒拿到 APNs 權杖時訂閱主題,會直接丟錯誤,所以先檢查,拿不到就稍後重試(簡化):

if (defaultTargetPlatform == TargetPlatform.iOS &&
    await messaging.getAPNSToken() == null) {
  retryLater();   // 每 5 秒再試,最多 1 分鐘;之後等權杖更新或下次啟動
  return;
}
await messaging.subscribeToTopic(topic);

4. 點通知要開到那則示警,三種狀態都要處理。 App 在前景、在背景、已經關閉,接收通知的入口各不相同(onMessage、onMessageOpenedApp、getInitialMessage)。通知的資料欄位有 uid、title、level,App 用 uid 去 Firestore 找。示警到期後會被搬到 history,所以先查 alerts,沒有再查 history;都找不到就顯示「這則示警已結束。」,不顯示錯誤畫面。

Android 通知欄收到的重大示警

網頁版不做推播

Flutter 的 Firebase 套件在網頁上不能自己訂閱主題,呼叫 subscribeToTopic() 會直接丟出「not supported on the web clients」。要做就得另外寫一個後端服務代替網頁訂閱,這次先不做。網頁版改成一行說明,請使用者安裝 App 接收通知。

怎麼測

推播不能拿真的使用者來試。測試分成兩部分:

  • 測試主題。 除錯版有一個測試開關,打開後改訂 test_ 開頭的主題,後端用測試腳本只對這些主題送。正式使用者永遠不會訂到。
  • 先只驗證、不送出。 推播接上正式流程後,先用 FCM 的 dry run 模式上線:照常判斷、組訊息,FCM 只檢查格式,不真的送出。確認判斷日誌沒問題、App 和隱私權政策裡的推播說明更新、定期補推和冷卻規則也做好後,10/7 04:02 才改成真的送。

切換時要注意定期補推的範圍。補推會找「生效中、但還沒有推播紀錄」的示警重推;只驗證期間的紀錄寫在另一個集合,所以一切換,所有生效中的舊示警看起來都「還沒推過」。我們用一個切換時間當起點,補推只處理這個時間之後才進來的示警。

Android 模擬器上測了四種情況,都通過:App 在前景時畫面下方出現提示列;在背景時通知欄出現通知;App 關閉時點通知直接開到詳情;示警已結束時顯示「這則示警已結束。」。後端每種語言各送一則,模擬器只收到介面語言那一則,沒有重複。切成正式送出後,再用一支實體 Android 手機訂閱正式主題測一次,收到通知、點開到詳情都正常。

iOS 還差什麼

iOS 的程式已經寫好,但要真的收到推播,還要:

  1. Apple 開發者帳號(每年 99 美元)。
  2. APNs 金鑰:在 Apple 開發者後台產生,上傳到 Firebase 主控台。之後 FCM 會代替我們轉送到 APNs,後端不用為 iOS 另外寫。
  3. 實機測試:APNs 到手機的整段流程,我們會用真的 iPhone 驗證,不以模擬器結果代替。

在這之前,iOS 版的推播設定頁會說明 iPhone/iPad 目前還不支援推播。

今日小結

  • 只推最嚴重的兩級,而且要有官方依據;AI 補判的等級不推。
  • 這次抽查的 6 份氣象署降雨、強風示警都是更新電文,去重要以更新鏈為單位,不能看到 Update 就不推。
  • 同一時段的多份獨立電文,用「同縣市+同等級+同類別」30 分鐘冷卻;極重大不受限。
  • 用主題送,後端不用保存推播識別碼;Android 的 tag 和 iOS 的 apns-collapse-id 用同一個合併鍵,同一則只留一個通知。
  • iOS 要先拿到 APNs 權杖才能訂閱;網頁版不支援訂閱主題。
  • 先用測試主題和只驗證不送出的模式確認,條件都滿足後 10/7 04:02 起對真的使用者送;切換時補推只處理切換後的新示警。

明天 Day 26:三則警報,該合成一則通知嗎?讓 Gemini 當防災代理的界線。


上一篇
Day 24|把 30 年、2,011 起地震畫在地圖上:空白的地方不代表安全
系列文
30 天用 Google AI 打造台灣防災速報 App 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言