消消乐引擎 对接文档

一套可换皮的 HTML5 三消游戏。买家(租户)通过 API 给自己的玩家发道具,在管理后台上传自己的图片包。

服务地址
道具 APIhttps://xxl-api-test.htdtek.com
管理后台https://xxl-admin-test.htdtek.com
游戏https://xxl-classic-test.htdtek.com/?tenant=<租户>&player=<玩家ID>
本文所有 Key 都是占位符:sk_xxx(secret key)、pk_xxx(publishable key)。测试用的真实 Key 由管理员单独发放,请勿贴到群聊、工单或截图里。测试团队使用租户 qa(新玩家初始道具:锤子、洗牌、加步各 3 个)。

1. 5 分钟快速开始

① 设置环境变量(把管理员发给你的 Key 填进去):

export API=https://xxl-api-test.htdtek.com
export SK=sk_xxx   # secret key,只能放在你的服务端
export PK=pk_xxx   # publishable key,可以放在前端
export P=qa-test-$(date +%s)   # 一个新的玩家 ID

② 查余额(新玩家第一次查会自动领取初始道具):

curl -s "$API/v1/balance?playerId=$P" -H "Authorization: Bearer $PK"
# {"playerId":"qa-test-...","balance":{"h":3,"s":3,"m":3}}

③ 发 5 个锤子:

curl -s -X POST $API/v1/grant -H "Authorization: Bearer $SK" -H 'Content-Type: application/json' \
  -d "{\"playerId\":\"$P\",\"item\":\"h\",\"qty\":5,\"grantId\":\"order-$P-1\"}"

④ 打开游戏查看(游戏会通过 /v1/tenant-config 自动获取 qa 的 pk,不需要再带 &pk=):

echo "https://xxl-classic-test.htdtek.com/?tenant=qa&player=$P"

游戏每 3 秒刷新一次余额,锤子数应显示为 8。

2. 核心概念

租户(tenant)

每个买家是一个租户,用一个 ID 标识(小写字母、数字和 -,2–40 位,如 qa)。玩家余额、发放记录、图片包都按租户隔离:同一个 playerId 在不同租户下是两个互不相干的账户。

两种 Key

secret key sk_<租户>_…publishable key pk_<租户>_…
放在哪只能放在你的服务端可以放在游戏前端 / App
发道具 POST /v1/grant✅❌ 403
查余额 GET /v1/balance✅✅
扣减 POST /v1/consume✅✅
通关奖励 POST /v1/reward✅✅

Key 决定租户:服务端根据 Key 找到所属租户,请求里带 tenant 参数不会改变租户。Key 明文只在生成时显示一次,服务端只保存 SHA-256 哈希。

playerId 由谁传

playerId 是你(买家)自己系统里的玩家 ID,引擎不负责注册和登录。你的服务端发道具时传它,打开游戏时通过 URL 参数 player 传同一个值。格式:1–128 位,只能包含字母、数字、_ . : @ -(正则 ^[\w.:@-]{1,128}$)。

不传 player 时,游戏会自动生成一个 anon-<UUID> 的匿名 ID 并存在浏览器 localStorage 里,换设备或清缓存就会变。

3. 鉴权

所有 /v1/grant、/v1/balance、/v1/consume、/v1/reward 请求都要带 HTTP 头:

Authorization: Bearer sk_xxx

4. 接口参考

基址 https://xxl-api-test.htdtek.com。请求体都是 JSON(Content-Type: application/json),响应都是 JSON。

POST/v1/grant · 发放道具(仅 secret key)

字段类型必填说明
playerIdstring是玩家 ID,见上方格式
itemstring是h / s / m
qty整数是1–100000
grantIdstring是你这边的唯一单号(如订单号),1–128 字符,用于幂等
curl -s -X POST https://xxl-api-test.htdtek.com/v1/grant \
  -H "Authorization: Bearer sk_xxx" -H 'Content-Type: application/json' \
  -d '{"playerId":"p1","item":"h","qty":5,"grantId":"order-1001"}'

首次发放返回 201:

{"ok":true,"duplicate":false,
 "grant":{"grantId":"order-1001","playerId":"p1","item":"h","qty":5,"created":1761400000000},
 "balance":{"h":5,"s":0,"m":0}}
