單錢包 API · 服務端 Demo

本頁配套 單錢包 API 文檔裡 1-7 號「商戶回調 APIs」。這些端點由商戶自己實現,PetaX 主動調用;鑑權用 JWT 驗證指南描述的方案,驗證公鑰/JWKS 簽名與 iss/aud/exp/permissions claim。permissions claim 的具體形狀(本 demo 假定是字符串數組)官方文檔未給出示例 payload,接入時請與 PetaX 確認。本頁 Demo 只演示路由、鑑權、請求體和響應體的基本寫法;進程內餘額表只是讓示例可運行,不包含商戶自身的錢包、數據庫、冪等賬本、併發控制、錯誤碼映射或金額精度處理,生產接入需在商戶系統內自行實現。

PHP

依賴:composer require firebase/php-jwt。運行:php -S 0.0.0.0:8081 server.php

<?php
// server.php — PetaX single-wallet-api 商戶回調 Demo(PHP,無框架)
// 運行:php -S 0.0.0.0:8081 server.php
// 依賴:composer require firebase/php-jwt

require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

$JWKS_URL = getenv('PETAX_JWKS_URL') ?: 'https://your-petax-host.example/api/v2/auth/.well-known/jwks.json';
$ISSUER   = getenv('PETAX_ISSUER') ?: 'your-issuer';
$AUDIENCE = getenv('PETAX_AUDIENCE') ?: 'your-audience';

// 僅用於 Demo:進程內餘額表只是讓示例可運行,不代表商戶錢包、數據庫、transactionId 冪等、併發控制、錯誤碼映射或金額精度處理。
$balances = ['player1' => ['USDT' => 1000.0]];

// 獲取并緩存 PetaX 公共簽名密鑰(JWKS),緩存生命周期与當前進程一致,
// 避免每個入站請求都重新拉取。
function jwks_key_set(string $url): array {
    static $cache = null;
    if ($cache === null) {
        $json = file_get_contents($url);
        $cache = JWK::parseKeySet(json_decode($json, true));
    }
    return $cache;
}

// 在下方每個路由開頭調用。這裡實際完成 PetaX 入站請求鑑權:
// PetaX 會在 Authorization Header 中用 JWT 簽名每個回調,
// 商戶必須先完成驗證,才能信任請求體。
function require_permission(string $permission, string $jwksUrl, string $issuer, string $audience): array {
    // 1. 请求必須攜帶 "Authorization: Bearer <jwt>"。
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/^Bearer\s+(.+)$/', $header, $m)) {
        http_response_code(401);
        echo json_encode(['error' => 'missing bearer token']);
        exit;
    }
    // 2. JWT::decode 会用 PetaX 的 JWKS 公鑰驗證 RS256 簽名,
    //    并拒絕已過期 token(exp claim)。
    try {
        $decoded = JWT::decode($m[1], jwks_key_set($jwksUrl));
        $payload = (array) $decoded;
    } catch (Throwable $e) {
        http_response_code(401);
        echo json_encode(['error' => 'invalid token: ' . $e->getMessage()]);
        exit;
    }
    // 3. token 必須面向当前對接配置簽發(iss/aud)。
    if (($payload['iss'] ?? null) !== $issuer || ($payload['aud'] ?? null) !== $audience) {
        http_response_code(403);
        echo json_encode(['error' => 'iss/aud mismatch']);
        exit;
    }
    // 4. token 必須包含該端點要求的權限(例如 /bet 對應 swApi.bet),
    //    映射見 single-wallet-api.html。
    $perms = $payload['permissions'] ?? [];
    if (!in_array($permission, (array) $perms, true)) {
        http_response_code(403);
        echo json_encode(['error' => "missing permission: $permission"]);
        exit;
    }
    return $payload;
}

function read_json_body(): array {
    $raw = file_get_contents('php://input');
    return json_decode($raw, true) ?: [];
}

header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// 端點 1:GET /balances/{username}/{currency} —— PetaX 在動作前/後查詢玩家當前餘額。
if ($method === 'GET' && preg_match('#^/balances/([^/]+)/([^/]+)$#', $path, $m)) {
    require_permission('swApi.getBalance', $JWKS_URL, $ISSUER, $AUDIENCE);
    [$_, $username, $ccy] = $m;
    $balance = $balances[$username][$ccy] ?? 0.0;
    echo json_encode(['balance' => $balance, 'ccy' => $ccy, 'username' => $username]);
    exit;
}

