编程 USDT TRC20 自动收款:链上匹配为什么不能直接传固定金额

2026-08-27 20:08:23 views 8

USDT TRC20 自动收款:链上匹配为什么不能直接传固定金额

做链上 USDT 收款时,最先遇到的问题不是签名、不是节点同步,而是:怎么确定某笔链上转账对应的是哪个订单?

一开始最容易想到的方案是:用户下单 100 USDT,我们给他一个收款地址,他去转 100 USDT,我们扫链发现一笔 100 USDT 的转入,就标记订单完成。

但这个方案只在「同一时间只有一个待支付订单」时成立。只要有两个以上订单金额相同,扫链时就无法判断这笔 100 USDT 是哪笔订单的。更不能根据「刚好有一笔 100 的转账进来」就匹配——有可能 A 的 100 还没到,B 的 100 先到了,扫到后你把它匹配给了 A,等 A 的真转账到了,反而没法匹配了。金额是唯一匹配依据时,撞金额就是死结。

所以这个系统在设计上做了一个取舍:不给用户原始金额,给一个「实际支付金额」

创建订单 (gRPC)  →  用户转账 USDT  →  链上扫描匹配  →  MQ 通知对接方

金额冲突与 actual_amount 递增

创建订单时,对接方提交原始金额 original_amount,系统返回一个 actual_amount,这个值才是展示给用户去转的金额。

actual_amount 的生成规则:在同一收款地址下,对相同金额的订单做 +0.01 递增。

比如第一笔 100.00 的订单,actual_amount 就是 100.00。如果这个订单还没支付,又来了第二笔 100.00 的订单,那第二笔的 actual_amount 就是 100.01。这样同样面额的多个待支付订单,链上金额不会重复,扫链时就能通过金额精确匹配。

代价是用户多付 0.01 USDT,相当于支付手续费。实践中用户大多能接受,毕竟也就一分钱。而且这个递增是在同一个收款地址维度下做的,不同收款地址互不影响。

actual_amount 和金额字段全部用字符串传递,数据库用 DECIMAL(20,2) 存储。float 在金额计算上会有精度问题,比如 0.1+0.2 这种基础错误,在金额场景里不可接受。

gRPC 接口定义:

service UsdtPayService {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
}

请求字段就一个:

字段类型说明
amountstring原始支付金额,如 "100.00"

响应字段:

字段类型说明
order_nostring18 位订单号
actual_amountstring实际需支付金额(展示给用户)
original_amountstring原始请求金额
wallet_addressstring收款钱包地址

Go 侧调用示例:

resp, err := client.CreateOrder(ctx, &pb.CreateOrderRequest{
    Amount: "100.00",
})
// resp.OrderNo, resp.ActualAmount, resp.WalletAddress

这里明确一下:前端必须展示 actual_amount,不能用 original_amount。用户按原始金额转的话,扫链匹配不上,订单超时是必然的。

扫链匹配与扫描间隔

订单创建后进入待支付状态,默认 30 分钟过期。系统定时扫链,通过 TronGrid API 拉取收款地址的 USDT TRC20 转账记录,逐条与待支付订单做金额匹配。

扫链间隔默认 10 秒,由环境变量 SCAN_INTERVAL 控制。

扫描间隔是个权衡:

  • 间隔短,确认延迟低,但 TronGrid API 请求量大会触发限流。没配 API Key 的时候限制更严格。
  • 间隔长,请求量下来了,但用户付完款要等更久才能收到通知。

如果交易吞吐量不高,10 秒够用。如果要做到秒级确认,就得配 TRONGRID_API_KEY 并适当调低间隔,但注意限流余量。API Key 建议配置,不配也能跑,但更容易被限流。

链上扫描不是推送,是轮询,天然有延迟。这个系统适合对确认时间不敏感的场景。如果对账要求高、需要逐笔精确核对,或者交易量极高,轮询扫链就不合适了——推送(如 TronGrid 的 webhook、事件订阅)更合适,或者直接接交易平台的回调。

订单查询 HTTP 接口

查询订单用来给后台或管理端用,支持分页和多字段筛选:

GET /api/orders?page=1&page_size=20
GET /api/orders?order_no=202602&status=1
GET /api/orders?wallet_address=TXxxxxx&page=2&page_size=10

Query 参数:

参数类型默认值说明
pageint1页码
page_sizeint20每页条数(上限 100)
order_nostring-订单号模糊搜索
statusint-0 待支付 / 1 已支付 / 2 已过期
wallet_addressstring-钱包地址精确匹配
tx_hashstring-交易哈希精确匹配

响应示例:

{
  "code": 0,
  "message": "success",
  "data": {
    "total": 100,
    "page": 1,
    "page_size": 20,
    "orders": [
      {
        "order_no": "202602121430251234",
        "original_amount": "100.00",
        "actual_amount": "100.01",
        "wallet_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
        "status": 1,
        "status_text": "已支付",
        "tx_hash": "abc123...def456",
        "created_at": "2026-02-12T14:30:25Z",
        "expired_at": "2026-02-12T15:00:25Z"
      }
    ]
  }
}