grantId 幂等:同一租户下 grantId 只会生效一次。用同一个 grantId、相同内容(playerId、item、qty 都一样)再调用,返回 200、"duplicate":true,grant 是第一次的原始记录,余额不变。网络超时可以放心用同一个 grantId 重试。
同一个 grantId 但内容不同(2026-10-25 起):返回 409 {"error":"conflict","detail":"grantId already used with different playerId/item/qty","grant":{…原记录…}},不会发放。每笔新订单必须用新的 grantId。不同租户的 grantId 互不影响。created 是毫秒时间戳。
保留前缀:grantId 不能以 init: 或 reward: 开头(系统内部用于初始道具和通关奖励),否则返回 400 {"error":"bad_request","need":"grantId must not start with reserved prefix init: or reward:"}。
初始道具:对新玩家调用 grant 时,会先发放租户配置的初始道具,再加上本次数量(例如初始 3/3/3 的新玩家发 1 个锤子后余额为 h=4)。

错误:

状态码响应原因
400{"error":"bad_json"}请求体不是合法 JSON
400{"error":"bad_request","need":"playerId,item(h|s|m),qty(1..100000 int),grantId"}字段缺失或不合法
401{"error":"unauthorized"}Key 缺失 / 无效 / 已吊销
403{"error":"forbidden","detail":"publishable key cannot grant"}用了 publishable key
409{"error":"conflict",…}grantId 已用过且内容不同

GET/v1/balance?playerId= · 查询余额

curl -s "https://xxl-api-test.htdtek.com/v1/balance?playerId=p1" -H "Authorization: Bearer pk_xxx"
{"playerId":"p1","balance":{"h":5,"s":0,"m":0}}

没有记录的道具返回 0。响应头带 Cache-Control: no-store。错误:400 {"error":"bad_request","need":"playerId"}、401。

初始道具:如果租户在后台配置了新玩家初始道具,玩家第一次调用 balance 时会自动发放一次(记为一条 grantId=init:<playerId> 的发放记录),并发查询也只发一次。之后再查不会重复发。init: 是保留前缀,自己用会返回 400。修改配置只影响之后首次查询的玩家;在“初始道具”功能上线前就存在的玩家,下次查询时也会领一次。第一次调 grant(或 consume 之前的 balance)同样会触发初始道具。

POST/v1/consume · 扣减道具

字段类型必填说明
playerIdstring是
itemstring是h / s / m
qty整数否默认 1,范围 1–100000
consumeIdstring否(强烈建议)本次扣减的唯一单号,1–128 位,只能含 A-Z a-z 0-9 _ . : -。用于幂等
curl -s -X POST https://xxl-api-test.htdtek.com/v1/consume \
  -H "Authorization: Bearer pk_xxx" -H 'Content-Type: application/json' \
  -d '{"playerId":"p1","item":"h","qty":1,"consumeId":"c-123"}'
# 第一次 200 {"ok":true,"duplicate":false,"consumeId":"c-123","balance":{"h":2,"s":3,"m":3}}
# 同 id 再调 200 {"ok":true,"duplicate":true,"consumeId":"c-123","balance":{"h":2,"s":3,"m":3}}  ← 不重复扣
consumeId 幂等:同一租户下同一个 consumeId 只扣一次(并发 5 次也只扣一次)。重复请求返回 200、duplicate:true 和当前余额。同一个 consumeId 但 playerId/item/qty 不同,返回 409 {"error":"conflict","detail":"consumeId already used with different playerId/item/qty"}。
不传 consumeId 仍可用(旧写法),响应只有 {"ok":true,"balance":{…}},但重试会重复扣。
新玩家:没查过余额直接 consume 时,会先发初始道具再扣减(以前会返回 409)。
余额不足返回 409:{"error":"insufficient","balance":{"h":0,"s":0,"m":0}},余额不会变成负数,也不会部分扣减。带 consumeId 时余额不足不会占用这个 id,充值后可用同一个 id 重试。

其他错误:400 {"error":"bad_request","need":"playerId,item(h|s|m),qty(1..100000 int, default 1),consumeId(optional, 1..128 [A-Za-z0-9_.:-])"}、400 bad_json、401。

限流(2026-10-25 第二批起)

接口默认阈值计数维度
POST /v1/grant600 次/分钟每租户
POST /v1/consume + POST /v1/reward(合计)120 次/分钟每租户 + 每玩家
GET /v1/balance、/v1/tenant-config、/v1/images不限流—(游戏 3 秒轮询不受影响)

按固定 60 秒窗口计数。每个租户可在后台单独设置,填 0 表示不限。超限返回 429,并带 Retry-After 响应头(秒):