// 端點 2:POST /bet —— PetaX 通知扣減本次下注金額。
if ($method === 'POST' && $path === '/bet') {
    require_permission('swApi.bet', $JWKS_URL, $ISSUER, $AUDIENCE);
    $b = read_json_body();
    $username = $b['username']; $ccy = $b['ccy']; $amount = (float) $b['amount'];
    $current = $balances[$username][$ccy] ?? 0.0;
    if ($current < $amount) {
        http_response_code(400);
        echo json_encode(['error' => 'insufficient balance']);
        exit;
    }
    $balances[$username][$ccy] = $current - $amount;
    echo json_encode(['balance' => $balances[$username][$ccy], 'betAmount' => $amount, 'ccy' => $ccy, 'username' => $username]);
    exit;
}

// 端點 3/4:POST /cancel-bet 和 /error-bet —— PetaX 要求退回已取消或出錯的下注;
// 兩者都只是把下注金額加回餘額。
if ($method === 'POST' && ($path === '/cancel-bet' || $path === '/error-bet')) {
    $permission = $path === '/cancel-bet' ? 'swApi.cancelBet' : 'swApi.errorBet';
    require_permission($permission, $JWKS_URL, $ISSUER, $AUDIENCE);
    $b = read_json_body();
    $username = $b['username']; $ccy = $b['ccy']; $amount = (float) $b['amount'];
    $current = $balances[$username][$ccy] ?? 0.0;
    $balances[$username][$ccy] = $current + $amount;
    echo json_encode(['balance' => $balances[$username][$ccy], 'ccy' => $ccy, 'username' => $username]);
    exit;
}

// 端點 5:POST /payout —— PetaX 通知本次下注中獎,需要入賬派彩金額。
if ($method === 'POST' && $path === '/payout') {
    require_permission('swApi.payout', $JWKS_URL, $ISSUER, $AUDIENCE);
    $b = read_json_body();
    $username = $b['username']; $ccy = $b['ccy']; $amount = (float) $b['amount'];
    $current = $balances[$username][$ccy] ?? 0.0;
    $balances[$username][$ccy] = $current + $amount;
    echo json_encode(['balance' => $balances[$username][$ccy], 'ccy' => $ccy, 'username' => $username]);
    exit;
}

// 端點 6(可選):POST /settled-ticket —— 僅通知 WON/LOST 狀態;
// 實際派彩仍会通过上方 /payout 單獨到達。
if ($method === 'POST' && $path === '/settled-ticket') {
    require_permission('swApi.bet', $JWKS_URL, $ISSUER, $AUDIENCE);
    read_json_body(); // 真實商戶可在這裡記錄 WON/LOST 信息;派彩仍会通过 /payout 到達。
    echo json_encode(['success' => true]);
    exit;
}

// 端點 7(可選):POST /settled-round —— 牌桌遊戲的局結算通知;
// 同樣只作信息通知。
if ($method === 'POST' && $path === '/settled-round') {
    require_permission('swApi.bet', $JWKS_URL, $ISSUER, $AUDIENCE);
    read_json_body();
    echo json_encode(['success' => true]);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'not found']);

示例:模擬 PetaX 的入站請求

啟動服務後,可以用 curl 模擬 PetaX 對「端點 2:POST /bet」發起的真實調用,把 <JWT> 換成一個真實簽發、permissions 裡帶 swApi.bet 的 token:

curl -X POST http://localhost:8081/bet \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"amount":100,"betTime":"2026-07-11T00:00:00Z","ccy":"USDT","gameCategory":"CASINO","gameType":"BCR","ticketId":"t1","transactionId":"tx1","username":"player1"}'

驗證通過時返回:

{"balance":900,"betAmount":100,"ccy":"USDT","username":"player1"}

本 demo 已在本機用自簽 JWT + 本機 JWKS server 真實跑通過上述請求/回應(含缺 token/錯簽名/權限不符的負向場景,均正確返回 401/403)。

Node.js

依賴:npm install express jsonwebtoken jwks-rsa。運行:node server.js

// server.js — PetaX single-wallet-api 商戶回調 Demo(Node.js/Express)
// 運行:npm install express jsonwebtoken jwks-rsa && node server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const JWKS_URL = process.env.PETAX_JWKS_URL || 'https://your-petax-host.example/api/v2/auth/.well-known/jwks.json';
const ISSUER = process.env.PETAX_ISSUER || 'your-issuer';
const AUDIENCE = process.env.PETAX_AUDIENCE || 'your-audience';
const PORT = process.env.PORT || 8082;

