廠商一句話,整個回調機制要重寫

我們的健康管理 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 的關聯,原理相同。
AASAApple App Site Association,放在 https://你的網域/.well-known/apple-app-site-association 的 JSON 檔案,告訴 iOS「哪些路徑要交給哪個 App 開啟」。
assetlinks.jsonAndroid 版的 AASA,放在 https://你的網域/.well-known/assetlinks.json,功能相同。
Provisioning ProfileApple 的簽名設定檔,記錄 App 被允許使用哪些功能(如 Push Notifications、Associated Domains)。新增功能時必須重新產生。
Deep Link泛指所有能從外部開啟 App 並導到特定頁面的連結,URL Scheme 和 Universal Link 都是 Deep Link 的實作方式。

改動前後的流程差異,用一張圖看最清楚:

Mermaid Diagram

URL Scheme 像是在門上掛一個自訂名牌 —— 任何人都可以掛相同的名牌,系統無法驗證誰才是真正的主人。

Universal Link 則是把你的 App 和你的網域綁定在一起。iOS 會去 https://your-domain.com/.well-known/apple-app-site-association 抓一份 JSON 設定檔,確認「這個網域的特定路徑,確實屬於這個 App」。Android 的 App Links 也是同樣原理,透過 assetlinks.json 驗證。

這代表三件事:

  1. 你需要一個自己控制的網域,而且上面要放驗證檔案
  2. 驗證檔案必須在 App 安裝前就部署好,因為 iOS 在安裝時下載 AASA
  3. 回調 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。整個驗證流程如下:

Mermaid Diagram

具體步驟:

  1. App 發起綁定時,產生一個隨機 token 存在本地(SharedPreferences)
  2. Token 跟著 callback URL 送給廠商
  3. 廠商完成綁定後,原封不動帶回 token
  4. 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

這是最容易搞錯的地方:

Mermaid Diagram

Apple 在 App 安裝時下載 AASA,不是每次啟動。所以:

  1. 先部署網站 — AASA 和 assetlinks.json 上線
  2. 驗證 Apple CDN 已快取curl https://app-site-association.cdn-apple.com/a/v1/www.your-domain.com
  3. 再發佈 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 的網域? 如果有,遷移的技術門檻其實不高。如果沒有,那才是真正的前置工作。