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);
}
请求字段就一个:
| 字段 | 类型 | 说明 |
|---|---|---|
amount | string | 原始支付金额,如 "100.00" |
响应字段:
| 字段 | 类型 | 说明 |
|---|---|---|
order_no | string | 18 位订单号 |
actual_amount | string | 实际需支付金额(展示给用户) |
original_amount | string | 原始请求金额 |
wallet_address | string | 收款钱包地址 |
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 参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | int | 1 | 页码 |
page_size | int | 20 | 每页条数(上限 100) |
order_no | string | - | 订单号模糊搜索 |
status | int | - | 0 待支付 / 1 已支付 / 2 已过期 |
wallet_address | string | - | 钱包地址精确匹配 |
tx_hash | string | - | 交易哈希精确匹配 |
响应示例:
{
"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 仅支付成功时有值。
消费者要注意两件事:
- 幂等。RabbitMQ 不保证消息只投递一次,消费端必须以
order_no做去重,否则重复消息会导致重复发货、重复入账。 - 手动 ack。
auto_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, ¬ify)
// 根据 notify.Status 处理业务逻辑
msg.Ack(false)
}
数据库表
订单表 orders:
| 字段 | 类型 | 说明 |
|---|---|---|
order_no | VARCHAR(18) | 订单号(唯一索引) |
original_amount | DECIMAL(20,2) | 原始金额 |
actual_amount | DECIMAL(20,2) | 实际支付金额 |
wallet_address | VARCHAR(64) | 收款地址 |
status | SMALLINT | 0=待支付, 1=已支付, 2=已过期 |
tx_hash | VARCHAR(128) | 交易哈希 |
expired_at | TIMESTAMP | 过期时间 |
已扫交易表 scanned_transactions:
| 字段 | 类型 | 说明 |
|---|---|---|
tx_id | VARCHAR(128) | 交易哈希(唯一,去重用) |
from_address | VARCHAR(64) | 发送地址 |
to_address | VARCHAR(64) | 接收地址 |
amount | DECIMAL(30,6) | 交易金额 |
block_timestamp | BIGINT | 区块时间戳(ms) |
scanned_transactions 的作用是去重:同一笔链上交易可能被重复扫到,记录已处理过的 tx_id,避免一笔转账被匹配两次。
环境变量
关键配置项:
| 变量 | 默认值 | 说明 |
|---|---|---|
GRPC_PORT | 50051 | gRPC 端口 |
HTTP_PORT | 8080 | HTTP 端口 |
DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME | localhost / 5432 / postgres / password / usdt_pay | PostgreSQL 连接 |
RABBITMQ_URL | amqp://guest:guest@localhost:5672/ | RabbitMQ 地址 |
WALLET_ADDRESS | - | 必填,USDT TRC20 收款地址 |
TRONGRID_API_URL | https://api.trongrid.io | TronGrid API |
TRONGRID_API_KEY | - | TronGrid Key,建议配置 |
SCAN_INTERVAL | 10 | 扫描间隔(秒) |
ORDER_EXPIRE_MINUTES | 30 | 订单过期时间(分钟) |
测试网环境:把 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 幂等这三个坑,踩过一遍后面就顺了。