// jwks-rsa 会按 kid(key ID)獲取并緩存 PetaX 公共簽名密鑰,
// jsonwebtoken 会用匹配的密钥驗證每個 token 的簽名。
const jwks = jwksClient({ jwksUri: JWKS_URL });
function getKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

// 這個 Express 中間件會在下方每個路由前運行。這裡實際完成 PetaX 入站請求鑑權:
// PetaX 會在 Authorization Header 中用 JWT 簽名每個回調,
// 我們必須先驗證簽名、issuer、audience、過期時間和 permission,
// 才能信任請求體。
function requirePermission(permission) {
  return (req, res, next) => {
    // 1. 请求必須攜帶 "Authorization: Bearer <jwt>"。
    const auth = req.headers.authorization || '';
    const match = /^Bearer\s+(.+)$/.exec(auth);
    if (!match) return res.status(401).json({ error: 'missing bearer token' });
    // 2. jwt.verify 会用 JWKS key 驗證 RS256 簽名,
    //    同時校驗 iss/aud claims,并拒絕已過期 token。
    jwt.verify(match[1], getKey, { algorithms: ['RS256'], issuer: ISSUER, audience: AUDIENCE }, (err, payload) => {
      if (err) return res.status(401).json({ error: `invalid token: ${err.message}` });
      // 3. token 必須包含該端點要求的權限(例如 /bet 對應 swApi.bet),
      //    映射見 single-wallet-api.html。
      const perms = payload.permissions || [];
      if (!perms.includes(permission)) return res.status(403).json({ error: `missing permission: ${permission}` });
      next();
    });
  };
}

// 僅用於 Demo:進程內餘額表只是讓示例可運行,不代表商戶錢包、數據庫、transactionId 冪等、併發控制、錯誤碼映射或金額精度處理。
const balances = { player1: { USDT: 1000.0 } };

const app = express();
app.use(express.json());

// 端點 1:GET /balances/:username/:currency —— PetaX 在動作前/後查詢玩家當前餘額。
app.get('/balances/:username/:currency', requirePermission('swApi.getBalance'), (req, res) => {
  const { username, currency } = req.params;
  const balance = (balances[username] || {})[currency] ?? 0;
  res.json({ balance, ccy: currency, username });
});

// 端點 2:POST /bet —— PetaX 通知扣減本次下注金額。
app.post('/bet', requirePermission('swApi.bet'), (req, res) => {
  const { username, ccy, amount } = req.body;
  const current = (balances[username] || {})[ccy] ?? 0;
  if (current < amount) return res.status(400).json({ error: 'insufficient balance' });
  balances[username] = balances[username] || {};
  balances[username][ccy] = current - amount;
  res.json({ balance: balances[username][ccy], betAmount: amount, ccy, username });
});

// 端點 3/4:POST /cancel-bet 和 /error-bet —— PetaX 要求退回已取消或出錯的下注;
// 兩者都只是把下注金額加回餘額。
function refund(req, res) {
  const { username, ccy, amount } = req.body;
  balances[username] = balances[username] || {};
  const current = balances[username][ccy] ?? 0;
  balances[username][ccy] = current + amount;
  res.json({ balance: balances[username][ccy], ccy, username });
}
app.post('/cancel-bet', requirePermission('swApi.cancelBet'), refund);
app.post('/error-bet', requirePermission('swApi.errorBet'), refund);

// 端點 5:POST /payout —— PetaX 通知本次下注中獎,需要入賬派彩金額。
app.post('/payout', requirePermission('swApi.payout'), (req, res) => {
  const { username, ccy, amount } = req.body;
  balances[username] = balances[username] || {};
  const current = balances[username][ccy] ?? 0;
  balances[username][ccy] = current + amount;
  res.json({ balance: balances[username][ccy], ccy, username });
});

// 端點 6(可選):POST /settled-ticket —— 僅通知 WON/LOST 狀態;
// 實際派彩仍会通过上方 /payout 單獨到達。
app.post('/settled-ticket', requirePermission('swApi.bet'), (req, res) => {
  // 真實商戶可在這裡記錄 WON/LOST 信息;派彩仍会通过 /payout 到達。
  res.json({ success: true });
});

// 端點 7(可選):POST /settled-round —— 牌桌遊戲的局結算通知;
// 同樣只作信息通知。
app.post('/settled-round', requirePermission('swApi.bet'), (req, res) => {
  res.json({ success: true });
});

app.listen(PORT, () => console.log(`listening on :${PORT}`));

