加密货币收银台 · 对接文档
一站式加密货币支付网关:商户 HTTP API 创建订单 → 我们生成专属 HD 钱包地址 → 用户跳转收银台支付 → 我们服务端监听链上到账 → 签名回调通知商户 → 自动转账到商户地址。商户端只需两个接口:一个创建订单、一个接收回调。
支持 8 条主流公链 · HD 钱包无限扩容 · 服务端实时监听 · 带重试签名回调 · 自动归集- 去 /merchant.html 用邮箱密码注册,拿到
api_key和callback_secret - 把商户后台"买金币"按钮的跳转代码改成:
POST https://cryptocashier.shop/api/v1/orders Header: Authorization: Bearer <api_key> Body: { "merchant_order_id": "ORD123", "uid": "USER456", "amount_usd": "9.99" } → 拿到 pay_url,302 到这个地址让用户付款 - 实现回调接口接收
POST callback_url(JSON payload):- 验证
X-Signatureheader(HMAC-SHA256 签名,用callback_secret校验) event: "paid"→ 给用户加金币event: "partial"→ 按比例发货(选做)
- 验证
1项目概览
这是一个独立的 HTML 文件 index.html,零服务端依赖。工作流:
- 用户在网站买金币页选择「加密货币支付」→ 网站构造带参 URL 跳转到
index.html - 用户选币种(USDT/USDC)+ 网络(TRON/ETH/BSC/Solana 等 8 条)
- 展示 QR 码、地址、金额;前端开始监听链上到账(公共 RPC 轮询)
- 收到款 → 前端
POST到你的回调 URL → 弹框提示成功 - 用户点「确定」→ 跳回你指定的
return_url
✓ 网关后台独立监听链上到账(不依赖用户浏览器)
✓ 每个 uid 一组永久地址(同 uid 二次支付回到同一地址,便于归集 / 审计)
✓ HMAC 签名回调(带重试,失败自动指数退避)
✓ 一套网关服务多个商户(多租户)
2服务端 API 接入
基础 URL:
https://pay.yoursite.com(部署后填实际域名)鉴权方式:
Authorization: Bearer <api_key>回调鉴权:网关发出的 POST 请求带
X-Signature header(HMAC-SHA256, callback_secret 作为 key,payload body 作为消息)
获取 api_key
联系网关管理员(即本服务部署方),提供你的:
- 商户名称
- 接收回调的 URL(例
https://site.com/api/crypto-webhook)
管理员会返回两样东西(仅出现一次,务必保存):
api_key:调用我们 API 时用,放在 Authorization headercallback_secret:我们回调你时用来签名,你用它来验签
支付完整流程
用户浏览器 商户后端 加密网关 区块链
| | | |
| 选择加密货币支付 | | |
|------------------------>| | |
| | POST /api/v1/orders | |
| | (api_key, uid, amount) | |
| |------------------------->| |
| | | 派生 HD 地址 |
| | | 写入订单表 |
| | { pay_url, addresses } | |
| |<-------------------------| |
| 302 redirect to pay_url | |
|<---------------------- | | |
| | |
| GET /pay/:id | |
|---------------------------------------------------->| |
| | 302 to /cashier.html |
|<----------------------------------------------------| |
| | |
| 用户用钱包转 USDT | |
|---------------------------------------------------------------------------->|
| | |
| | 后台每分钟轮询链上 |
| |<------------------------|
| | 检测到到账 |
| | 更新订单状态 |
| POST webhook (带签名) |
| |<-------------------------| |
| | 验签 + 加金币 | |
| |------>200 OK | |
| |------------------------->| |3创建订单
POST /api/v1/orders
请求
curl -X POST https://pay.yoursite.com/api/v1/orders \ -H "Authorization: Bearer pk_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "merchant_order_id": "ORD-20260419-001", "uid": "USER_12345", "amount_usd": "9.99", "product": "100 金币", "return_url": "https://site.com/order/paid?id=ORD-20260419-001", "expires_min": 10 }'
请求字段
| 字段 | 必填 | 说明 |
|---|---|---|
merchant_order_id | 必 | 你系统里订单 ID,唯一。重复调用同一 ID 返回同一订单(幂等) |
uid | 必 | 付款用户 ID(你系统里的 user_id)。同一 uid 永远得到同一组收款地址 |
amount_usd | 必 | 订单金额,按 1:1 当作 USDT/USDC 金额。字符串类型避免浮点精度 |
product | 选 | 商品名,仅展示 |
return_url | 选 | 支付成功后跳转地址 |
expires_min | 选 | 订单有效期(分钟),默认 10,范围 1-180 |
响应
{
"id": "f6c8a97b-4569-4e92-a8fb-7f15b1c5a840", // 我们的 UUID
"merchant_order_id": "ORD-20260419-001",
"uid": "USER_12345",
"amount_usd": "9.99",
"amount_received": "0",
"status": "pending", // pending|partial|paid|overpaid|expired
"pay_url": "https://pay.yoursite.com/pay/f6c8...", // 让用户浏览器打开这个
"addresses": {
"evm": "0x9858effd232b4033e47d90003d41ec34ecaeda94",
"tron": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"sol": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk",
"sol_ata": {
"USDT": "6pXYguUixhfdydHDMgsvSy3sAWjStL2eEYh4KeYJAUDk",
"USDC": "5N3f1tj9v1vc5TUZ8S7mCAnVmjVKrfnzXWhxLaxyZAgt"
}
},
"created_at": 1776613832602,
"expires_at": 1776614432602
}拿到 pay_url 后,用户浏览器跳转过去即可(302 redirect 或 location.href = pay_url)。
merchant_order_id 重复调用会返回相同的订单(不会重复创建)。这样你系统里哪怕意外重试了,也不会产生两张订单。
4查询订单
GET /api/v1/orders/:id(id 是创建订单返回的我们的 UUID)
curl https://pay.yoursite.com/api/v1/orders/f6c8a97b-4569-4e92-a8fb-7f15b1c5a840 \
-H "Authorization: Bearer pk_YOUR_API_KEY"响应同上,额外带 payments 数组(该订单所有到账记录)。
5接收回调(Webhook)
我们在订单状态变化时 POST 到你注册的 callback_url,通知以下事件:
| 事件 (event) | 触发时机 |
|---|---|
paid | 累计收到金额 ≥ 订单金额(刚好足额) |
overpaid | 累计收到金额 > 订单金额(多付了) |
partial | 收到部分款(小于订单金额) |
expired | 10 分钟内未付款或未足额 |
回调请求 Headers
Content-Type | application/json |
X-Signature | HMAC-SHA256(callback_secret, body) — 十六进制 |
X-Signature-Alg | HMAC-SHA256 |
回调 Payload
{
"event": "paid", // paid|overpaid|partial|expired
"order_id": "f6c8a97b-...", // 我们的 UUID
"merchant_order_id": "ORD-20260419-001", // 你的订单 ID
"uid": "USER_12345",
"amount_requested": "9.99",
"amount_received": "9.99", // 累计收到
"currency": "USDT",
"chain": "trc20", // 最后一笔所在链
"fully_paid": true,
"overpaid": false,
"underpaid": false,
"latest_tx": { // 触发本次回调的那笔 tx
"tx": "0xabc...",
"chain": "trc20",
"currency": "USDT",
"from": "TXxxxxxxxxxx",
"amount": "9.99"
},
"created_at": 1776613832602,
"expires_at": 1776614432602,
"ts": 1776613890123
}接收端实现(Node.js)
import express from "express"; import crypto from "crypto"; const CALLBACK_SECRET = process.env.CALLBACK_SECRET; // 存在 env 里 const app = express(); // 必须用 raw body 解析,否则算出的签名不一致 app.post("/api/crypto-webhook", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body; // Buffer const sig = req.headers["x-signature"]; const expected = crypto.createHmac("sha256", CALLBACK_SECRET) .update(rawBody).digest("hex"); if (sig !== expected) return res.sendStatus(401); // 验签失败 const p = JSON.parse(rawBody.toString()); // 幂等:检查订单状态,避免重复加金币 const order = await findOrder(p.merchant_order_id); if (order.fulfilled) return res.json({ ok: true, dup: true }); if (p.event === "paid" || p.event === "overpaid") { await grantCredits(p.uid, p.amount_received); await markFulfilled(order); } else if (p.event === "partial") { // 按比例发货:收到 5/9.99 ≈ 50% → 给 50% 金币(你自己决定策略) const ratio = Number(p.amount_received) / Number(p.amount_requested); await grantCredits(p.uid, ratio * order.credits); } else if (p.event === "expired") { await cancelOrder(order); } res.json({ ok: true }); });
① 必须在 15 秒内返回 2xx。否则我们判定失败并重试。
② 重试策略:1 min / 5 min / 30 min / 2 h / 8 h / 24 h / 48 h(共 8 次)。48h 后放弃,你可以通过查询接口自行核对。
③ 回调可能重复(网络抖动 / 我方重启)。你必须按
merchant_order_id + 是否已处理做幂等。④
partial 事件可能收到多次(用户分期付款)。每次 amount_received 会累加。⑤
paid 事件收到后通常不会再有 overpaid(状态机单向流转),但注意 partial → paid/overpaid 是可能的跳转。
2部署(4 步)
把文件放到你们已有的静态资源目录,任何 Web 服务器都行(Nginx / CDN / OSS):
https://site.com/cashier/index.html
建议与网站同域,避免跨域问题。
打开 index.html,找到顶部 RECEIVERS 配置区(约 250 行前后),替换为你们生产环境的地址:
const EVM_ADDR = "0x..."; // 所有 EVM 链共用(ETH/BSC/Arbitrum/Base/Polygon/Optimism) const TRON_ADDR = "T..."; // TRON 专用地址 const SOL_ADDR = "..."; // Solana 钱包地址 // Solana 需要预计算 USDT / USDC 的关联代币账户(ATA) // 换 SOL_ADDR 后必须用 @solana/spl-token 的 getAssociatedTokenAddress 重算这俩 ATA const SOL_ATA = { USDT: "9WeRrRnES89F7e57YQnJdju5ej79MtWByKewVBfE5M7s", USDC: "B6ZZVmAJNp93njSRNJksJr99sZEgb1MGATQsWE9GfMnE", };
上线前搜索并删除以下块:
<details class="demo">...</details>— 黄色的联调演示面板<pre id="log"></pre>— 底部黑色日志面板(或保留但 CSS 隐藏)
网站的「加密货币支付」按钮点击时跳转到收银台,带上订单参数:
function payWithCrypto(order) { const p = new URLSearchParams({ order_id: order.id, product: order.name, amount: order.amount.toString(), return_url: `${location.origin}/order/success?id=${order.id}`, callback_url: `${location.origin}/api/crypto-callback`, callback_token: order.sig, // 服务端签发的鉴权 token }); location.href = `/cashier/index.html?${p}`; }
3URL 参数
收银台从 window.location.search 读取以下参数:
| 参数 | 必填 | 类型 | 说明 | 示例 |
|---|---|---|---|---|
order_id |
必填 | string | 订单唯一标识,会原样回传回调 | ORD20260419001 |
amount |
必填 | string / number | 订单金额,以 USD 计价(1:1 当 USDT/USDC 金额用);支持小数(如 9.99) | 9.99 |
product |
选填 | string | 商品名,仅展示用。缺省显示"商品支付" | 100 金币 |
created_at |
选填 | 毫秒时间戳 | 订单创建时间。缺省用当前时间 | 1712345678000 |
currency |
选填 | "USDT" | "USDC" | 预选币种(跳过币种选择) | USDT |
network |
选填 | string | 预选网络(需配合 currency),直接进支付页 | trc20 |
return_url |
选填 | URL | 用户点「确定」按钮后的跳转地址。不填则显示"支付完成"静态页 | https://site.com/paid?id=ORD... |
callback_url |
选填 | URL | 收到付款时浏览器端 POST 到的接口(需支持 CORS) | https://site.com/api/crypto-callback |
callback_token |
选填 | string | 鉴权 token,原样带在回调 body 里。服务端用来验证请求合法性 | eyJhbGciOiJI... |
addr_slot |
选填 | number (0..N-1) | 服务端分配的收款地址槽位。不传则前端基于 localStorage 自行选择(仅限同浏览器可靠)。生产建议服务端强制指定,见「并发与扩容」 | 0 |
fetch 发出,不可信任 IP/来源。callback_token 应为服务端签发的短期签名(如 HMAC),服务端在收到回调时验证 token 和 order_id 的对应关系,防止伪造。
完整示例 URL
https://site.com/cashier/index.html ?order_id=ORD20260419001 &amount=9.99 &product=100%20金币 &return_url=https%3A%2F%2Fsite.com%2Fpaid%3Fid%3DORD20260419001 &callback_url=https%3A%2F%2Fsite.com%2Fapi%2Fcrypto-callback &callback_token=eyJhbGciOiJI...
4回调接口
浏览器通过 fetch(callback_url, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(payload) }) 发起回调,fire-and-forget,不依赖响应。
接口规格
| 字段 | 值 |
|---|---|
| 方法 | POST |
| Content-Type | application/json |
| CORS | 需允许来自收银台所在域的 Access-Control-Allow-Origin。若同域则无需配置。 |
| 重复性 | 同一订单可能收到多次回调(分期付款、用户刷新、帮助流程"再查一下"查到补录等),服务端需幂等处理(按 tx 去重) |
① 支付回调 Payload
收到链上到账时触发(不论足额/欠费/多付,每次到账都发):
{
"order_id": "ORD20260419001",
"product": "100 金币",
"currency": "USDT", // "USDT" | "USDC"
"chain": "trc20", // 见"支持的链"章节
"chain_name": "TRC-20", // 人类可读的链名
"amount_requested": "9.99", // 订单原始金额(来自 URL param)
"amount_received_this": "5.00", // 本次到账金额(单笔)
"amount_received_total": "5.00", // 累计到账(同一会话内多笔会累加)
"tx": "0xabc...def", // 交易哈希(TRON/EVM 带 0x,Sol 不带)
"from": "0x1234...", // 付款方地址(Sol 可能为空)
"to": "0x1eef...", // 本次链上的收款地址
"fully_paid": false, // 累计 ≥ 订单金额
"overpaid": false, // 累计 > 订单金额
"underpaid": true, // 累计 < 订单金额
"addr_slot": 0, // 本订单使用的收款地址槽位 (0..N-1)
"callback_token": "eyJhbGciOiJI...", // 原样回传 URL 传入的 token
"ts": 1712345678000 // 本次回调时间戳(毫秒)
}•
fully_paid + overpaid:足额(含多付)→ 发货•
fully_paid 单独 true:刚好足额 → 发货•
underpaid:未足额 → 记录部分,等待后续到账或超时后按比例处理
② 反馈回调 Payload
用户通过右上角"反馈问题"或自助帮助失败后提交凭证时触发:
{
"type": "feedback", // 标识为反馈,支付回调没有此字段
"topic": "no-response", // "no-response"|"wrong-network"|"suggest"
"order_id": "ORD20260419001",
"product": "100 金币",
"user_address": "0xabc...", // 用户填入的支付地址(suggest 为 null)
"chain": "trc20", // 相关链 id(可能为 null)
"chain_name": "TRC-20",
"currency": "USDT",
"amount_requested": "9.99",
"suggestion": "...", // 建议内容(仅 suggest 主题有)
"screenshots_base64": ["data:image/png;base64,..."], // 最多 6 张
"addr_slot": 0,
"callback_token": "eyJhbGciOiJI...",
"ts": 1712345678000
}screenshots_base64 直接丢弃并在前端另行做多部分表单上传。
回调成功/失败判断
前端不 await 结果也不重试。所以即使服务端返回 500,用户侧仍会看到"支付成功"弹框。
5前后端集成示例
服务端回调接口 · Node.js / Express
import express from "express"; import crypto from "crypto"; const app = express(); app.use(express.json({ limit: "10mb" })); // 反馈可能带 base64 截图 // CORS — 允许收银台所在域 app.use("/api/crypto-callback", (req, res, next) => { res.set("Access-Control-Allow-Origin", "https://site.com"); res.set("Access-Control-Allow-Methods", "POST, OPTIONS"); res.set("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") return res.sendStatus(204); next(); }); app.post("/api/crypto-callback", async (req, res) => { const p = req.body; // 1. 鉴权:校验 callback_token(假设是 HMAC 签名) const expected = crypto .createHmac("sha256", process.env.SIGN_SECRET) .update(p.order_id) .digest("hex"); if (p.callback_token !== expected) return res.sendStatus(401); // 2. 分流:反馈 vs 支付 if (p.type === "feedback") { await saveFeedback(p); return res.json({ ok: true }); } // 3. 支付 — 幂等处理(同一 tx 只处理一次) const existing = await findPayment({ tx: p.tx }); if (existing) return res.json({ ok: true, dup: true }); await recordPayment({ order_id: p.order_id, tx: p.tx, from: p.from, chain: p.chain, currency: p.currency, amount: p.amount_received_this, ts: p.ts, }); // 4. 根据累计状态决定发货 if (p.fully_paid) { await deliverOrder(p.order_id); // 加金币 } else if (p.underpaid) { // 部分到账 — 记录,等待继续支付或超时后按比例处理 await markPartial(p.order_id, p.amount_received_total); } res.json({ ok: true }); });
前端:买金币页跳转
async function goCryptoCashier(orderId, amount, productName) { // 向服务端获取带签名的 token(防伪造) const r = await fetch("/api/sign-order", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderId, amount }), }); const { token } = await r.json(); const q = new URLSearchParams({ order_id: orderId, amount: amount.toString(), product: productName, return_url: `${location.origin}/order/success?id=${orderId}`, callback_url: `${location.origin}/api/crypto-callback`, callback_token: token, }); location.href = `/cashier/index.html?${q}`; }
监听支付事件(进阶:在已嵌入场景下免回调)
如果把收银台嵌入 iframe,父页可监听 postMessage 代替 HTTP 回调:
window.addEventListener("message", (e) => { if (e.origin !== "https://site.com") return; if (e.data?.type === "crypto-payment-received") { const { payload } = e.data; // payload 结构同 HTTP 回调 updateOrderUI(payload); } if (e.data?.type === "crypto-payment-close") { // 用户点"确定"且未设置 return_url 时发出 closeCashierModal(); } });
6收款地址池
为支持并发支付下的收款归属识别,收银台维护一个地址池(ADDRESS_SLOTS 数组),每个槽位(slot)包含 EVM / TRON / Solana 三套地址。
• 默认用 slot 0,被占用时依次分配 slot 1 / 2 / 3...
• 粘性分配:同一
order_id 重访,尽量回到原 slot(便于补款)• 锁 TTL 15 分钟(略长于支付倒计时),超时或支付完成后自动释放
• 全部被占时拒绝新用户进入,提示联系客服 + 管理员扩容
前端使用
localStorage 记录占用,无法跨浏览器 / 跨用户协调。若两个不同用户同时打开收银台,他们的浏览器各自看到"slot 0 空闲",会同时分配到 slot 0。生产环境强烈建议:服务端在创建订单时自己分配并锁定 slot,然后在跳转 URL 里传
addr_slot=N。前端会优先采用服务端指定。
当前配置(4 个 slot)
| Slot | EVM | TRON | Solana |
|---|---|---|---|
0 默认 |
0x1eef529055a19015fd134f7322ffaef6c946e25f |
TQ7vtojMqgCpaHG43fDVd1c8Sv8KX4rKpM |
DQEau2YpbzHrXUwkDKAkG86hXQ7R5TFs7yRoXJwgX1bV |
1 |
0xdece4bc636dbce04f8b17306b3f544d8387cd5fe |
TUSVfF1CdPSr9QA932vFL2hrZPrTfQ2J2P |
31mg3nBguqcTns4Zp8S62eomv4Xkc7Gd4yKd95xTLKn4 |
2 |
0xc2369979ca7becbc2be8ce9fb9b92132cf7cc265 |
TUXEUk4yusbhuZmgcGt7sDXym7ypmMUoMX |
CNVF5QNpTZg4hjCcWfPnXUoPoPoas4YWJrVSKYy7RbAT |
3 |
0xd1ffedf7a2709abdcfb8693028a2d80a11b53d2c |
TS7o1vyF3pEPPVb9wwvAFVehGKXDu4kboT |
D9tUNxvPZGtvAagoRqDFJDrsbyVPaSAPvhCAZnR4Kx42 |
如何新增 slot
在 index.html 顶部找到 ADDRESS_SLOTS 数组,追加一个对象:
const ADDRESS_SLOTS = [ // ... 已有的 slot 0..3 ... { // slot 4 (新增) evm: "0x...", // 新 EVM 地址 tron: "T...", // 新 TRON 地址 sol: "...", // 新 Solana 地址 solAta: { // ⚠ 必须预先用 Python 脚本计算 USDT: "<SOL 地址 + USDT mint 的 ATA>", USDC: "<SOL 地址 + USDC mint 的 ATA>", }, }, ];
Solana ATA 计算脚本
每次新增 Solana 地址,都必须用以下脚本算出对应的 USDT 和 USDC ATA:
# pip install solders from solders.pubkey import Pubkey sol_addrs = [ "<你的新 SOL 地址>", # 可一次算多个 ] token = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") atap = Pubkey.from_string("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") for s in sol_addrs: owner = Pubkey.from_string(s) print(f"\nSOL: {s}") for name, mint in [ ("USDT", "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"), ("USDC", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), ]: m = Pubkey.from_string(mint) ata, _ = Pubkey.find_program_address( [bytes(owner), bytes(token), bytes(m)], atap ) print(f" {name} ATA: {ata}")
• 每个 slot 的三套地址可来自独立的三个钱包(推荐)或是三条链上派生自同一助记词的地址
• 私钥完全离线保管,仅用于定期归集到冷钱包
• 前端 HTML 仅存地址(公开信息),不含私钥
• 定期扫各 slot 余额,归集到冷钱包避免单地址资金累积过高
7并发与扩容
每个 slot 同时只能服务一个订单。如果瞬时并发支付人数 > slot 数量,新来的用户会被拒绝进入支付页。
容量规划
| slot 数 | 最大并发支付(同时在付款中) | 适用场景 |
|---|---|---|
| 1 | 1 人 | 仅单用户测试 |
| 4 (当前) | 4 人 | 小体量,低并发 |
| 10 | 10 人 | 日单量 < 500 |
| 50+ | 50+ 人 | 较高并发业务 |
两种 slot 分配模式
模式 A:前端自行分配(默认 / 简单场景)
跳转 URL 不传 addr_slot 时,前端通过 localStorage 做 best-effort 分配:
- ✅ 同浏览器下,同 order_id 的二次打开,会拿回原 slot
- ✅ 单浏览器多订单不会撞车
- ❌ 两个不同用户同时打开,都分到 slot 0,发生撞车
模式 B:服务端强制指定(推荐 / 生产)
服务端在创建订单时自己维护 slot 锁表(Redis / DB),分配后通过 URL addr_slot=N 传给前端。前端直接使用该槽位,不再自行决策:
import redis, time r = redis.Redis() POOL_SIZE = 4 # 与前端 ADDRESS_SLOTS 长度一致 LOCK_TTL_SEC = 15 * 60 def allocate_slot(order_id: str) -> int | None: # 1. 粘性:同一订单返回原 slot prev = r.get(f"cashier:sticky:{order_id}") if prev: slot = int(prev) # 续租该 slot r.set(f"cashier:slot:{slot}", order_id, ex=LOCK_TTL_SEC) return slot # 2. 找首个空闲 slot,原子抢占 for i in range(POOL_SIZE): if r.set(f"cashier:slot:{i}", order_id, ex=LOCK_TTL_SEC, nx=True): r.set(f"cashier:sticky:{order_id}", i, ex=LOCK_TTL_SEC * 2) return i return None # 全部占用 def release_slot(slot: int, order_id: str): owner = r.get(f"cashier:slot:{slot}") if owner and owner.decode() == order_id: r.delete(f"cashier:slot:{slot}") # 在生成收银台 URL 前调用 slot = allocate_slot(order_id) if slot is None: raise TooBusyError("pool saturated — ask admin to add more addresses") cashier_url = f"/cashier/index.html?order_id={order_id}&amount={amt}&addr_slot={slot}&..." # 收到支付回调时,在 deliverOrder 内释放 if payload["fully_paid"]: deliver_order(payload["order_id"]) release_slot(payload["addr_slot"], payload["order_id"])
监控告警
在服务端定时任务中(每 30s)扫一次 Redis slot 占用数。阈值告警:
- 🟡 占用率 ≥ 60%:警告,考虑扩容
- 🔴 占用率 ≥ 90%:紧急告警,立即扩容
- 🚨 饱和拒绝次数 > 0:已经有用户被拒,必须扩容
扩容步骤:① 准备新的 EVM/TRON/Solana 三套钱包地址;② 用 Python 脚本算 Solana 的两个 ATA;③ 追加到 ADDRESS_SLOTS;④ 服务端 POOL_SIZE 常量同步增加;⑤ 部署。
7支持的链(chain id)
传入 network 参数或回调 chain 字段的取值:
| chain id | 链 | 协议 | USDT 合约 / mint | USDC 合约 / mint | 精度 |
|---|---|---|---|---|---|
trc20 | TRON | TRC-20 | TR7N...j6t | TEkx...dz8 | 6 |
erc20 | Ethereum | ERC-20 | 0xdac1...1ec7 | 0xa0b8...eb48 | 6 |
sol | Solana | SPL | Es9v...NYB | EPjF...Dt1v | 6 |
bsc | BSC | BEP-20 | 0x55d3...7955 | 0x8ac7...580d | 18 |
arb | Arbitrum | Arbitrum One | 0xfd08...cbb9 | 0xaf88...5831 | 6 |
polygon | Polygon | PoS | 0xc213...e8f | 0x3c49...3359 | 6 |
base | Base | Coinbase L2 | 0xfde4...9bb2 | 0x8335...2913 | 6 |
op | Optimism | OP Mainnet | 0x94b0...8e58 | 0x0b2c...ff85 | 6 |
8联调 / 演示模式
8联调 / 演示模式
唯一开启方式:商户中心的「一键测试」
- 登录 /merchant.html
- (可选)先在面板「🔔 回调 URL」填上 webhook.site 的临时 URL,可以直接看到我们发来的 webhook JSON
- 点 🎲 生成测试订单并打开收银台
- 新窗口打开的收银台底部有 3 个模拟按钮:
- 模拟足额支付 → 触发
event: "paid"回调 - 模拟欠费(少 0.01) → 触发
event: "partial"+ 欠费弹框 - 模拟多付(+0.5) → 触发
event: "overpaid"
测试订单的 merchant_order_id 以 test_ 开头,数据库 kind='test'。所有模拟数据隔离于真实订单。
真实订单(
kind='normal')的支付状态只接受后台链上扫描确认,绝不接受来自浏览器前端的"已支付"声明。
即使攻击者知道某真实订单的 ID 并能访问收银台页面,也无法伪造 webhook——所有模拟按钮针对真实订单都是空操作。
这意味着:
- ✅ 真实订单绝无"白拿商品"风险
- ✅ 真实订单的 webhook 只会在链上真转账后才触发(延迟 ≤ 1 分钟)
- ✅ 你的 callback 接收端无需额外校验,只要 HMAC 签名对就可以信任
9上线前检查清单
- 替换
EVM_ADDR/TRON_ADDR/SOL_ADDR为生产收款地址(切勿沿用测试地址) - 用
solders重算 Solana USDT/USDC 的 ATA 并填入SOL_ATA - 删除
<details class="demo">...</details>演示面板 - 删除或 CSS 隐藏
<pre id="log"></pre>(日志面板,开发期用) - 服务端回调接口配置 CORS(允许收银台所在域)
- 服务端回调验证
callback_token(建议 HMAC 签名) - 服务端回调按
tx字段幂等处理(同一 tx 可能重复回调) - 服务端同时起后台链监听兜底(不依赖前端回调做最终真相)
- 所有页面走 HTTPS(公共 RPC 也是 HTTPS,避免混合内容)
- 真机跑一遍真实链支付(至少 TRON + ETH + BSC 各一笔小额)
10常见问题
Q:用户付了钱但页面一直没反应?
可能原因:① 公共 RPC 暂时抖动;② 用户付款超出前端 40 块的 lookback 窗口;③ 浏览器后台掉了轮询。用户可通过"已支付但没反应"自助流程输入地址手动查询。服务端后台监听会兜底。
Q:支付时选错了网络怎么办?
用户可通过"支付时选错了网络"自助流程,选择实际转的链 + 填地址 + 上传截图。前端会到目标链查询并识别,服务端会收到带正确 chain 的回调。
Q:公共 RPC 会挂吗?
每条链配置了 2-3 个备用 RPC(含 publicnode / drpc / 官方)。高流量场景建议替换为付费节点(Alchemy / QuickNode / Helius)获得更好的 SLA。
Q:用户付款后刷新页面会丢失状态吗?
刷新后前端会从当前链高度前 40 块开始扫,能捕获最近 ~1 分钟内的付款。超过这个窗口的已支付用户需走"已支付但没反应"自助查询流程。服务端后台监听不受影响。
Q:能自定义支持更多链吗?
能。找到 CHAINS 数组,按已有项格式(EVM 填 rpcs + contract + decimals,Tron 填 apiBase,Sol 填 mint + 预算好的 ATA)加一项即可。
Q:回调超时怎么办?
前端是 fire-and-forget,不等待响应也不重试。可能有极少数情况前端回调失败(用户瞬间关页/断网),因此务必服务端也独立监听链上到账作为最终真相。
文档版本 v1 · 对应 index.html 同目录 · 有问题联系前端