HTTP/1.1 429 Too Many Requests
Retry-After: 37
{"error":"rate_limited","detail":"…","retryAfter":37}
客户端处理 429 的建议:不要立即重试;按 retryAfter(或 Retry-After 头)等待后再发。consume 重试时继续用同一个 consumeId,不会重复扣。界面提示「操作太频繁,请稍后」并保持按钮锁定到等待结束。服务端发放(grant)请排队或退避,不要并发猛刷;grantId 幂等,重试安全。

推荐的客户端重试方式

  1. 玩家点道具时先生成一个 consumeId(如 playerId-时间戳-随机数),并锁住道具按钮,防止连点。
  2. 每次请求最多等 8 秒,超时或网络错误时用同一个 consumeId 重试,最多重试 2 次。
  3. 拿到 200(无论 duplicate 是 true 还是 false)才让道具生效;409 insufficient 提示余额不足;429 按 retryAfter 等待后用同一个 consumeId 再试;全部失败提示「未扣除」并解锁按钮。
  4. 超过约 1.2 秒可以先提示「网络较慢」。
async function consume(api, pk, playerId, item) {
  const consumeId = `${playerId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  for (let i = 0; i < 3; i++) {               // 1 次 + 重试 2 次,都用同一个 consumeId
    const ac = new AbortController(), t = setTimeout(() => ac.abort(), 8000);
    try {
      const r = await fetch(api + '/v1/consume', { method: 'POST', signal: ac.signal,
        headers: { Authorization: 'Bearer ' + pk, 'Content-Type': 'application/json' },
        body: JSON.stringify({ playerId, item, qty: 1, consumeId }) });
      if (r.status === 429) { const j = await r.json(); return [429, j]; } // 调用方按 j.retryAfter 等待后用同一 consumeId 重试
      if (r.status === 200 || r.status === 409 || r.status === 400) return [r.status, await r.json()];
    } catch (e) { /* 超时或断网,继续重试 */ } finally { clearTimeout(t); }
  }
  return [0, { error: 'not_charged' }];        // 未扣除
}

POST/v1/reward · 通关奖励(pk 或 sk)

游戏通关时调用。同一租户、同一玩家、同一关只发一次。奖励内容和开关由租户在后台配置,默认锤子×1。内部记为 grantId=reward:<关卡>:<playerId>。

字段类型必填说明
playerIdstring是
level整数是1–1000
curl -s -X POST https://xxl-api-test.htdtek.com/v1/reward \
  -H "Authorization: Bearer pk_xxx" -H 'Content-Type: application/json' \
  -d '{"playerId":"p1","level":1}'
# 首次 201 {"ok":true,"granted":true,"level":1,"reward":{"item":"h","qty":1},"balance":{"h":5,"s":2,"m":2}}
# 再调 200 {"ok":true,"granted":false,"level":1,"reward":{"item":"h","qty":1},"balance":{…}}
# 后台关闭时 200 {"ok":true,"granted":false,"reason":"disabled","balance":{…}}

错误:400 {"error":"bad_request","need":"playerId,level(1..1000 int)"}、400 bad_json、401。

已知限制:pk 是公开的,玩家可以不通关直接调这个接口。每关只发一次,所以每个玩家最多能刷到「关卡数 × 奖励数量」(默认 50 关 × 锤子 1 个)。如果不能接受,请在后台关闭通关奖励,改由你的服务端用 sk 调 /v1/grant 发放。

GET/v1/tenant-config?tenant= · 租户配置(公开)

不需要 Key。游戏启动时调用,拿到该租户的游戏 pk 和道具配置。

curl -s "https://xxl-api-test.htdtek.com/v1/tenant-config?tenant=qa"
# 200 {"tenant":"qa","name":"QA测试","publishableKey":"pk_xxx",
#      "initItems":{"h":3,"s":3,"m":3},"levelReward":{"item":"h","qty":1}}

GET/v1/images?tenant= · 图片包列表(公开)

curl -s "https://xxl-api-test.htdtek.com/v1/images?tenant=qa"
# {"tenant":"qa","images":[{"level":1,"order":1,"url":"https://xxl-api-test.htdtek.com/img/qa/1-ab12cd34.webp"}]}

按关卡升序。不存在的租户也返回 200 和空列表。可缓存 30 秒。图片本身 GET /img/<路径>,不存在返回 404 纯文本 not found。

通用状态码

码含义
200成功 / 重复请求
201新发放 / 通关奖励发放成功
204CORS 预检 OPTIONS
400参数或 JSON 错误
401Key 无效(/v1/ 下未知路径,不带 Key 时也先返回 401)
403无权限
404{"error":"not_found"} 路径或方法不存在
409余额不足(insufficient)/ grantId 或 consumeId 内容冲突(conflict)
429{"error":"rate_limited","detail","retryAfter"} + Retry-After 头,见上文限流;后台登录被锁定也返回 429
500{"error":"server_error"}

5. 道具类型

代码道具游戏内效果
h🔨 锤子点一格直接敲掉
s🔀 洗牌重新打乱棋盘
m➕ 加步本关 +5 步

目前只支持这三种,传其他值返回 400。

6. 把游戏嵌进你的 App 或网页

URL 参数

参数说明
tenant租户 ID,决定加载哪个图片包。默认 demo
player玩家 ID,和你发道具时用的 playerId 一致。不传则生成匿名 ID
pk可选。游戏会通过 /v1/tenant-config 自动获取该租户在后台设置的 pk,一般不用传;传了则覆盖自动获取的值。拿不到配置时道具按钮置灰、数量显示「–」并提示「未知租户」或「连接失败」
apiAPI 地址,一般不用改
https://xxl-classic-test.htdtek.com/?tenant=qa&player=user_123

iframe 嵌入网页

<iframe src="https://xxl-classic-test.htdtek.com/?tenant=qa&player=user_123"
  style="width:100%;height:100vh;border:0" allow="autoplay; fullscreen"></iframe>

App 里用 WebView(iOS WKWebView / Android WebView)打开同一个 URL 即可,需开启 JavaScript 和 localStorage。声音需要用户点一下「点击开始」才会解锁(浏览器限制)。

道具同步方式

游戏每 3 秒(页面可见时)拉一次 /v1/balance,切回页面时立即拉一次;玩家使用道具时调用 /v1/consume 扣 1 个(带 consumeId,8 秒超时,同 id 重试 2 次,等待期间按钮锁住);通关时调用 /v1/reward 领通关奖励。你服务端发放后,游戏里最多约 3 秒显示新数量。

CORS 白名单

浏览器直接调 API 时,只有白名单里的域名能读到响应。当前白名单:xxl-test、xxl-mosaic-test、xxl-classic-test(均为 .htdtek.com)及本地 localhost/127.0.0.1:8080、8091。用 iframe 嵌我们的游戏不需要申请。如果你要在自己的网页前端直接调 API,请按 第 11 节把完整 Origin(如 https://game.example.com)发给管理员添加。服务端调 API 不受 CORS 限制。允许的方法 GET/POST/OPTIONS,允许的头 Authorization、Content-Type。

7. 管理后台

地址 https://xxl-admin-test.htdtek.com,用管理员密码登录(会话 12 小时)。测试团队如需后台操作请联系管理员。

登录防暴力破解:按 IP 统计失败次数(IPv6 按 /48 合并)。连续错 5 次锁 5 分钟,之后每再错 5 次锁定时间翻倍,最长 30 分钟;锁定期间即使密码正确也返回 429 页面(带 Retry-After);登录成功后计数清零。只影响后台登录,API Key 调用完全不受影响。需要提前解锁请联系管理员。
  1. 创建租户:首页填 tenant id(小写字母数字和 -,2–40 位)和名称 → 创建。已存在的 id 不会覆盖。
  2. 生成 Key:进入租户 → API Keys → 选 secret 或 publishable → 生成。明文只显示这一次,列表里只显示前缀。
  3. 吊销 Key:在列表里点「吊销」,立即生效,之后用它请求返回 401。不能恢复,只能重新生成。
  4. 初始道具:「新玩家初始道具」填 h / s / m 数量(0–999)→ 保存。
  5. 游戏 pk:「游戏前端 publishable key」填一个本租户未吊销的 pk → 保存,游戏通过 /v1/tenant-config 公开下发。新建 publishable key 时如果还没设置会自动填上。未设置时游戏道具不可用。
  6. 通关奖励:勾选开关、选道具(h/s/m)和数量(0–99)→ 保存。默认开启、锤子×1。关闭后 /v1/reward 返回 granted:false, reason:"disabled"。
  7. 手动发道具:填 playerId、选道具和数量 → 发放(grantId 自动生成为 admin-…)。
  8. 上传图片包:填「从第 N 关开始」,一次选多张图,按选择顺序依次对应第 N、N+1…关。浏览器会自动居中裁成 1:1(最大 1024px)并压成 WebP;单张处理后需 ≤3MB,支持 JPG/PNG/WebP。同一关重复上传会替换。可逐张删除。
  9. 限流设置:租户页 →「限流」,分别填 grant(每租户/分钟)和 consume+reward(每玩家/分钟),0 = 不限 → 保存。
  10. 最近发放:显示该租户最近 20 条发放记录(含初始道具和后台手动发放)。

8. 端到端测试流程

export API=https://xxl-api-test.htdtek.com SK=sk_xxx PK=pk_xxx
export P=qa-e2e-$(date +%s)
# 1. 首次查询:应为 3/3/3(初始道具)
curl -s "$API/v1/balance?playerId=$P" -H "Authorization: Bearer $PK"
# 2. 手机或电脑打开游戏,进第 2 关,锤子应显示 3
echo "https://xxl-classic-test.htdtek.com/?tenant=qa&player=$P"
# 3. 发 2 个锤子 → 201,h=5;3 秒内游戏里锤子变 5
curl -s -w ' [%{http_code}]\n' -X POST $API/v1/grant -H "Authorization: Bearer $SK" \
  -H 'Content-Type: application/json' -d "{\"playerId\":\"$P\",\"item\":\"h\",\"qty\":2,\"grantId\":\"e2e-$P\"}"
# 4. 同一个 grantId 再发 → 200 duplicate:true,h 仍是 5
# 5. 在游戏里用一次锤子(点锤子再点棋盘一格)→ 游戏显示 4
# 6. 再查余额 → h=4
curl -s "$API/v1/balance?playerId=$P" -H "Authorization: Bearer $PK"

9. 测试用例清单

#用例期望
1新玩家首次查余额200,h/s/m = 3/3/3
2同一玩家再次查余额 / 5 个并发首次查询仍是 3/3/3,不重复发
3sk 发放新 grantId201,duplicate:false
4同一 grantId、相同内容重发200,duplicate:true,余额不变
4a同一 grantId 改 qty 或 item 重发409 conflict,余额不变
4bgrantId 以 init: 或 reward: 开头400
5pk 扣减 qty=1 / 不传 qty200,余额 -1
5a同一 consumeId 调两次 / 并发 5 次只扣一次,第二次起 duplicate:true
5b同一 consumeId 改 item 或 qty409 conflict
5c/v1/reward 同一玩家同一关调两次第一次 201 granted:true,第二次 200 granted:false
5dtenant-config?tenant=qa / 不存在的租户200 带 publishableKey / 404 not_found
5e打开 ?tenant=qa 不带 pk;快速连点道具道具正常同步;连点只扣 1 个
6游戏内发放后 3 秒内显示、使用后服务端同步通过
应该失败的情况
7不带 Authorization401 unauthorized
8错误 Key / Bearer 后为空 / 已吊销 Key401
9pk 调 /v1/grant403 forbidden
10扣减超过余额409 insufficient,余额不变
11item 为 x、qty 为 0 / -1 / 1.5 / "5" / 100001400 bad_request
12缺 grantId 或 playerId;playerId 含空格、中文或超过 128 位400
13请求体不是 JSON400 bad_json
14balance 不传 playerId400 need:playerId
15用 demo 的 Key 查/扣/发 qa 的玩家;请求里加 tenant=qa只作用在 demo 自己的空间,qa 玩家余额不变
16GET /v1/grant、POST /v1/balance、/v1/unknown(带 Key)404 not_found
17未登录访问后台 /t/qa、伪造 cookie401 登录页
18API 域名访问 /admin404
19非白名单 Origin 的浏览器请求响应无 Access-Control-Allow-Origin,浏览器拦截
20同一玩家 1 分钟内并发 130 次 consume(默认阈值)前 120 次正常,之后 429 rate_limited + Retry-After;另一玩家同时正常
21同一玩家连续 150 次 balance全部 200(不限流)
221 分钟内 grant 超过 600 次(每租户)429;下一窗口恢复 201
23收到 429 后等 retryAfter 秒,用同一 consumeId 重试200,只扣一次
24后台登录密码错 6 次第 1–5 次 401,第 6 次 429;锁定期间正确密码也 429,API 照常 200
25新玩家不查余额直接 consume先发初始道具再扣减,返回 200

10. 已知限制

12. 更新记录

2026-10-25(第二批修复,向后兼容)

2026-10-25(第一批修复,向后兼容)

11. 问题反馈

请发给 Tan Wei(项目负责人),每个问题注明:

申请 CORS 白名单、新的测试 Key 或吊销 Key,也找同一个人。