Skip to content

免費旋轉回呼

平台 → 廠商回呼之一,和 Session 撤銷回呼一樣,這裡的方向是反過來的:由平台呼叫你的伺服端,不是你呼叫平台。當免費旋轉促銷工具——不論是後台管理員操作,還是運營商自己簽名呼叫自助 API——要為你某個遊戲的玩家發放、查詢或取消一批免費旋轉時,就會觸發這裡的呼叫。免費旋轉的執行與遊戲數學完全留在你這一側:平台只會告訴你「發放/查詢/取消一批」,自己只留一份用於稽核與冪等的本地記錄,僅此而已。

請實作下面三條路徑(精確路徑,位於你的對接窗口為你註冊的 baseUrl 上——和你的 Session 撤銷回呼是同一個 baseUrl),並在信任請求內容之前,用 @moosehq/provider-sdkverifyPlatformSignature 驗證每一次呼叫,做法和 Session 撤銷回呼完全一樣。

簽名

簽名方式和其他所有「平台呼叫廠商」的介面一致:X-Tenant-IDX-TimestampX-NonceX-Signature 四個請求頭,用你的廠商密鑰對 method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodyHMAC-SHA256X-Tenant-ID 帶的是你自己的廠商租戶 ID。

和你透過 ProviderClient 發出的請求不同,平台不會對這三條路徑的失敗呼叫做重試。 如果你的端點其實已經成功發放/取消了旋轉,但回應在傳輸過程中遺失(逾時、連線中斷),平台無從得知——它會在自己那一側把這次嘗試記錄為失敗,而且不會自動再試一次。請以 requestRef/externalRef(見下)為鍵來處理你這一側的邏輯,讓同一批次的後續呼叫可以安全地冪等處理,而不是假設每一次收到的呼叫都必然是第一次嘗試。

POST /v1/game/free-spins/grant

請求:

ts
type GrantFreeSpinsRequest = {
  requestRef: string // 平台這一側對此次發放的識別碼——見下方說明
  playerRef: string
  gameId: string
  spins: number
  betAmountMinor: number // 貨幣最小單位;0 表示「使用你自己的預設單注」
  currency: string
}

requestRef 是平台這一側對這批發放的本地識別碼——它本身並不保證你只會收到一次(見上方「不重試」的說明),所以如果你想在自己這一側做去重,應該以 requestRef 為鍵,而不是假設一次發放只會對應一次呼叫。

響應:成功回傳 200,並附上這批次的識別碼,後續 status/cancel 呼叫都會用到:

ts
type GrantFreeSpinsResponse = {
  externalRef: string
}

任何其他狀態碼都視為失敗——平台會在自己這一側把這筆發放標記為 failed(你的響應內容會被記進平台內部的錯誤日誌,所以回傳有意義的錯誤內容,對日後除錯的人會有幫助),且不會重試。

POST /v1/game/free-spins/status

請求:

ts
type FreeSpinsStatusRequest = {
  externalRef: string // 來自 grant 呼叫的響應
  playerRef: string
  gameId: string
}

響應:

ts
type FreeSpinsStatusResponse = {
  remainingSpins: number
  completed: boolean
}

這是拉取式查詢,不是推播——目前平台沒有任何地方會自動輪詢它,只會在有需要時(例如管理員查詢某一筆發放)被呼叫。

POST /v1/game/free-spins/cancel

請求:

ts
type CancelFreeSpinsRequest = {
  externalRef: string
  playerRef: string
  gameId: string
}

響應:成功回傳 200(內容可忽略,空的 body 也可以)。請在你這一側作廢這批次尚未使用的剩餘旋轉次數;玩家在取消送達前已經轉過的那幾次不受影響。

任何非 200 都視為失敗——和 Session 撤銷回呼不同(那邊 session 無論通知有沒有送達都已經被刪除了),如果這通呼叫失敗,平台不會把自己這一側的發放記錄標成已取消。 因為從平台的角度看,這批旋轉在你這一側可能仍然有效,所以它會刻意繼續維持原狀,直到取消真的成功為止。

範例(Node/Express)

ts
import { verifyPlatformSignature } from '@moosehq/provider-sdk'

function verifyOrReject(req: Request, res: Response): string | undefined {
  const result = verifyPlatformSignature({
    method: req.method,
    path: req.path,
    headers: req.headers,
    body: req.body,
    secret: process.env.PLATFORM_SECRET!,
    now: new Date(),
    expectedTenantId: 'acme-studio', // 你自己的廠商租戶 ID
  })
  if (!result.ok) {
    res.status(401).json({ error: result.reason })
    return undefined
  }
  return req.body
}

// req.body 必須是平台簽名時用的那個原始字串本身,所以這些路由
// 需要原始 body,不能用 JSON 解析中介軟體。
const rawBody = express.text({ type: '*/*' })

app.post('/v1/game/free-spins/grant', rawBody, async (req, res) => {
  const raw = verifyOrReject(req, res)
  if (!raw) return
  const { requestRef, playerRef, gameId, spins, betAmountMinor, currency } = JSON.parse(raw)

  const externalRef = await freeSpinsStore.grant({ requestRef, playerRef, gameId, spins, betAmountMinor, currency })
  res.json({ externalRef })
})

app.post('/v1/game/free-spins/status', rawBody, async (req, res) => {
  const raw = verifyOrReject(req, res)
  if (!raw) return
  const { externalRef } = JSON.parse(raw)

  const batch = await freeSpinsStore.get(externalRef)
  res.json({ remainingSpins: batch.remainingSpins, completed: batch.completed })
})

app.post('/v1/game/free-spins/cancel', rawBody, async (req, res) => {
  const raw = verifyOrReject(req, res)
  if (!raw) return
  const { externalRef } = JSON.parse(raw)

  await freeSpinsStore.cancelRemaining(externalRef)
  res.sendStatus(200)
})

免費旋轉贏得的彩金不屬於這個契約的一部分——請透過 POST /v1/wallet/transaction 送出正常的 WIN,和真錢旋轉的做法完全一樣(見錢包 API 參考)。這個回呼從頭到尾只處理「還剩幾次」這件事,不涉及金流。

verifyPlatformSignature 的完整簽名與錯誤原因,見 Session 撤銷回呼參考——是同一個函式,這裡直接複用。