GoodRoad API Reference

GoodRoad 后端接口文档

这份文档详细说明 GoodRoad 插件与后端交互的所有 API。 第三方在接入时,需要在自己的后端实现这些接口,或者代理到我们的接口服务上。

接口概览

GoodRoad 分为四个主要阶段的接口调用:

阶段 接口 方法 说明
1. 初始化 POST /plugin/v1/init-ticket POST 创建一次性初始化票据
2. 会话交换 POST /plugin/v1/session/exchange POST 将 init_ticket 兑换为会话令牌
3. 获取快照 GET /plugin/v1/tables/snapshot GET 获取桌台列表及路盘数据
4. 进入游戏 POST /plugin/v1/game/enter POST 请求进入指定桌台
接口调用流程是链式的:先 init-ticket → exchange → snapshot → enter。 除 init-ticket 外,后续接口都需要有效的 session_token。

认证与签名

签名校验(可选)

如果后端启用了 GOODROAD_CHANNEL_SECRET 环境变量,所有请求都需要包含签名头。

签名算法

signText = "{channelId}|{timestamp}|{nonce}"
signature = HMAC-SHA256(signText, secret).hex()

必需头部

  • x-channel-id: 渠道号
  • x-timestamp: 当前 Unix 时间戳(秒),允许偏差 ±300 秒
  • x-nonce: 随机字符串,每个时间窗口内一次性使用
  • x-signature: 上述签名结果

域名白名单(可选)

如果配置了 GOODROAD_CHANNEL_DOMAIN_MAP,请求时需要在 domain 字段里传当前页面域名,后端会校验是否在该 channel 的白名单内。

POST init-ticket

/plugin/v1/init-ticket

创建一次性初始化票据。返回的 init_ticket 用于后续的 session/exchange 阶段。

请求字段

字段类型必需说明
channel_idstring渠道号
domainstring当前页面域名或来源(可选校验)
user_typestring'member' | 'guest'(默认 guest)
tokenstring会员 token,传此字段则认为是会员用户

成功响应

result = 0
{
  "result": 0,
  "desc": "ok",
  "data": {
    "init_ticket": "hex_string_32_bytes",
    "expires_in": 60,
    "channel_id": "demo-channel",
    "user_type": "guest"
  }
}

常见错误

CHANNEL_ID_REQUIRED
channel_id 为空或缺失
DOMAIN_FORBIDDEN
当前域名未在该 channel 的白名单内
SIGNATURE_INVALID
签名校验失败或格式错误
TOKEN_INVALID
传入的 token 无法识别

POST session/exchange

/plugin/v1/session/exchange

将 init_ticket 兑换为持久会话令牌。session_token 有效期通常为 10 分钟(会员)或 2 小时(游客)。

请求字段

字段类型必需说明
init_ticketstring来自 init-ticket 响应的 init_ticket

成功响应

result = 0
{
  "result": 0,
  "desc": "ok",
  "data": {
    "session_token": "hex_string_48_bytes",
    "ws_token": "hex_string_36_bytes",
    "expires_in": 600,
    "user_type": "guest"
  }
}

常见错误

INIT_TICKET_REQUIRED
init_ticket 为空或缺失
INIT_TICKET_INVALID
init_ticket 已过期、已使用或不存在

GET tables/snapshot

/plugin/v1/tables/snapshot

获取可用桌台列表及其路盘数据。插件会定期调用此接口以刷新桌台列表。

请求字段

字段来源类型说明
session_tokenHeader: x-session-token
Query: session_token
string会话令牌(推荐用 header 传递)
limitQuerynumber最多返回多少个桌台(默认 6,范围 1-50)
include_good_roadQuerybool是否包含好路数据(默认 1)
include_historyQuerybool是否包含游戏历史(默认 1)

成功响应