示例:模擬 PetaX 的入站請求

啟動服務後,可以用 curl 模擬 PetaX 對「端點 2:POST /bet」發起的真實調用,把 <JWT> 換成一個真實簽發、permissions 裡帶 swApi.bet 的 token:

curl -X POST http://localhost:8082/bet \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"amount":100,"betTime":"2026-07-11T00:00:00Z","ccy":"USDT","gameCategory":"CASINO","gameType":"BCR","ticketId":"t1","transactionId":"tx1","username":"player1"}'

驗證通過時返回:

{"balance":900,"betAmount":100,"ccy":"USDT","username":"player1"}

本 demo 已在本機用自簽 JWT + 本機 JWKS server 真實跑通過上述請求/回應(含缺 token/錯簽名/權限不符的負向場景,均正確返回 401/403)。

Java

依賴:Maven + pom.xml(見下)。運行:mvn spring-boot:run

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example.petax</groupId>
  <artifactId>single-wallet-webhook-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
  </parent>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>com.nimbusds</groupId>
      <artifactId>nimbus-jose-jwt</artifactId>
      <version>9.40</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>
package com.example.petax;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

@SpringBootApplication
@RestController
public class WebhookDemoApplication {

    private static final String JWKS_URL = System.getenv().getOrDefault(
            "PETAX_JWKS_URL", "https://your-petax-host.example/api/v2/auth/.well-known/jwks.json");
    private static final String ISSUER = System.getenv().getOrDefault("PETAX_ISSUER", "your-issuer");
    private static final String AUDIENCE = System.getenv().getOrDefault("PETAX_AUDIENCE", "your-audience");

    // 僅用於 Demo:進程內餘額表只是讓示例可運行,不代表商戶錢包、數據庫、transactionId 冪等、併發控制、錯誤碼映射或金額精度處理。
    private final Map<String, Map<String, Double>> balances = new ConcurrentHashMap<>();
    private final DefaultJWTProcessor<SecurityContext> jwtProcessor = buildJwtProcessor();

    public WebhookDemoApplication() {
        balances.put("player1", new ConcurrentHashMap<>(Map.of("USDT", 1000.0)));
    }

    // 構建 JWT processor:獲取并緩存 PetaX 公共簽名密鑰(JWKS),
    // 並用它们驗證每個 token 的 RS256 簽名。
    private static DefaultJWTProcessor<SecurityContext> buildJwtProcessor() {
        try {
            var processor = new DefaultJWTProcessor<SecurityContext>();
            var jwkSource = JWKSourceBuilder.create(new URL(JWKS_URL)).build();
            processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
            return processor;
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }
    }

