单钱包 API · 服务端 Demo
本页配套 单钱包 API 文档里 1-7 号"商户回调 APIs"。这些端点由商户自己实现,PetaX 主动调用;鉴权用 JWT 验证指南描述的方案,验证公钥/JWKS 签名与iss/aud/exp/permissionsclaim。permissionsclaim 的具体形状(本 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)。