跳轉到

Callback 與事件

分流規則

Handler 先判斷 payload 是否有 event 鍵:

有 event? 類型
無 Task terminal callback
有 device.bound / bank.bound / balance.changed / device.locked / device.unlocked

既有 task handler 不必改;用有無 event 即可安全分流。

flowchart TD
  R[收到 POST callback] --> E{payload 有 event?}
  E -->|否| T[task terminal:task_id / status / result]
  E -->|是| V{event 值}
  V -->|device.bound / bank.bound| B[enrollment 綁定]
  V -->|balance.changed| C[餘額異動]
  V -->|device.locked / unlocked| L[鎖狀態]

Delivery matrix

Payload 接收端去重鍵 現行投遞保證 PAYMENT fallback
Task terminal task_id(callback 的 idempotency_key 同值) best-effort,無持久化重送 GET /v1/tasks/{task_id} 或 ?idempotency_key=
device.bound enrollment_id(= event idempotency_key) best-effort GET /v1/enrollments/{id}
bank.bound enrollment_id best-effort GET enrollment + GET device
balance.changed source_task_id best-effort GET /v1/tasks/{source_task_id};舊事件無此欄位時另派 Balance probe
device.locked event idempotency_key(如 lock-42) DB outbox,重試至 2xx GET device
device.unlocked event idempotency_key DB outbox,重試至 2xx GET device

不要假設 exactly-once

best-effort callback 不具備 durable delivery。 locked/unlocked 雖 outbox 重試至 2xx,仍可能重複投遞 → 必須以 idempotency_key 冪等落地。 Worker 不跟隨 3xx;3xx 視同失敗。

Handler 實作要求

  1. 快速回 2xx
  2. 先以去重鍵冪等落地
  3. 再異步做 ingest/matching
  4. 權威狀態有疑慮時一律 GET,不依賴「沒收到 callback = 沒發生」

Task terminal callback

{
  "task_id": "01J…",
  "idempotency_key": "01J…",
  "device_id": "TH-XXXX",
  "type": "transfer",
  "status": "succeeded",
  "result": {},
  "error": null
}
  • status 為終態:succeeded 或 failed(對應 store state)。
  • idempotency_key = task_id(server 任務 id,不是 client 的 ORD key)。
  • URL 為 task 登記的 callback_url(或 tenant 預設)。

Bound events

見 enrollment。idempotency_key = enrollment_id。永不帶 PIN。

balance.changed

{
  "event": "balance.changed",
  "device_id": "TH-XXXX",
  "tenant_id": "…",
  "source_task_id": "01J…",
  "old": {"accounts": []},
  "new": {"accounts": []},
  "ts": 0
}

best-effort。old/new 只供提示,不是 PAYMENT 的寫入依據;PAYMENT 應以 tenant Bearer token 讀取 source_task_id,核對 task 為同裝置、type=balance、state=succeeded 且結果完整後,採用該次已完成的銀行權威讀值。這可避免再次開啟銀行 App 的 Balance probe。過渡期若舊 Farm 事件沒有 source_task_id,才沿用另派 probe 的 fallback。

Farm 只在該筆 Balance task 已寫入 succeeded 終態之後才送這個事件(iOS 與 Android Worker 共用同一條路徑:finalize → observe.record → emit),所以 GET /v1/tasks/{source_task_id} 必定讀得到終態與完整 result。事件本身不重試,因此 PAYMENT 以 source_task_id 作冪等 identity,並以該 task 的觀測/完成時間排序:重複事件不重複寫入,較舊的 source task 不覆蓋 較新的餘額,也不用「收到事件的先後」猜銀行觀測順序。

Farm 可能接著自動入列 transaction_pull(trigger=balance_changed),PAYMENT 會再收一筆 無 event 鍵的 task callback。那是另一條正式明細鏈,不取代上面的 Balance 採用,也不是 再派 Balance probe 的理由。

Lock events

{
  "event": "device.locked",
  "idempotency_key": "lock-42",
  "device_id": "TH-XXXX",
  "device_code": "TH-XXXX",
  "bank_code": "SCB",
  "reason": "pin_locked",
  "source": "task",
  "task_id": "01J…",
  "locked_at": 1754712345.6
}

device.unlocked 另含 unlocked_at、unlocked_by("tenant" / "admin")、note; reason/source/task_id 為解鎖前快照。

  • 送到 tenant 層 callback_url(不是單一 task callback)。
  • Outbox 退避 min(30 × 2^attempts, 3600) 秒,不放棄。
  • 未設 callback_url 時事件仍留存,補上後可送出。

非 PAYMENT 下單的 task callback

balance.changed 觸發的 auto transaction_pull 會以 task terminal 形狀回呼。 以 result/payload 脈絡(或業務側關聯)辨識,勿假設每筆 task 皆由己方 POST。