    // 在下方每個 handler 開頭調用。這裡實際完成 PetaX 入站請求鑑權:
    // PetaX 會在 Authorization Header 中用 JWT 簽名每個回調,
    // 我們必須先驗證簽名、issuer、audience、過期時間和 permission,
    // 才能信任請求體。
    private JWTClaimsSet requirePermission(String authHeader, String permission) {
        // 1. 请求必須攜帶 "Authorization: Bearer <jwt>"。
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new ApiError(HttpStatus.UNAUTHORIZED, "missing bearer token");
        }
        JWTClaimsSet claims;
        try {
            // 2. jwtProcessor.process 默认会通过 JWKS 驗證 RS256 簽名,
            //    并拒絕已過期 token(exp claim)。
            claims = jwtProcessor.process(authHeader.substring(7), null);
        } catch (Exception e) {
            throw new ApiError(HttpStatus.UNAUTHORIZED, "invalid token: " + e.getMessage());
        }
        // 3. token 必須面向当前對接配置簽發(iss/aud)。
        if (!Objects.equals(claims.getIssuer(), ISSUER) || claims.getAudience() == null || !claims.getAudience().contains(AUDIENCE)) {
            throw new ApiError(HttpStatus.FORBIDDEN, "iss/aud mismatch");
        }
        // 4. token 必須包含該端點要求的權限(例如 /bet 對應 swApi.bet),
        //    映射見 single-wallet-api.html。
        @SuppressWarnings("unchecked")
        List<String> perms = (List<String>) claims.getClaim("permissions");
        if (perms == null || !perms.contains(permission)) {
            throw new ApiError(HttpStatus.FORBIDDEN, "missing permission: " + permission);
        }
        return claims;
    }

    // 端點 1:GET /balances/{username}/{currency} —— PetaX 在動作前/後查詢玩家當前餘額。
    @GetMapping("/balances/{username}/{currency}")
    public Map<String, Object> getBalance(@RequestHeader(value = "Authorization", required = false) String auth,
                                           @PathVariable String username, @PathVariable String currency) {
        requirePermission(auth, "swApi.getBalance");
        double balance = balances.getOrDefault(username, Map.of()).getOrDefault(currency, 0.0);
        return Map.of("balance", balance, "ccy", currency, "username", username);
    }

    // 端點 2:POST /bet —— PetaX 通知扣減本次下注金額。
    @PostMapping("/bet")
    public Map<String, Object> bet(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.bet");
        String username = (String) body.get("username");
        String ccy = (String) body.get("ccy");
        double amount = ((Number) body.get("amount")).doubleValue();
        var userBalances = balances.computeIfAbsent(username, k -> new ConcurrentHashMap<>());
        double current = userBalances.getOrDefault(ccy, 0.0);
        if (current < amount) throw new ApiError(HttpStatus.BAD_REQUEST, "insufficient balance");
        userBalances.put(ccy, current - amount);
        return Map.of("balance", userBalances.get(ccy), "betAmount", amount, "ccy", ccy, "username", username);
    }

    // 端點 3/4:POST /cancel-bet 和 /error-bet —— PetaX 要求退回已取消或出錯的下注;
    // 兩者都會通过下方共用的 refund() helper 把下注金額加回餘額。
    @PostMapping("/cancel-bet")
    public Map<String, Object> cancelBet(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.cancelBet");
        return refund(body);
    }

    @PostMapping("/error-bet")
    public Map<String, Object> errorBet(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.errorBet");
        return refund(body);
    }

    // 端點 5:POST /payout —— PetaX 通知本次下注中獎,需要入賬派彩金額。
    @PostMapping("/payout")
    public Map<String, Object> payout(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.payout");
        return refund(body);
    }

    private Map<String, Object> refund(Map<String, Object> body) {
        String username = (String) body.get("username");
        String ccy = (String) body.get("ccy");
        double amount = ((Number) body.get("amount")).doubleValue();
        var userBalances = balances.computeIfAbsent(username, k -> new ConcurrentHashMap<>());
        double current = userBalances.getOrDefault(ccy, 0.0);
        userBalances.put(ccy, current + amount);
        return Map.of("balance", userBalances.get(ccy), "ccy", ccy, "username", username);
    }

    // 端點 6(可選):POST /settled-ticket —— 僅通知 WON/LOST 狀態;
    // 實際派彩仍会通过上方 /payout 單獨到達。
    @PostMapping("/settled-ticket")
    public Map<String, Object> settledTicket(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.bet");
        // 真實商戶可在這裡記錄 WON/LOST 信息;派彩仍会通过 /payout 到達。
        return Map.of("success", true);
    }

    // 端點 7(可選):POST /settled-round —— 牌桌遊戲的局結算通知;
    // 同樣只作信息通知。
    @PostMapping("/settled-round")
    public Map<String, Object> settledRound(@RequestHeader(value = "Authorization", required = false) String auth, @RequestBody Map<String, Object> body) {
        requirePermission(auth, "swApi.bet");
        return Map.of("success", true);
    }

    @ExceptionHandler(ApiError.class)
    public ResponseEntity<Map<String, String>> handleApiError(ApiError e) {
        return ResponseEntity.status(e.status).body(Map.of("error", e.getMessage()));
    }

    static class ApiError extends RuntimeException {
        final HttpStatus status;
        ApiError(HttpStatus status, String message) { super(message); this.status = status; }
    }

    public static void main(String[] args) {
        SpringApplication.run(WebhookDemoApplication.class, args);
    }
}

示例:模擬 PetaX 的入站請求

啟動服務後,可以用 curl 模擬 PetaX 對「端點 2:POST /bet」發起的真實調用,把 <JWT> 換成一個真實簽發、permissions 裡帶 swApi.bet 的 token:

curl -X POST http://localhost:8080/bet \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"amount":100,"betTime":"2026-07-11T00:00:00Z","ccy":"USDT","gameCategory":"CASINO","gameType":"BCR","ticketId":"t1","transactionId":"tx1","username":"player1"}'

驗證通過時返回:

{"balance":900,"betAmount":100,"ccy":"USDT","username":"player1"}

本 demo 已在本機用自簽 JWT + 本機 JWKS server 真實跑通過上述請求/回應(含缺 token/錯簽名/權限不符的負向場景,均正確返回 401/403)。

