廠商一句話,整個回調機制要重寫
我們的健康管理 App 整合了一台第三方氣酮檢測裝置。綁定流程很單純:使用者從我們的 App 跳到廠商的 App 完成綁定,綁定完成後跳回來,顯示成功或失敗。
原本的回調方式是 URL Scheme:
myapp://device-bindback?status=success
iOS 上一直運作正常,直到廠商的 Android App 更新後回報:他們不支援 URL Scheme 回調,要求改用 Universal Link。
這不是改一行 URL 的事。Universal Link 牽涉到網域驗證、靜態檔案部署、平台設定、安全機制,幾乎是把整個回調架構重新設計。
在深入之前,先釐清幾個關鍵術語:
| 術語 | 說明 |
|---|---|
| URL Scheme | 自訂的 URL 前綴(如 myapp://),讓其他 App 可以透過這個 URL 開啟你的 App。缺點是任何 App 都可以註冊相同的 scheme,沒有驗證機制。 |
| Universal Link(iOS) | Apple 的機制,把 HTTPS URL 和特定 App 綁定。系統透過 AASA 檔案驗證網域和 App 的關聯,確保只有合法的 App 能攔截該 URL。 |
| App Links(Android) | Google 對應 Universal Link 的機制,透過 assetlinks.json 驗證網域和 App 的關聯,原理相同。 |
| AASA | Apple App Site Association,放在 https://你的網域/.well-known/apple-app-site-association 的 JSON 檔案,告訴 iOS「哪些路徑要交給哪個 App 開啟」。 |
| assetlinks.json | Android 版的 AASA,放在 https://你的網域/.well-known/assetlinks.json,功能相同。 |
| Provisioning Profile | Apple 的簽名設定檔,記錄 App 被允許使用哪些功能(如 Push Notifications、Associated Domains)。新增功能時必須重新產生。 |
| Deep Link | 泛指所有能從外部開啟 App 並導到特定頁面的連結,URL Scheme 和 Universal Link 都是 Deep Link 的實作方式。 |
| SHA-256 指紋 | App 簽名憑證的雜湊值(32 組 hex,如 AA:BB:CC:...),用來唯一識別一個 App 的簽名身份。assetlinks.json 靠它比對「宣稱的 App」和「裝置上的 App」是否同一個。 |
| Upload Key | 開發者本地 keystore 的私鑰,用來簽名上傳到 Play Console 的 AAB/APK。本地開發、CI、模擬器都用這把。 |
| App Signing Key | Google Play 持有的私鑰,用來對上架版 App 做最終簽名。使用者從 Play Store 安裝的 App 是這把 key 簽的,指紋跟 upload key 不同。 |
改動前後的流程差異,用一張圖看最清楚:
URL Scheme 和 Universal Link 的本質差異
URL Scheme 像是在門上掛一個自訂名牌 —— 任何人都可以掛相同的名牌,系統無法驗證誰才是真正的主人。
Universal Link 則是把你的 App 和你的網域綁定在一起。iOS 會去 https://your-domain.com/.well-known/apple-app-site-association 抓一份 JSON 設定檔,確認「這個網域的特定路徑,確實屬於這個 App」。Android 的 App Links 也是同樣原理,透過 assetlinks.json 驗證。
這代表三件事:
- 你需要一個自己控制的網域,而且上面要放驗證檔案
- 驗證檔案必須在 App 安裝前就部署好,因為 iOS 在安裝時下載 AASA
- 回調 URL 從
myapp://變成https://,本質上變成了一個真實的網頁 URL
遷移的三個戰場
戰場一:網站端 — 放驗證檔案和 fallback 頁面
AASA 檔案本身不複雜,但有幾個容易踩的坑:
檔案不能有副檔名。 apple-app-site-association 就是檔名,不是 apple-app-site-association.json。如果你的 web server 沒設定好,可能會回傳錯誤的 Content-Type。
# Nginx 必須在 SPA fallback 之前攔截
location /.well-known {
root /usr/share/nginx/html;
default_type application/json;
}
SPA 的 try_files 會吃掉所有路徑。 如果你的前端是 Vue 或 React SPA,try_files $uri $uri/ /index.html 會把 .well-known 路徑導到 index.html,Apple 驗證就會失敗。必須把 .well-known 的 location block 放在 location / 之前。
另一個容易忽略的是 fallback 頁面。Universal Link 不保證 100% 觸發 App 攔截 —— Safari 有時候會直接開網頁。所以你需要一個 fallback 頁面,在 App 沒攔截到的時候嘗試用 URL Scheme 喚醒:
// iOS: 嘗試 URL Scheme,失敗後導向 App Store
window.location = `myapp://device-bindback?status=${status}`
setTimeout(() => {
window.location = 'https://apps.apple.com/app/id123456789'
}, 2000)
Android 也用同樣策略。原本想用 intent:// URI(Chrome 原生支援),但 LINE 和 Facebook 的內建瀏覽器不吃 intent://,最終統一用 URL Scheme + setTimeout。
戰場二:App 端 — 平台設定和 handler 改寫
iOS 要在 entitlements 加 Associated Domains:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.your-domain.com</string>
</array>
Android 要在 AndroidManifest 加 intent-filter,重點是 autoVerify="true":
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="www.your-domain.com"
android:path="/app/device-bindback" />
</intent-filter>
Deep link handler 要同時處理新舊兩種格式 —— Universal Link(https:// scheme)和舊的 URL Scheme(myapp://),因為 fallback 頁面會用 URL Scheme 嘗試喚醒,而且舊版 App 使用者不應該受影響。
戰場三:安全性 — 不能信任 callback 的 status
原本的做法有一個嚴重問題:任何人都可以偽造 callback URL。 只要打開 https://www.your-domain.com/app/device-bindback?status=success,App 就認定綁定成功。
解法是加一個一次性 token。整個驗證流程如下:
具體步驟:
- App 發起綁定時,產生一個隨機 token 存在本地(SharedPreferences)
- Token 跟著 callback URL 送給廠商
- 廠商完成綁定後,原封不動帶回 token
- App 比對 token 一致才認定結果有效
// 產生 token 並持久化(不能只存記憶體!)
final token = List.generate(
32, (_) => Random.secure().nextInt(16).toRadixString(16),
).join();
await prefs.setString('pending_token', token);
await prefs.setInt('token_timestamp', DateTime.now().millisecondsSinceEpoch);
為什麼不能只存記憶體? 因為使用者跳到廠商 App 的過程中,iOS 很可能在背景 kill 你的 App。回來的時候記憶體裡的 token 已經消失,合法的 callback 反而被當成非法。
Token 還要加上過期時間(我們設 5 分鐘),防止舊 token 被重用。
部署順序:先網站,後 App
這是最容易搞錯的地方:
Apple 在 App 安裝時下載 AASA,不是每次啟動。所以:
- 先部署網站 — AASA 和 assetlinks.json 上線
- 驗證 Apple CDN 已快取 —
curl https://app-site-association.cdn-apple.com/a/v1/www.your-domain.com - 再發佈 App — 安裝時 iOS 才會抓到正確的 AASA
順序反了的話,使用者安裝 App 時 AASA 還不存在,Universal Link 就不會生效,而且不會自動重試 —— 要等使用者重新安裝 App 才會再次驗證。
另一個要注意的命名衝突
我們的 callback URL 原本用 token 作為 query parameter 名稱。部署到 staging 後發現 Vue Router 的 navigation guard 會攔截 URL 中的 token 參數,把它當成 JWT 認證 token 處理,然後從 URL 中移除。
Fallback 頁面收到的 token 值永遠是空的。
解法很簡單:把參數改名為 callback_token。但如果沒有部署 staging 先測試,這個問題會直接在 production 爆掉。
上線後的兩個坑:Android 跳 Play Store、iOS 按鈕不變綠
Universal Link 部署上線後,兩個平台各出了一個問題。都不是程式碼邏輯的錯,卻都花了不少時間定位。
Android:assetlinks.json 的指紋陷阱
上線後 Android 使用者回報:從廠商 App 跳回來,不是開啟我們的 App,而是跳到 Play Store。
要理解這個問題,先看 Android App Links 的驗證機制。前面提過 assetlinks.json 是 Android 版的 AASA —— 放在你網域的 /.well-known/ 路徑下,告訴 Android「這個網域的連結屬於這個 App」。但 Android 怎麼確認「這個 App」就是它宣稱的那個?答案是簽名指紋。
每個 Android App 在打包時都會用一把私鑰簽名,產生一組獨一無二的 SHA-256 指紋(長這樣:AA:BB:CC:DD:... 共 32 組 hex)。assetlinks.json 裡放的就是這組指紋,Android 安裝 App 時會比對:「App 的簽名指紋」和「assetlinks.json 裡宣告的指紋」一致,才認定這個 App 有權攔截該網域的連結。
整個驗證流程可以用這張圖理解:
問題就出在「指紋比對」這一步。
第一反應是 assetlinks.json 沒部署好。用 curl 確認檔案存在、Content-Type 正確、Google 的 Digital Asset Links API 也驗證通過。問題不在這裡。
關鍵線索是用 adb 查裝置上的實際驗證狀態:
$ adb shell pm get-app-links com.example.myapp
Signatures: [A0:A5:35:C3:...]
Domain verification state:
www.your-domain.com: 1024 # 驗證失敗
裝置上 App 的簽名指紋(A0:A5:...),跟 assetlinks.json 裡的(38:20:...)不一樣。
原因是 Google Play App Signing。這是理解這個坑的核心:Android App 的簽名其實分成兩把 key。
| Key | 誰持有 | 用途 | 指紋來源 |
|---|---|---|---|
| Upload key | 你(本地 keystore) | 上傳 AAB/APK 到 Play Console | keytool -list -v -keystore your.jks |
| App signing key | Google(Play Console 管理) | 對上架版 App 做最終簽名 | Play Console → 設定 → 應用程式簽署 |
從 2021 年 8 月起,新上架的 App 強制啟用 Play App Signing。Google 會用自己的 key 重新簽名你上傳的 AAB,所以從 Play Store 安裝的 App,簽名指紋是 app signing key 的,不是你本地 keystore(upload key)的。
我們犯的錯就是 assetlinks.json 裡只放了 upload key 的指紋。本地開發、CI、模擬器全部用 upload key 簽名,測試一切正常。但真實使用者從 Play Store 裝的 App 是 app signing key 簽名的 —— 指紋不匹配,驗證失敗。
修正方式:到 Play Console → 設定 → 應用程式簽署,找到 App signing key 的 SHA-256 指紋,加進 assetlinks.json:
{
"sha256_cert_fingerprints": [
"AA:BB:CC:...(upload key,保留給 debug/CI)",
"DD:EE:FF:...(Play Store app signing key,這才是關鍵)"
]
}
修正後用 adb 驗證:
# 手動核准(測試用,模擬指紋匹配的狀態)
$ adb shell pm set-app-links --package com.example.myapp 2 all
# 驗證狀態
$ adb shell pm get-app-links com.example.myapp
Domain verification state:
www.your-domain.com: approved # 通過
# 取消核准,確認失敗行為
$ adb shell pm set-app-links --package com.example.myapp 0 all
# → 開啟 Universal Link → Chrome 打開(復現問題)
教訓: 如果你的 App 走 Google Play 發佈,assetlinks.json 至少要放兩組指紋 —— upload key 和 Play Store app signing key。只放 upload key 是最常見的 Android App Links 失敗原因,而且開發階段完全不會發現,因為你的測試裝置用的永遠是 upload key。
iOS:token 驗證的過度防禦
iOS 的 Universal Link 攔截正常,App 也確實被喚醒了,但綁定按鈕的狀態沒有從「綁定」變成「綁定成功」。
追蹤資料流:deep link handler 收到 URL → 解析 status=success → 呼叫 verifyToken() → 按鈕應該變綠。
問題出在 token 驗證。遷移到 Universal Link 時,我們加了 callback_token 安全機制:App 發起綁定時產生隨機 token,廠商原封不動帶回,App 比對後才接受結果。
但廠商的回調 URL 沒有帶回 callback_token 參數。verifyToken(null) 直接回傳 false,狀態就永遠不會更新 —— 而且不會報錯,完全靜默失敗。
修正方式:把「沒有 token」和「token 驗證失敗」分開處理。沒有 token 時降級為無驗證模式,而不是直接拒絕:
// 修正前:token 為 null 直接拒絕,整個流程卡死
if (token == null) return false;
// 修正後:沒有 token 時跳過驗證,接受狀態
if (callbackToken == null) {
_applyBindResult(status); // 降級為無驗證模式
return;
}
// 有 token 才做完整驗證
verifyToken(callbackToken).then((valid) { ... });
這是典型的防禦過度問題。安全驗證沒有做 graceful degradation —— 當第三方廠商不配合帶回 token 時,整個功能就卡死了,而且沒有任何錯誤提示。
教訓: 跟第三方整合的安全機制,要設計 fallback 路徑。驗證材料齊全就嚴格驗證,缺失就降級處理並留下日誌,而不是靜默吞掉整個流程。寫安全驗證時多問一句:如果對方沒照規格來,這段程式碼會怎樣?
回顧:一次改動背後的連鎖反應
看起來只是「把 myapp:// 改成 https://」,實際上牽動了:
- 網站:靜態驗證檔、Nginx 設定、新路由、fallback 頁面
- App:iOS entitlements、Android manifest、兩個 service 改寫
- 安全機制:token 產生、持久化、過期、一次性使用、防重複觸發
- 部署流程:嚴格的先後順序
- 廠商溝通:新的 callback URL 格式和 token 規範
- 簽名驗證:Play Store app signing key 和 upload key 的差異
而且上線後還會冒出新問題 —— Android 的指紋不匹配和 iOS 的 token 靜默失敗,都不是開發階段能發現的。前者要真正透過 Play Store 安裝才會觸發,後者要等廠商實際回調才能重現。
最終的架構比 URL Scheme 穩固很多 —— 有網域驗證防劫持、有 token 防偽造、有 fallback 防漏接。但代價是複雜度高了一個量級,而且每一層都有自己獨立的失敗模式。
如果你正在評估要不要從 URL Scheme 遷移到 Universal Link,先確認兩件事:你有沒有一個自己控制的、已經部署好 HTTPS 的網域? 以及 你的 Android App 是否透過 Play Store 發佈? 如果是,記得去 Play Console 拿 app signing key 的指紋,不要只用本地 keystore 的。