MQ 通知与幂等

支付成功或订单过期,系统往 RabbitMQ 的 order.notify.queue 发消息:

{
  "order_no": "202602121430251234",
  "original_amount": "100.00",
  "actual_amount": "100.01",
  "status": 1,
  "wallet_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
  "tx_id": "abc123...def456",
  "timestamp": 1739356225
}

status 字段:1 = 支付成功,2 = 订单过期。tx_id 仅支付成功时有值。

消费者要注意两件事:

  1. 幂等。RabbitMQ 不保证消息只投递一次,消费端必须以 order_no 做去重,否则重复消息会导致重复发货、重复入账。
  2. 手动 ackauto_ack 必须设为 false,业务处理失败时 Nack 让消息重回队列重试。自动 ack 会在消息被消费后立即确认,如果业务处理抛异常,消息就丢了。

Go 消费示例:

msgs, _ := ch.Consume("order.notify.queue", "my-consumer", false, false, false, false, nil)
for msg := range msgs {
    var notify OrderNotifyMessage
    json.Unmarshal(msg.Body, &notify)
    // 根据 notify.Status 处理业务逻辑
    msg.Ack(false)
}

数据库表

订单表 orders

字段类型说明
order_noVARCHAR(18)订单号(唯一索引)
original_amountDECIMAL(20,2)原始金额
actual_amountDECIMAL(20,2)实际支付金额
wallet_addressVARCHAR(64)收款地址
statusSMALLINT0=待支付, 1=已支付, 2=已过期
tx_hashVARCHAR(128)交易哈希
expired_atTIMESTAMP过期时间

已扫交易表 scanned_transactions

字段类型说明
tx_idVARCHAR(128)交易哈希(唯一,去重用)
from_addressVARCHAR(64)发送地址
to_addressVARCHAR(64)接收地址
amountDECIMAL(30,6)交易金额
block_timestampBIGINT区块时间戳(ms)

scanned_transactions 的作用是去重:同一笔链上交易可能被重复扫到,记录已处理过的 tx_id,避免一笔转账被匹配两次。

环境变量

关键配置项:

变量默认值说明
GRPC_PORT50051gRPC 端口
HTTP_PORT8080HTTP 端口
DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAMElocalhost / 5432 / postgres / password / usdt_payPostgreSQL 连接
RABBITMQ_URLamqp://guest:guest@localhost:5672/RabbitMQ 地址
WALLET_ADDRESS-必填,USDT TRC20 收款地址
TRONGRID_API_URLhttps://api.trongrid.ioTronGrid API
TRONGRID_API_KEY-TronGrid Key,建议配置
SCAN_INTERVAL10扫描间隔(秒)
ORDER_EXPIRE_MINUTES30订单过期时间(分钟)

测试网环境:把 TRONGRID_API_URL 改为 https://api.shasta.trongrid.io(Shasta)或 https://nile.trongrid.io(Nile)。注意测试网和主网的收款地址不同,钱包地址要换成测试网地址。

什么时候该用轮询扫链

这个系统适合的场景:

  • 单地址交易量不大,每分钟几笔到几十笔。
  • 对确认延迟不敏感,10~30 秒内收到通知可接受。
  • 不想依赖外部推送服务,轮询实现简单可控。

不适合的场景:

  • 交易吞吐量高:轮询扫链拉全量交易再逐条匹配,浪费严重,延迟也上来。
  • 对账要求高:需要逐笔核对、处理异常重试、对账报表的场景,轮询的匹配模型太粗,应该用事件驱动 + 事务记录。
  • 低吞吐、低频场景:如果一天就几笔转账,轮询扫链也在空转,不如直接监听 webhook 或事件日志。

另外注意,金额碰撞的规避(+0.01 递增)依赖同一地址下订单的并发控制,如果同时创建大量同额订单,递增序列要保证不冲突。这个系统在订单号生成和查重上做了数据库唯一索引兜底,但并发过高时仍然可能出现匹配延迟。

实现结构

cmd/server/main.go              # 应用入口
internal/
├── config/config.go            # 环境变量配置
├── service/                    # 业务逻辑(订单、链上扫描)
├── mq/rabbitmq.go              # RabbitMQ 封装
└── server/
    ├── grpc_server.go          # gRPC Handler
    └── http_server.go          # HTTP Handler
pkg/trongrid/client.go          # TronGrid 客户端
proto/order/                    # Proto 定义及生成代码

这个项目地址在 https://github.com/tuohai86/usdt-pay-grpc,需要的直接拉代码跑。核心逻辑不算复杂,关键就是金额冲突、扫链间隔、MQ 幂等这三个坑,踩过一遍后面就顺了。

复制全文 生成海报 Go 支付 区块链 RabbitMQ gRPC

推荐文章

程序员茄子在线接单