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 實作要求¶
- 快速回 2xx
- 先以去重鍵冪等落地
- 再異步做 ingest/matching
- 權威狀態有疑慮時一律 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(對應 storestate)。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。