免費旋轉回呼
平台 → 廠商回呼之一,和 Session 撤銷回呼一樣,這裡的方向是反過來的:由平台呼叫你的伺服端,不是你呼叫平台。當免費旋轉促銷工具——不論是後台管理員操作,還是運營商自己簽名呼叫自助 API——要為你某個遊戲的玩家發放、查詢或取消一批免費旋轉時,就會觸發這裡的呼叫。免費旋轉的執行與遊戲數學完全留在你這一側:平台只會告訴你「發放/查詢/取消一批」,自己只留一份用於稽核與冪等的本地記錄,僅此而已。
請實作下面三條路徑(精確路徑,位於你的對接窗口為你註冊的 baseUrl 上——和你的 Session 撤銷回呼是同一個 baseUrl),並在信任請求內容之前,用 @moose/provider-sdk 的 verifyPlatformSignature 驗證每一次呼叫,做法和 Session 撤銷回呼完全一樣。
簽名
簽名方式和其他所有「平台呼叫廠商」的介面一致:X-Tenant-ID、X-Timestamp、X-Nonce、X-Signature 四個請求頭,用你的廠商密鑰對 method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body 做 HMAC-SHA256。X-Tenant-ID 帶的是你自己的廠商租戶 ID。
和你透過 ProviderClient 發出的請求不同,平台不會對這三條路徑的失敗呼叫做重試。 如果你的端點其實已經成功發放/取消了旋轉,但回應在傳輸過程中遺失(逾時、連線中斷),平台無從得知——它會在自己那一側把這次嘗試記錄為失敗,而且不會自動再試一次。請以 requestRef/externalRef(見下)為鍵來處理你這一側的邏輯,讓同一批次的後續呼叫可以安全地冪等處理,而不是假設每一次收到的呼叫都必然是第一次嘗試。
POST /v1/game/free-spins/grant
請求:
type GrantFreeSpinsRequest = {
requestRef: string // 平台這一側對此次發放的識別碼——見下方說明
playerRef: string
gameId: string
spins: number
betAmountMinor: number // 貨幣最小單位;0 表示「使用你自己的預設單注」
currency: string
}requestRef 是平台這一側對這批發放的本地識別碼——它本身並不保證你只會收到一次(見上方「不重試」的說明),所以如果你想在自己這一側做去重,應該以 requestRef 為鍵,而不是假設一次發放只會對應一次呼叫。
響應:成功回傳 200,並附上這批次的識別碼,後續 status/cancel 呼叫都會用到:
type GrantFreeSpinsResponse = {
externalRef: string
}任何其他狀態碼都視為失敗——平台會在自己這一側把這筆發放標記為 failed(你的響應內容會被記進平台內部的錯誤日誌,所以回傳有意義的錯誤內容,對日後除錯的人會有幫助),且不會重試。
POST /v1/game/free-spins/status
請求:
type FreeSpinsStatusRequest = {
externalRef: string // 來自 grant 呼叫的響應
playerRef: string
gameId: string
}響應:
type FreeSpinsStatusResponse = {
remainingSpins: number
completed: boolean
}這是拉取式查詢,不是推播——目前平台沒有任何地方會自動輪詢它,只會在有需要時(例如管理員查詢某一筆發放)被呼叫。
POST /v1/game/free-spins/cancel
請求:
type CancelFreeSpinsRequest = {
externalRef: string
playerRef: string
gameId: string
}響應:成功回傳 200(內容可忽略,空的 body 也可以)。請在你這一側作廢這批次尚未使用的剩餘旋轉次數;玩家在取消送達前已經轉過的那幾次不受影響。
任何非 200 都視為失敗——和 Session 撤銷回呼不同(那邊 session 無論通知有沒有送達都已經被刪除了),如果這通呼叫失敗,平台不會把自己這一側的發放記錄標成已取消。 因為從平台的角度看,這批旋轉在你這一側可能仍然有效,所以它會刻意繼續維持原狀,直到取消真的成功為止。
範例(Node/Express)
import { verifyPlatformSignature } from '@moose/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 撤銷回呼參考——是同一個函式,這裡直接複用。