加密货币收银台 · 对接文档

一站式加密货币支付网关:商户 HTTP API 创建订单 → 我们生成专属 HD 钱包地址 → 用户跳转收银台支付 → 我们服务端监听链上到账 → 签名回调通知商户 → 自动转账到商户地址。商户端只需两个接口:一个创建订单、一个接收回调。

支持 8 条主流公链 · HD 钱包无限扩容 · 服务端实时监听 · 带重试签名回调 · 自动归集
⚡ 3 分钟极速接入(TL;DR)
  1. /merchant.html 用邮箱密码注册,拿到 api_keycallback_secret
  2. 把商户后台"买金币"按钮的跳转代码改成:
    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 到这个地址让用户付款
  3. 实现回调接口接收 POST callback_url(JSON payload):
    • 验证 X-Signature header(HMAC-SHA256 签名,用 callback_secret 校验)
    • event: "paid" → 给用户加金币
    • event: "partial" → 按比例发货(选做)
详细请看下面章节,所有代码段都能一键复制。

1项目概览

这是一个独立的 HTML 文件 index.html,零服务端依赖。工作流:

  1. 用户在网站买金币页选择「加密货币支付」→ 网站构造带参 URL 跳转到 index.html
  2. 用户选币种(USDT/USDC)+ 网络(TRON/ETH/BSC/Solana 等 8 条)
  3. 展示 QR 码、地址、金额;前端开始监听链上到账(公共 RPC 轮询)
  4. 收到款 → 前端 POST 到你的回调 URL → 弹框提示成功
  5. 用户点「确定」→ 跳回你指定的 return_url
服务端 API 模式的核心好处
✓ 网关后台独立监听链上到账(不依赖用户浏览器)
✓ 每个 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

联系网关管理员(即本服务部署方),提供你的:

  1. 商户名称
  2. 接收回调的 URL(例 https://site.com/api/crypto-webhook

管理员会返回两样东西(仅出现一次,务必保存):

  • api_key:调用我们 API 时用,放在 Authorization header
  • callback_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
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

响应

JSON
{
  "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
curl https://pay.yoursite.com/api/v1/orders/f6c8a97b-4569-4e92-a8fb-7f15b1c5a840 \
  -H "Authorization: Bearer pk_YOUR_API_KEY"

响应同上,额外带 payments 数组(该订单所有到账记录)。

什么时候用这个接口?正常情况下你是被动接收我们的 webhook 回调,不需要主动查。但可用于:① 订单列表页显示最新状态;② 回调失败后的兜底查询;③ 客服手动核对。

5接收回调(Webhook)

我们在订单状态变化时 POST 到你注册的 callback_url,通知以下事件:

事件 (event)触发时机
paid累计收到金额 ≥ 订单金额(刚好足额)
overpaid累计收到金额 > 订单金额(多付了)
partial收到部分款(小于订单金额)
expired10 分钟内未付款或未足额

回调请求 Headers

Content-Typeapplication/json
X-SignatureHMAC-SHA256(callback_secret, body) — 十六进制
X-Signature-AlgHMAC-SHA256

回调 Payload

JSON
{
  "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)

Node.js / Express
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(状态机单向流转),但注意 partialpaid/overpaid 是可能的跳转。

2部署(4 步)

1
托管 index.html

把文件放到你们已有的静态资源目录,任何 Web 服务器都行(Nginx / CDN / OSS):

URL 示例
https://site.com/cashier/index.html

建议与网站同域,避免跨域问题。

2
改收款地址

打开 index.html,找到顶部 RECEIVERS 配置区(约 250 行前后),替换为你们生产环境的地址:

JavaScript
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",
};
3
删除演示 / 调试元素

上线前搜索并删除以下块:

  • <details class="demo">...</details> — 黄色的联调演示面板
  • <pre id="log"></pre> — 底部黑色日志面板(或保留但 CSS 隐藏)
4
从买金币页跳转

网站的「加密货币支付」按钮点击时跳转到收银台,带上订单参数:

JavaScript · 跳转
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
重要:前端 callback 通过浏览器 fetch 发出,不可信任 IP/来源callback_token 应为服务端签发的短期签名(如 HMAC),服务端在收到回调时验证 token 和 order_id 的对应关系,防止伪造。

完整示例 URL

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-Typeapplication/json
CORS需允许来自收银台所在域的 Access-Control-Allow-Origin。若同域则无需配置。
重复性同一订单可能收到多次回调(分期付款、用户刷新、帮助流程"再查一下"查到补录等),服务端需幂等处理(按 tx 去重)

① 支付回调 Payload

收到链上到账时触发(不论足额/欠费/多付,每次到账都发):

JSON · 支付回调
{
  "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         // 本次回调时间戳(毫秒)
}
三个布尔标志互斥:恰好一个为 true。
fully_paid + overpaid:足额(含多付)→ 发货
fully_paid 单独 true:刚好足额 → 发货
underpaid:未足额 → 记录部分,等待后续到账或超时后按比例处理

② 反馈回调 Payload

用户通过右上角"反馈问题"或自助帮助失败后提交凭证时触发:

JSON · 反馈回调
{
  "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
}
反馈中的截图是 base64 内嵌,可能每张几百 KB。服务端建议:① 解析后上传 OSS/S3 保存链接;② 或将 screenshots_base64 直接丢弃并在前端另行做多部分表单上传。

回调成功/失败判断

前端不 await 结果也不重试。所以即使服务端返回 500,用户侧仍会看到"支付成功"弹框。

强烈建议服务端同时起后台监听(或每 N 秒 cron 扫链),以回调为"更及时的通知",以自己扫到的为"最终真相"。前端在用户网络差/关页面时可能回调失败。

5前后端集成示例

服务端回调接口 · Node.js / Express

Node.js
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 });
});

