廠商一句話,整個回調機制要重寫
我們的健康管理 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 的實作方式。 |
改動前後的流程差異,用一張圖看最清楚:
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 爆掉。
回顧:一次改動背後的連鎖反應
看起來只是「把 myapp:// 改成 https://」,實際上牽動了:
- 網站:靜態驗證檔、Nginx 設定、新路由、fallback 頁面
- App:iOS entitlements、Android manifest、兩個 service 改寫
- 安全機制:token 產生、持久化、過期、一次性使用、防重複觸發
- 部署流程:嚴格的先後順序
- 廠商溝通:新的 callback URL 格式和 token 規範
最終的架構比 URL Scheme 穩固很多 —— 有網域驗證防劫持、有 token 防偽造、有 fallback 防漏接。但代價是複雜度高了一個量級。
如果你正在評估要不要從 URL Scheme 遷移到 Universal Link,先確認一件事:你有沒有一個自己控制的、已經部署好 HTTPS 的網域? 如果有,遷移的技術門檻其實不高。如果沒有,那才是真正的前置工作。
