SDK 与客户端库
五种语言的客户端,封装了 REST 调用、实时订阅(换票据、断线自动重连、重连后的补齐和去重)和带类型的消息;Flutter、iOS、Android 另有动画直播的原生组件。各 SDK 的消息格式、重连和去重规则相同。
有哪些 SDK
| SDK | 包含 | 运行环境 | 源码目录 |
|---|---|---|---|
| JavaScript / TypeScript | REST 客户端、实时订阅(WebSocket)、MQTT 接入辅助、类型定义;Node 和浏览器通用,没有运行时依赖 | Node 22+(自带 fetch 和 WebSocket);浏览器 | sdk/js |
| Python | REST 客户端(同步 / asyncio)、实时订阅(asyncio / 同步回调)、MQTT 接入辅助;没有运行时依赖 | Python 3.9+ | sdk/python |
| Dart | REST 客户端、实时订阅、带类型的消息;纯 Dart,服务端、命令行、Flutter(含 Web)都能用 | Dart 3.5+ | sdk/dart/opendata |
| Flutter | 比赛动画 Widget:令牌方式或 App Key 方式,自动续期令牌、切后台断开、进球和完场回调 | Flutter 3.27+,iOS / Android | sdk/dart/opendata_anim |
| Swift | OpenData:REST 客户端和实时订阅;OpenDataAnim:动画组件(SwiftUI / UIKit),令牌方式或 App Key 方式 | iOS 15+ / macOS 12+;动画组件只在 iOS | sdk/swift |
| Kotlin | opendata:REST 客户端和实时订阅;opendata-anim:Android 动画组件,令牌方式或 App Key 方式 | JVM 11+ / Android(minSdk 24) | sdk/kotlin |
密钥规则(先读这一节)
- 数据 API Key 只存在于你的服务端。不要写进网页、小程序或 App 安装包,也不要下发给前端:前端里的任何东西都能被取出来。
- 你的服务端用 API Key 换两样短期凭证:一次性的实时票据(
POST /api/v1/realtime/ticket,60 秒内有效、只能用一次)和组件令牌(POST /api/v1/widget/token,绑定配置和页面域名)。 - 浏览器和 App 里的 SDK 只拿票据或令牌:你给 SDK 一个回调(ticket provider / token provider),SDK 在每次连接、每次续期时调它,回调去问你的服务端。
- 唯一可以写进前端代码的凭证是公开的 App Key(
pk_开头):它只能加载动画组件、调不了任何数据接口,靠域名白名单、用量上限和可选的签名模式保护。
| 运行位置 | 用什么凭证 | 怎么拿 |
|---|---|---|
| 你的服务端 | API Key | 请求头 X-API-Key;构造 SDK 的 REST 客户端时传入。带 Key 的客户端只在服务端、命令行和内部工具里用 |
| 浏览器 / App 里的实时订阅 | 实时票据(60 秒内有效,只能用一次) | 你的服务端调 POST /api/v1/realtime/ticket,把结果交给前端;每次(重)连都要一张新的 |
| 网页 / App 里的动画直播 | 组件令牌,或 App Key(pk_ 开头) | 令牌:你的服务端调 POST /api/v1/widget/token。App Key:控制台新建,只能加载动画,可以写在页面或 App 里 |
获取与安装
SDK 以源码压缩包提供,登录控制台后在「SDK 下载」里下载(每个 SDK 一个 zip,附 SHA-256 校验值);没有发布到 npm、PyPI、pub.dev、Maven Central 或 Swift 包索引。每个包解压出来是 sdk/<目录>/ 加 sdk/contract/。解压后,按下面的方式从本地目录加到你的项目里。 去控制台下载 →
unzip opendata-sdk-js-<version>.zip # → sdk/js, sdk/contract
# 先把 SDK 构建一次(包里只有源码,没有 dist/)
cd sdk/js && npm install && npm run build
# 然后在你的项目里从这个目录安装
npm install /path/to/sdk/js
# 引用名(它 package.json 里的名字):
# import { RestClient, RealtimeClient } from '@openplatform/football-sdk'服务端:拉进行中的比赛,再订阅它们
下面两段在你的服务端运行,API Key 从环境变量读:先用 REST 取进行中的比赛,再订阅这些比赛的比分和事件。Key 需要开通实时推送。
import { RestClient, RealtimeClient } from '@openplatform/football-sdk'
const rest = new RestClient({ baseUrl: 'https://api.sports.csap.ai', apiKey: process.env.OPB_API_KEY })
const live = await rest.live({ limit: 50 }) // 进行中的比赛
console.log(live.length, 'live matches')
const rt = new RealtimeClient({
rest, // 每次(重)连都用它换一张一次性票据
subscriptions: [{ channels: ['match', 'incident'], matches: live.slice(0, 5).map((r) => r.id) }],
})
rt.on('match', (m, meta) => console.log(m.match_id, m.home_scores[0], m.away_scores[0], meta.snapshot ? '(snapshot)' : ''))
rt.on('incident', (e, meta) => console.log(e.match_id, e.incident_type, e.minute, meta.revised ? '(revised)' : ''))
rt.on('gap', (g) => console.log('offline', g.since, '→', g.until)) // 每次重连成功后一次
rt.on('fatal', (err) => console.error('will not reconnect:', err.message))
await rt.connect()浏览器和 App:只拿票据,不放 Key
前端代码里没有 API Key。先在你的服务端加一个换票据的接口(第一段),前端的 SDK 通过回调去调它;票据只能用一次,所以每次(重)连回调都会被调用。JS SDK 在浏览器里构造带 API Key 的客户端会直接报错。
你的服务端:换票据的接口(Node 示例)
Node.js
// API Key 只在这里:你的服务端的环境变量里
import express from 'express'
import { RestClient } from '@openplatform/football-sdk'
const rest = new RestClient({ baseUrl: 'https://api.sports.csap.ai', apiKey: process.env.OPB_API_KEY })
const app = express()
// 用你自己的登录态保护这个接口:谁能调它,谁就能看实时数据
app.post('/opendata/ticket', async (_req, res) => res.json(await rest.realtimeTicket())) // { ticket, expires_in, ws_url }
app.listen(3000)前端:用回调拿票据
import { RealtimeClient } from '@openplatform/football-sdk'
// 页面里没有 API Key:回调去问你的服务端要票据
const rt = new RealtimeClient({
baseUrl: 'https://api.sports.csap.ai',
getTicket: () => fetch('/opendata/ticket', { method: 'POST' }).then((r) => r.json()),
subscriptions: [{ channels: ['match'], matches: ['900001'] }],
})
rt.on('match', (m) => console.log(m.match_id, m.home_scores[0], m.away_scores[0]))
await rt.connect()断线、重连与补齐
- 服务端没有消息序号,也不重放断线期间的消息。SDK 断线后自动重连(1 秒起、每次翻倍、最长 30 秒,带随机抖动),每次重连换一张新票据,连上后重发全部订阅。
- 重连后服务端按订阅再推一遍最新状态(
snapshot: true):比赛(含整场事件表和统计)、动画、赔率。断线期间比分和事件的变化都在这份快照里。 - SDK 只去掉内容完全相同的重复:比赛消息,版本更旧的丢弃,同版本只有内容相同才丢弃;事件,完全重复的丢弃,
dkey相同但内容不同的照常给你并标revised(可能是对前一条的补全,也可能是同一分钟同一方的另一个事件,要你按内容判断);球位置只丢与上一帧完全相同的。断线期间没变化的比赛不会再回调一次。 - 每次重连成功,SDK 给出一个 gap 通知(断线的起止时间)。断线期间的单条事件不会补发;需要逐条事件时,用快照里的整场事件表对账。
- 不会再重连的情况(SDK 报致命错误):换票据返回 401 / 403(Key 无效或停用、没开实时推送、IP 不在白名单)、换票据时每日配额用完、WebSocket 升级被 403 拒绝(看得到状态码的 SDK:Python、Dart、Kotlin、Swift)、服务端以关闭码 1008 断开(实时权限被收回)、你主动关闭连接。其余失败都会退避后重试。
- 心跳由 SDK 处理。自己写客户端时要注意:连接上有两条及以上订阅规则时,不能用重发
set当心跳,它会把全部规则替换成这一条。订阅消息还有几个坑(退订的确切含义、写错的频道名、8192 字节上限),SDK 会在本地拦下明显的错误。
错误处理
| HTTP | 情况 | SDK 的处理 |
|---|---|---|
| 401 | Key 缺失、无效或已停用 | 抛出鉴权错误,不重试 |
| 403 | 接口不在套餐内、请求 IP 不在 Key 的白名单里、没开实时推送 | 抛出权限错误,不重试 |
| 404 | 路径或数据不存在 | 抛出错误,不重试 |
| 429 | 超过每分钟限速(message 是 rate limit exceeded) | 等到下一个自然分钟开头再试(服务端按自然分钟计数,不返回 Retry-After),默认最多自动等 2 次,还是 429 就把错误交给你 |
| 429 | 当日配额用完(message 是 daily quota exceeded) | 抛出错误,不重试(到次日才恢复) |
| 502 / 503 / 504 | 网关或服务暂时不可用 | 指数退避后自动重试,最多 2 次 |
| 500 | 服务端错误 | 抛出错误,不重试 |
| — | 网络失败或超时 | 只重试 GET 请求 |
动画直播的原生组件
Flutter、iOS、Android 各有一个组件,把平台托管的动画页放进 WebView,并负责令牌的获取和续期、退到后台时断开、进球和完场回调、高度自适应。
import 'package:flutter/widgets.dart';
import 'package:opendata_anim/opendata_anim.dart';
// 令牌方式:你的服务端用 API Key 换组件令牌(POST /api/v1/widget/token)
Widget tokenMode(Future<Map<String, Object?>> Function(Map<String, Object?> body) askMyServer) {
return OpenDataAnim(
baseUrl: Uri.parse('https://api.sports.csap.ai'),
match: '900001', // 比赛 id;'auto' 自动挑一场正在踢的;'replay:demo' 播放录像
view: AnimView.flat25d, // 或 AnimView.stadium3d
tokenProvider: (req) async => AnimToken.fromJson(await askMyServer(req.toJson())),
onEvent: (e) {
if (e.type == AnimEventType.goal && !e.historical) debugPrint('goal ${e.side} ${e.score}');
if (e.type == AnimEventType.fullTime) debugPrint('full time ${e.score}');
},
);
}
// App Key 方式:不需要自己的服务端;这把 Key 是公开的,只能加载动画
Widget appKeyMode() {
return OpenDataAnim.appKey(
baseUrl: Uri.parse('https://api.sports.csap.ai'),
appKey: 'pk_xxxxxxxx', // 控制台 → 动画直播组件 → App Key
embedOrigin: Uri.parse('https://app.example.com'), // 它的主机名必须在这把 Key 的域名白名单里
match: '900001',
onError: (e) {
if (e is AnimRefused) debugPrint('refused: ${e.reason}'); // domain / disabled / quota …
},
);
}- 令牌方式(三端都支持):组件向你提供的回调要组件令牌,你的服务端用 API Key 调
POST /api/v1/widget/token换给它。 - App Key 方式(三端都支持):不需要自己的服务端,组件用公开的 App Key 加载动画;被拒绝时组件报出原因(domain、disabled、quota 等)。
- App 没有网址,所以 App Key 方式要给一个
embedOrigin,它的主机名必须在这把 App Key 的域名白名单里;协议要和平台地址一致(正式环境都是 https)。App 里的来源是 App 自己声明的,域名白名单拦不住别人把 Key 抄进他的 App,在意这一点请开签名模式或用令牌方式。 - 画面(2.5D / 3D)、模板、布局由控制台里发布的那份配置决定。