Go

依賴:go get github.com/golang-jwt/jwt/v5 github.com/MicahParks/keyfunc/v3。運行:go run server.go

// server.go — PetaX single-wallet-api 商戶回調 Demo(Go,net/http)
// 運行:go mod init demo && go get github.com/golang-jwt/jwt/v5 github.com/MicahParks/keyfunc/v3 && go run server.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
	"strings"
	"sync"

	"github.com/MicahParks/keyfunc/v3"
	"github.com/golang-jwt/jwt/v5"
)

var (
	jwksURL  = getenv("PETAX_JWKS_URL", "https://your-petax-host.example/api/v2/auth/.well-known/jwks.json")
	issuer   = getenv("PETAX_ISSUER", "your-issuer")
	audience = getenv("PETAX_AUDIENCE", "your-audience")
	port     = getenv("PORT", "8083")

	mu sync.Mutex
	// 僅用於 Demo:進程內餘額表只是讓示例可運行,不代表商戶錢包、數據庫、transactionId 冪等、併發控制、錯誤碼映射或金額精度處理。
	balances = map[string]map[string]float64{"player1": {"USDT": 1000}}
)

func getenv(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

// requirePermission 會在下方每個 handler 開頭調用。這裡實際完成 PetaX 入站請求鑑權:
// PetaX 會在 Authorization Header 中用 JWT 簽名每個回調,
// 我們必須先驗證簽名、issuer、audience、過期時間和 permission,
// 才能信任請求體。
func requirePermission(w http.ResponseWriter, r *http.Request, permission string) (jwt.MapClaims, bool) {
	// 1. 请求必須攜帶 "Authorization: Bearer <jwt>"。
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer token"})
		return nil, false
	}
	tokenStr := strings.TrimPrefix(auth, "Bearer ")

	// 2. keyfunc 獲取 PetaX 公共簽名密鑰(JWKS),使 jwt.Parse 能驗證 token 的 RS256 簽名;
	//    WithIssuer/WithAudience 同時校驗 iss/aud,過期時間会自動校驗。
	kf, err := keyfunc.NewDefaultCtx(r.Context(), []string{jwksURL})
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "jwks fetch failed: " + err.Error()})
		return nil, false
	}
	token, err := jwt.Parse(tokenStr, kf.Keyfunc,
		jwt.WithIssuer(issuer), jwt.WithAudience(audience), jwt.WithValidMethods([]string{"RS256"}))
	if err != nil || !token.Valid {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
		return nil, false
	}
	// 3. token 必須包含該端點要求的權限(例如 /bet 對應 swApi.bet),
	//    映射見 single-wallet-api.html。
	claims := token.Claims.(jwt.MapClaims)
	perms, _ := claims["permissions"].([]interface{})
	ok := false
	for _, p := range perms {
		if p == permission {
			ok = true
			break
		}
	}
	if !ok {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "missing permission: " + permission})
		return nil, false
	}
	return claims, true
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func readBody(r *http.Request) map[string]interface{} {
	var body map[string]interface{}
	json.NewDecoder(r.Body).Decode(&body)
	return body
}

func balanceOf(username, ccy string) float64 {
	if m, ok := balances[username]; ok {
		return m[ccy]
	}
	return 0
}

func setBalance(username, ccy string, v float64) {
	if balances[username] == nil {
		balances[username] = map[string]float64{}
	}
	balances[username][ccy] = v
}

// 端點 1:GET /balances/{username}/{ccy} —— PetaX 在動作前/後查詢玩家當前餘額。
func handleGetBalance(w http.ResponseWriter, r *http.Request) {
	if _, ok := requirePermission(w, r, "swApi.getBalance"); !ok {
		return
	}
	username := r.PathValue("username")
	ccy := r.PathValue("ccy")
	mu.Lock()
	defer mu.Unlock()
	writeJSON(w, http.StatusOK, map[string]interface{}{"balance": balanceOf(username, ccy), "ccy": ccy, "username": username})
}

// 端點 2:POST /bet —— PetaX 通知扣減本次下注金額。
func handleBet(w http.ResponseWriter, r *http.Request) {
	if _, ok := requirePermission(w, r, "swApi.bet"); !ok {
		return
	}
	b := readBody(r)
	username, _ := b["username"].(string)
	ccy, _ := b["ccy"].(string)
	amount, _ := b["amount"].(float64)
	mu.Lock()
	defer mu.Unlock()
	current := balanceOf(username, ccy)
	if current < amount {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "insufficient balance"})
		return
	}
	setBalance(username, ccy, current-amount)
	writeJSON(w, http.StatusOK, map[string]interface{}{"balance": balanceOf(username, ccy), "betAmount": amount, "ccy": ccy, "username": username})
}