result = 0
{
  "result": 0,
  "desc": "ok",
  "data": {
    "generated_at": 1718358000,
    "user_type": "guest",
    "channel_id": "demo-channel",
    "table_count": 3,
    "table_list": [
      {
        "room_id": 1001,
        "game_code": "baccarat_001",
        "game_name": "百家乐 1 号桌",
        "game_type": 4,
        "online": 1,
        "shoe_id": 42,
        "round_count": 28,
        "player_count": 10,
        "banker_count": 16,
        "tie_count": 2,
        "good_road": {
          "list": ["b", "p", "b", "b", "t", "p"],
          "is_good_road": 1,
          "rule": "BACCARAT_LONG_BANKER_4",
          "rule_name": "长庄 >=4",
          "duration_rounds": 4
        },
        "game_history": [ ... ],
        "analy_data": "{...}"
      }
    ]
  }
}
重要:仅返回有路盘数据的桌台(即路盘列表不为空)。无任何落点的空桌将被过滤。

常见错误

SESSION_TOKEN_REQUIRED
session_token 为空或缺失
SESSION_INVALID
session_token 已过期或不存在

POST game/enter

/plugin/v1/game/enter

请求进入指定桌台。仅会员可进入,游客会收到拒绝。

请求字段

字段类型必需说明
room_idnumber目标桌台 ID
session_tokenstring会话令牌(header: x-session-token 优先)

成功响应

result = 0
{
  "result": 0,
  "desc": "ok",
  "data": {
    "room_id": 1001,
    "game_code": "baccarat_001",
    "open_url": "https://game.example.com/baccarat?room=1001&token=..."
  }
}

常见错误

SESSION_TOKEN_REQUIRED
session_token 为空或缺失
SESSION_INVALID
session_token 已过期或不存在
GUEST_FORBIDDEN_ENTER
游客无法进入游戏
ROOM_NOT_AVAILABLE
目标桌台不可用或不存在

最佳实践

错误处理

  • 始终检查响应中的 result 字段,非 0 表示出错。
  • 对于 SESSION_INVALID 错误,前端应重新初始化流程(从 init-ticket 开始)。
  • 对于网络错误,建议实现指数退避重试机制。

性能建议

  • 缓存 session_token 直到过期,避免频繁的 exchange 调用。
  • snapshot 列表通常可缓存 3-5 秒,再自动刷新。
  • 对于高并发场景,使用 Redis 缓存会话和防重放 nonce。

安全建议

  • 不要把 session_token 暴露在 URL query 里,优先使用 HTTP 请求头。
  • 不要在浏览器控制台输出或记录完整的 token。
  • 启用 GOODROAD_CHANNEL_SECRET 时,签名必须在后端完成,不要在前端暴露密钥。
  • 定期轮换 channel secret,并通知接入方同步更新。

完整示例

JavaScript/TypeScript 联调示例

async function setupGoodRoadSession() {
  const apiBase = 'http://127.0.0.1:13100';
  
  // 1. 获取 init_ticket
  const initResp = await fetch(`${apiBase}/plugin/v1/init-ticket`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      channel_id: 'demo-channel',
      domain: window.location.origin,
      user_type: 'guest'
    })
  }).then(r => r.json());
  
  if (initResp.result !== 0) {
    throw new Error(`Init failed: ${initResp.code}`);
  }
  
  const initTicket = initResp.data.init_ticket;
  
  // 2. 交换 session_token
  const sessionResp = await fetch(`${apiBase}/plugin/v1/session/exchange`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ init_ticket: initTicket })
  }).then(r => r.json());
  
  if (sessionResp.result !== 0) {
    throw new Error(`Exchange failed: ${sessionResp.code}`);
  }
  
  const sessionToken = sessionResp.data.session_token;
  
  // 3. 获取桌台快照
  const snapshotResp = await fetch(
    `${apiBase}/plugin/v1/tables/snapshot?limit=8`,
    {
      headers: { 'x-session-token': sessionToken }
    }
  ).then(r => r.json());
  
  console.log('Tables:', snapshotResp.data.table_list);
  
  // 4. 进入游戏(需要会员 token)
  // const enterResp = await fetch(`${apiBase}/plugin/v1/game/enter`, {
  //   method: 'POST',
  //   headers: {
  //     'Content-Type': 'application/json',
  //     'x-session-token': sessionToken
  //   },
  //   body: JSON.stringify({ room_id: 1001 })
  // }).then(r => r.json());
}

setupGoodRoadSession().catch(console.error);