单钱包 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';

// 这三项来自 PetaX 为本次对接分配的鉴权配置;不要把生产真实值写死在示例代码或仓库里。
// JWKS_URL 用来拿验签公钥,ISSUER/AUDIENCE 用来确认 token 属于当前这次商户接入。

// 仅用于 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。
    //    这里按字符串数组读取 permissions;若 PetaX 实际 payload 不同,需要按真实 claim 形状调整。
    $perms = $payload['permissions'] ?? [];
    if (!in_array($permission, (array) $perms, true)) {
        http_response_code(403);
        echo json_encode(['error' => "missing permission: $permission"]);
        exit;
    }
    return $payload;
}

// Demo 只负责读取 JSON body;生产实现应在这里或业务层补充字段必填、类型、金额精度和 transactionId 幂等校验。
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;

// 这三项来自 PetaX 为本次对接分配的鉴权配置;不要把生产真实值写死在示例代码或仓库里。
// JWKS_URL 用来拿验签公钥,ISSUER/AUDIENCE 用来确认 token 属于当前这次商户接入。

// 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。
      //    这里按字符串数组读取 permissions;若 PetaX 实际 payload 不同,需要按真实 claim 形状调整。
      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();
// Demo 只启用 JSON 解析;生产实现还应补充请求体大小限制、字段结构校验和请求日志。
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");

    // 这三项来自 PetaX 为本次对接分配的鉴权配置;不要把生产真实值写死在示例代码或仓库里。
    // JWKS_URL 用来拿验签公钥,ISSUER/AUDIENCE 用来确认 token 属于当前这次商户接入。

    // 仅用于 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。
        //    这里按字符串数组读取 permissions;若 PetaX 实际 payload 不同,需要按真实 claim 形状调整。
        @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");
        // Demo 直接从 Map 取字段;生产实现应补充必填、类型、金额精度和 transactionId 幂等校验。
        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);
    }

    // cancel-bet、error-bet 和 payout 在 demo 里共用加余额逻辑;生产应按 transactionId/refId 做幂等和账本记录。
    private Map<String, Object> refund(Map<String, Object> body) {
        // Demo 直接从 Map 取字段;生产实现应补充必填、类型、金额精度和 transactionId 幂等校验。
        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")

	// 这三项来自 PetaX 为本次对接分配的鉴权配置;不要把生产真实值写死在示例代码或仓库里。
	// jwksURL 用来拿验签公钥,issuer/audience 用来确认 token 属于当前这次商户接入。

	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。
	//    这里按字符串数组读取 permissions;若 PetaX 实际 payload 不同,需要按真实 claim 形状调整。
	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)
}

// Demo 只负责读取 JSON body;生产实现应补充字段必填、类型、金额精度和 transactionId 幂等校验。
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)
	// Demo 直接从 map 取字段;生产实现应对 username/ccy/amount/transactionId 做严格校验。
	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)
		// Demo 直接从 map 取字段;生产实现应对 username/ccy/amount/transactionId 做严格校验。
	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"))

# 这三项来自 PetaX 为本次对接分配的鉴权配置;不要把生产真实值写死在示例代码或仓库里。
# JWKS_URL 用来拿验签公钥,ISSUER/AUDIENCE 用来确认 token 属于当前这次商户接入。

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。
    #    这里按字符串数组读取 permissions;若 PetaX 实际 payload 不同,需要按真实 claim 形状调整。
    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()
    # Demo 直接从 dict 取字段;生产实现应补充必填、类型、金额精度和 transactionId 幂等校验。
    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 要求退回已取消或出错的下注;
# 两者都只是把下注金额加回余额。
# cancel-bet、error-bet 和 payout 在 demo 里共用加余额逻辑;生产应按 transactionId/refId 做幂等和账本记录。
def _refund(permission):
    _, err = require_permission(permission)
    if err:
        return err
    body = request.get_json()
    # Demo 直接从 dict 取字段;生产实现应补充必填、类型、金额精度和 transactionId 幂等校验。
    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)。