// 端點 3/4:POST /cancel-bet 和 /error-bet —— PetaX 要求退回已取消或出錯的下注;
// 兩者都只是把下注金額加回餘額。
func handleRefund(permission string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if _, ok := requirePermission(w, r, permission); !ok {
			return
		}
		b := readBody(r)
		username, _ := b["username"].(string)
		ccy, _ := b["ccy"].(string)
		amount, _ := b["amount"].(float64)
		mu.Lock()
		defer mu.Unlock()
		setBalance(username, ccy, balanceOf(username, ccy)+amount)
		writeJSON(w, http.StatusOK, map[string]interface{}{"balance": balanceOf(username, ccy), "ccy": ccy, "username": username})
	}
}

// 端點 6/7(可選):POST /settled-ticket 和 /settled-round —— WON/LOST 与局結算通知;
// 仅作信息通知,實際派彩仍会通过上方 /payout 單獨到達。
func handleSettled(w http.ResponseWriter, r *http.Request) {
	if _, ok := requirePermission(w, r, "swApi.bet"); !ok {
		return
	}
	readBody(r) // 真實商戶可在這裡記錄 WON/LOST 或局結算信息。
	writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /balances/{username}/{ccy}", handleGetBalance)
	mux.HandleFunc("POST /bet", handleBet)
	mux.HandleFunc("POST /cancel-bet", handleRefund("swApi.cancelBet"))
	mux.HandleFunc("POST /error-bet", handleRefund("swApi.errorBet"))
	// 端點 5:POST /payout —— PetaX 通知本次下注中獎,需要入賬派彩金額。
	mux.HandleFunc("POST /payout", handleRefund("swApi.payout"))
	mux.HandleFunc("POST /settled-ticket", handleSettled)
	mux.HandleFunc("POST /settled-round", handleSettled)
	log.Printf("listening on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}

示例:模擬 PetaX 的入站請求

啟動服務後,可以用 curl 模擬 PetaX 對「端點 2:POST /bet」發起的真實調用,把 <JWT> 換成一個真實簽發、permissions 裡帶 swApi.bet 的 token:

curl -X POST http://localhost:8083/bet \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"amount":100,"betTime":"2026-07-11T00:00:00Z","ccy":"USDT","gameCategory":"CASINO","gameType":"BCR","ticketId":"t1","transactionId":"tx1","username":"player1"}'

驗證通過時返回:

{"balance":900,"betAmount":100,"ccy":"USDT","username":"player1"}

本 demo 已在本機用自簽 JWT + 本機 JWKS server 真實跑通過上述請求/回應(含缺 token/錯簽名/權限不符的負向場景,均正確返回 401/403)。

Python

依賴:pip install flask pyjwt cryptography。運行:python3 server.py

# server.py — PetaX single-wallet-api 商戶回調 Demo(Python/Flask)
# 運行:pip install flask pyjwt requests cryptography && python3 server.py
import os
import threading

import jwt
from flask import Flask, jsonify, request

JWKS_URL = os.environ.get("PETAX_JWKS_URL", "https://your-petax-host.example/api/v2/auth/.well-known/jwks.json")
ISSUER = os.environ.get("PETAX_ISSUER", "your-issuer")
AUDIENCE = os.environ.get("PETAX_AUDIENCE", "your-audience")
PORT = int(os.environ.get("PORT", "8084"))

app = Flask(__name__)

# 僅用於 Demo:進程內餘額表只是讓示例可運行,不代表商戶錢包、數據庫、transactionId 冪等、併發控制、錯誤碼映射或金額精度處理。
balances = {"player1": {"USDT": 1000.0}}
lock = threading.Lock()

# PyJWKClient 会獲取并緩存 PetaX 公共簽名密鑰(JWKS),緩存生命周期与當前進程一致。
_jwk_client = jwt.PyJWKClient(JWKS_URL)


# 在下方每個路由開頭調用。這裡實際完成 PetaX 入站請求鑑權:
# PetaX 會在 Authorization Header 中用 JWT 簽名每個回調,
# 我們必須先驗證簽名、issuer、audience、過期時間和 permission,才能信任請求體。
def require_permission(permission):
    # 1. 请求必須攜帶 "Authorization: Bearer <jwt>"。
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return None, (jsonify(error="missing bearer token"), 401)
    token = auth[len("Bearer "):]
    try:
        # 2. jwt.decode 会用 JWKS key 驗證 RS256 簽名,
        #    同時校驗 iss/aud claims,并拒絕已過期 token。
        signing_key = _jwk_client.get_signing_key_from_jwt(token)
        payload = jwt.decode(token, signing_key.key, algorithms=["RS256"], issuer=ISSUER, audience=AUDIENCE)
    except jwt.PyJWTError as e:
        return None, (jsonify(error=f"invalid token: {e}"), 401)
    # 3. token 必須包含該端點要求的權限(例如 /bet 對應 swApi.bet),
    #    映射見 single-wallet-api.html。
    if permission not in (payload.get("permissions") or []):
        return None, (jsonify(error=f"missing permission: {permission}"), 403)
    return payload, None


# 端點 1:GET /balances/<username>/<currency> —— PetaX 在動作前/後查詢玩家當前餘額。
@app.get("/balances/<username>/<currency>")
def get_balance(username, currency):
    _, err = require_permission("swApi.getBalance")
    if err:
        return err
    with lock:
        balance = balances.get(username, {}).get(currency, 0.0)
    return jsonify(balance=balance, ccy=currency, username=username)


# 端點 2:POST /bet —— PetaX 通知扣減本次下注金額。
@app.post("/bet")
def bet():
    _, err = require_permission("swApi.bet")
    if err:
        return err
    body = request.get_json()
    username, ccy, amount = body["username"], body["ccy"], float(body["amount"])
    with lock:
        current = balances.get(username, {}).get(ccy, 0.0)
        if current < amount:
            return jsonify(error="insufficient balance"), 400
        balances.setdefault(username, {})[ccy] = current - amount
        new_balance = balances[username][ccy]
    return jsonify(balance=new_balance, betAmount=amount, ccy=ccy, username=username)


# 端點 3/4:/cancel-bet 和 /error-bet —— PetaX 要求退回已取消或出錯的下注;
# 兩者都只是把下注金額加回餘額。
def _refund(permission):
    _, err = require_permission(permission)
    if err:
        return err
    body = request.get_json()
    username, ccy, amount = body["username"], body["ccy"], float(body["amount"])
    with lock:
        current = balances.get(username, {}).get(ccy, 0.0)
        balances.setdefault(username, {})[ccy] = current + amount
        new_balance = balances[username][ccy]
    return jsonify(balance=new_balance, ccy=ccy, username=username)


@app.post("/cancel-bet")
def cancel_bet():
    return _refund("swApi.cancelBet")


@app.post("/error-bet")
def error_bet():
    return _refund("swApi.errorBet")


# 端點 5:POST /payout —— PetaX 通知本次下注中獎,需要入賬派彩金額。
@app.post("/payout")
def payout():
    return _refund("swApi.payout")


# 端點 6(可選):POST /settled-ticket —— 僅通知 WON/LOST 狀態;
# 實際派彩仍会通过上方 /payout 單獨到達。
@app.post("/settled-ticket")
def settled_ticket():
    _, err = require_permission("swApi.bet")
    if err:
        return err
    request.get_json()  # 真實商戶可在這裡記錄 WON/LOST 信息;派彩仍会通过 /payout 到達。
    return jsonify(success=True)


# 端點 7(可選):POST /settled-round —— 牌桌遊戲的局結算通知;
# 同樣只作信息通知。
@app.post("/settled-round")
def settled_round():
    _, err = require_permission("swApi.bet")
    if err:
        return err
    request.get_json()
    return jsonify(success=True)


if __name__ == "__main__":
    app.run(port=PORT)

示例:模擬 PetaX 的入站請求

啟動服務後,可以用 curl 模擬 PetaX 對「端點 2:POST /bet」發起的真實調用,把 <JWT> 換成一個真實簽發、permissions 裡帶 swApi.bet 的 token:

curl -X POST http://localhost:8084/bet \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"amount":100,"betTime":"2026-07-11T00:00:00Z","ccy":"USDT","gameCategory":"CASINO","gameType":"BCR","ticketId":"t1","transactionId":"tx1","username":"player1"}'

驗證通過時返回:

{"balance":900,"betAmount":100,"ccy":"USDT","username":"player1"}

本 demo 已在本機用自簽 JWT + 本機 JWKS server 真實跑通過上述請求/回應(含缺 token/錯簽名/權限不符的負向場景,均正確返回 401/403)。