前端:买金币页跳转

JavaScript · 跳转
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 回调:

JavaScript · 父页 iframe 集成
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)

SlotEVMTRONSolana
0 默认 0x1eef529055a19015fd134f7322ffaef6c946e25f TQ7vtojMqgCpaHG43fDVd1c8Sv8KX4rKpM DQEau2YpbzHrXUwkDKAkG86hXQ7R5TFs7yRoXJwgX1bV
1 0xdece4bc636dbce04f8b17306b3f544d8387cd5fe TUSVfF1CdPSr9QA932vFL2hrZPrTfQ2J2P 31mg3nBguqcTns4Zp8S62eomv4Xkc7Gd4yKd95xTLKn4
2 0xc2369979ca7becbc2be8ce9fb9b92132cf7cc265 TUXEUk4yusbhuZmgcGt7sDXym7ypmMUoMX CNVF5QNpTZg4hjCcWfPnXUoPoPoas4YWJrVSKYy7RbAT
3 0xd1ffedf7a2709abdcfb8693028a2d80a11b53d2c TS7o1vyF3pEPPVb9wwvAFVehGKXDu4kboT D9tUNxvPZGtvAagoRqDFJDrsbyVPaSAPvhCAZnR4Kx42

如何新增 slot

index.html 顶部找到 ADDRESS_SLOTS 数组,追加一个对象:

JavaScript · 新增 slot
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:

Python · 计算 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 数最大并发支付(同时在付款中)适用场景
11 人仅单用户测试
4 (当前)4 人小体量,低并发
1010 人日单量 < 500
50+50+ 人较高并发业务
扩容阈值建议:观察 slot 饱和率,当「某时段并发订单数 ≥ slot 数 × 80%」时就该加地址了。否则有用户会看到"支付通道繁忙"而流失。

两种 slot 分配模式

模式 A:前端自行分配(默认 / 简单场景)

跳转 URL 不传 addr_slot 时,前端通过 localStorage 做 best-effort 分配:

  • ✅ 同浏览器下,同 order_id 的二次打开,会拿回原 slot
  • ✅ 单浏览器多订单不会撞车
  • 两个不同用户同时打开,都分到 slot 0,发生撞车

模式 B:服务端强制指定(推荐 / 生产)

服务端在创建订单时自己维护 slot 锁表(Redis / DB),分配后通过 URL addr_slot=N 传给前端。前端直接使用该槽位,不再自行决策:

Python / Redis · 服务端分配示例
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 合约 / mintUSDC 合约 / mint精度
trc20TRONTRC-20TR7N...j6tTEkx...dz86
erc20EthereumERC-200xdac1...1ec70xa0b8...eb486
solSolanaSPLEs9v...NYBEPjF...Dt1v6
bscBSCBEP-200x55d3...79550x8ac7...580d18
arbArbitrumArbitrum One0xfd08...cbb90xaf88...58316
polygonPolygonPoS0xc213...e8f0x3c49...33596
baseBaseCoinbase L20xfde4...9bb20x8335...29136
opOptimismOP Mainnet0x94b0...8e580x0b2c...ff856
BSC 精度注意:BSC 上 USDT 和 USDC 都是 18 位小数(Binance-Peg 版本),其它链都是 6 位。前端已处理,服务端如果从原始 raw 值解析,注意按 chain 区分精度。

8联调 / 演示模式

8联调 / 演示模式

联调演示默认完全关闭——只有商户中心的「🎲 一键测试」按钮创建的订单才会启用模拟面板。 真实订单看不到任何演示按钮,上线无需任何改动

唯一开启方式:商户中心的「一键测试」

  1. 登录 /merchant.html
  2. (可选)先在面板「🔔 回调 URL」填上 webhook.site 的临时 URL,可以直接看到我们发来的 webhook JSON
  3. 🎲 生成测试订单并打开收银台
  4. 新窗口打开的收银台底部有 3 个模拟按钮:
  • 模拟足额支付 → 触发 event: "paid" 回调
  • 模拟欠费(少 0.01) → 触发 event: "partial" + 欠费弹框
  • 模拟多付(+0.5) → 触发 event: "overpaid"

测试订单的 merchant_order_idtest_ 开头,数据库 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 同目录 · 有问题联系前端