简介:一套基于Java开发的TRC20(USDT)收款系统完整工程,面向支付开发人员、区块链应用学习者以及需要快速接入泰达币收款能力的Java工程师。系统围绕链上转账监控、订单生成、回调确认等环节,覆盖从用户扫码付款到后台审核到账的完整业务链路。资源包共377个文件,压缩后仅6.15MB,整体紧凑;其中32个Java类承担后端业务逻辑与链上交互,166个JS脚本和14个HTML页面搭建起管理后台与收银台界面,CSS样式及图标文件用于前端美化,22个JSON文件保存配置参数,另有SQL建表脚本、Maven构建描述和Markdown说明文档,目录层次清晰,标注明确。当前已有274人学习下载,对理解TRC20支付对接流程有直接帮助。通过这套工程,开发者可以获得可运行的前后端源码、数据库初始化脚本、常用配置文件及链上监听和订单回调的参考实现,既能直接部署试用,也可按需改造,低成本接入自有业务系统。
1. TRC20 收款系统不是“生成地址等入账”那么简单:监听、确认和回调才是真正的重活
做 TRC20 收款系统,最容易踩的认知误区是:生成一个收款地址、把二维码贴到页面上,剩下交给钱包就行了。实际上地址和二维码只是工作量的小头,真正决定系统上线后丢不丢单的,是后半截链路——区块监听、交易确认、金额校验、回调业务系统。这套基于 Java 开发的 TRC20 收款系统,就是围绕 Spring Boot 把这条链路做成完整闭环的工程。压缩包里的 mvnw.cmd 和一组后台前端样式文件,说明它是标准 Maven 工程搭配管理端界面。适合需要在自有系统里接入 TRC20 入账回调的开发者,也适合想搞懂区块链支付回调怎么落地的人。
2. 工程形态拆解:Spring Boot 骨架、Maven Wrapper 与后台前端资源
2.1 压缩包里的文件在真实项目中的角色
先看压缩包里的文件,它们把工程形态拼得很清楚。表面上是零散的静态资源,实际上每一份都对应真实项目里的一个明确位置。
| 文件 | 在项目里的角色 |
|---|---|
| mvnw.cmd | Maven Wrapper 的 Windows 执行脚本,把 Maven 版本锁死在项目配置里 |
| bootstrap.min.css | 后台页面的 UI 基础框架,栅格、卡片、表格样式全靠它 |
| materialdesignicons.min.css | Material Design 风格图标库,菜单和按钮上的图标 |
| animate.min.css | 页面过渡动画样式,弹窗和提示层的进出场效果 |
| main.css / main.min.css | 后台框架主样式,侧边栏、顶部栏、内容区布局都在这 |
| bootstrap-datepicker3.css | 日期选择控件样式,订单列表的时间范围筛选 |
| jquery-confirm.min.css | 弹窗确认组件,删除订单、手动补单的前端交互 |
这套资源组合是后台管理界面非常标准的搭配,对应到代码里就是 Spring Boot + Thymeleaf + Bootstrap 的风格,工程里通常还会配合 MyBatis-Plus 操作 MySQL。mvnw.cmd 在工程根目录,说明构建方式走 Maven Wrapper;那一批 CSS 文件一般落在 src/main/resources/static 下的 assets 或 vendor 目录里,模板引擎渲染时直接引用,不用走 Webpack 那套构建链路。
有一点要提醒:如果拿到压缩包发现只有静态资源和 wrapper 脚本,没有 pom.xml 和 src 目录,不用慌。这通常是发布打包时只选了资源目录,或者源码没有一起归档。正确做法是先按第 3 章的配置搭一个标准 Spring Boot 骨架,再把这些文件放回 static 目录,页面就能正常渲染。
2.2 四个功能模块和一条完整交易链路
这个系统从业务上切,可以分成四个模块:收单模块、后台管理模块、区块监听模块、回调通知模块。
收单模块负责向业务方提供一个申请收款地址的接口。调用方传订单号、期望金额、过期时间,后端生成一个 TRC20 地址并落库,同时用 zxing 生成二维码返回给前端展示。后台管理模块就是订单列表、支付状态查询、日期筛选、手动补单这些操作,压缩包里的 datepicker 和 confirm 弹窗就是为这类交互准备的。
区块监听模块是整个系统的黑匣子,也是最容易出问题的地方。它用定时任务反复轮询 TRON 网络,找出落在收款地址上的 USDT 转账,校验合约地址和金额,再更新订单状态。回调通知模块在状态更新成功后,把交易 ID、收款地址、实收金额 POST 到业务系统指定的接口,并且要带重试和幂等处理,否则网络抖动一次就丢单。
完整链路是这样的:用户点支付 → 后端生成地址并落订单 → 页面展示二维码 → 用户从钱包转 USDT → 监听任务扫到交易 → 过滤合约地址和收款地址 → 等到确认数达标 → 更新订单状态 → 回调业务系统 → 业务方发货或入账。这条链路里最薄弱的环节是 5 到 8,后面第 4 章和第 5 章会专门展开。
2.3 为什么用公共节点 API 而不是自建全节点
做链上监听,先要解决“去哪读链上数据”的问题。自建 TRON 全节点意味着要同步全量区块,首次同步花几个小时是常事,磁盘和带宽占用都不小,之后还得长期维护节点进程,掉线几小时再追块非常痛苦。这种投入对小团队或刚接入链上支付的业务来说性价比很低。
常见做法是先用 TronGrid 这类公共 API 把业务跑通。拿最新块高度用 /wallet/getnowblock,拿某个区块的交易列表用 /wallet/getblockbynum,拿单笔交易回执用 /wallet/gettransactioninfobyid,三个接口就能覆盖监听主流程。公共 API 的代价是限流,免费档对单 IP 的 QPS 有约束,轮询间隔太短容易被限。这也是为什么第 4 章的扫描任务按 3 秒间隔跑,而不是 500 毫秒。
等业务量真正上来,再把上游替换成内网自建 fullnode 加负载均衡,业务代码不需要大改,只替换 RPC 客户端实现即可。对这套系统来说,“先公共 API、后自建节点”是投入产出比最高的演进路线。
3. 本地跑通:mvnw.cmd 构建、application.yml 参数与首次入账验证
3.1 用 Maven Wrapper 构建,绕开本机 Maven 版本差异
mvnw.cmd 是 Maven Wrapper 的 Windows 脚本,作用是让项目依赖的 Maven 版本和源码一起固定下来。团队里有人用 3.6、有人用 3.9,构建时经常出现莫名其妙的依赖差异,根源多半是全局 Maven 版本不一致。用 Wrapper 之后,第一次执行会自动下载配置里指定的 Maven 版本,之后所有人构建环境都一样,属于软件工程里“构建可复现”的兜底手段。
在 Windows 命令行进入工程根目录,执行:
mvnw.cmd clean package -DskipTests先 clean 清理 target,再 package 打包。加 -DskipTests 是跳过测试执行,但测试类仍然会编译;如果连测试代码编译都想省掉,用 -Dmaven.test.skip=true。本地验证阶段用 -DskipTests 能省不少时间,提交到打包机时再跑完整测试。
需要注意压缩包里只有 .cmd 脚本,说明开发环境是 Windows。Linux 服务器上部署时没有 mvnw 可执行文件,要么直接用全局 mvn 构建,要么补一个 mvnw 脚本再继续。多数情况下直接mvn clean package -DskipTests就行,只要本机 Maven 版本和项目要求接近,构建结果差异可以接受。
3.2 三组关键配置:节点 API、数据库、回调 URL
启动之前先把配置文件里的三组参数改对。以 Spring Boot 的 application.yml 为例:
spring: datasource: url: jdbc:mysql://localhost:3306/trc20_pay?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver tron: node: api-url: https://api.trongrid.io api-key: your_tron_api_key usdt-contract-address: "your_contract_address" confirm-blocks: 6 scan-interval-ms: 3000 read-timeout-ms: 5000 callback: notify-url: http://your-biz-server/api/trc20/notify retry-count: 3spring.datasource 是常规的 MySQL 配置,注意 characterEncoding 用 utf8,serverTimezone 用 Asia/Shanghai,否则日期字段读写会出现 8 小时偏差。tron.node.api-url 是链上接口的根地址,主网用官方公共 API 即可,测试网换成测试网节点地址。usdt-contract-address 是整个监听的过滤条件,必须按测试网和主网分别配置,这个地址在两条链上不一样,很多人上线后收不到回调就是栽在这里。
confirm-blocks 决定一笔交易要等多少个确认才算可信。scan-interval-ms 是轮询间隔,3 秒是比较保守的值,既能保证入账时效,又不容易把公共 API 打到限流。callback.notify-url 是业务系统接收入账通知的地址,接口必须支持 POST JSON,并且要处理好重复通知,这个幂等细节在第 5 章会讲。
3.3 启动应用与首笔测试交易验证
配置改好后,用 Maven Wrapper 直接启动:
mvnw.cmd spring-boot:run或者先打包再运行:
mvnw.cmd clean package -DskipTests java -jar target/trc20-pay-1.0.0.jar应用起来之后,先确认健康检查接口通不通:
curl http://localhost:8080/actuator/health返回{"status":"UP"}说明 Spring Boot 容器没问题。下一步做首笔测试交易:调用收单接口申请一个收款地址,从测试网水龙头领一点测试 USDT,转给这个地址,然后盯着应用日志。正常流程下几秒内会出现类似“收到 USDT 入账,txId=xxx,amount=xxx”的日志,然后业务系统收到回调 POST。
如果日志里什么都没有,先从三个方向排查:一是收款地址有没有被监听任务加载进内存缓存,二是合约地址配置和测试网是否一致,三是轮询任务有没有真正启动。这三个问题占了首测失败原因的大头。
4. 入账监听实现:轮询最新区块、解析 TRC20 转账、确认数判断
4.1 监听方案选型:事件订阅 vs 区块轮询
做 TRON 链上的入账监听,主流有两种思路:事件订阅和区块轮询。放在一起对比会更清楚:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 轮询 getnowblock | 实现简单,公共 API 稳定 | 有最多一个出块周期的延迟 | 中小项目首选 |
| 事件订阅 | 实时性最好 | 公共 API 上连接不稳定,断线补事件麻烦 | 不推荐在公共 API 上依赖 |
| 自建节点 + 订阅 | 完全自主可控 | 运维成本高 | 大交易量业务 |
实际项目中,事件订阅在公共 API 上的表现并不稳定,断连之后要自己处理事件补拉,逻辑复杂度并不比轮询低。轮询的延迟最多也就一个出块周期,TRON 平均 3 秒一个块,对收款系统来说完全够用。做轮询有一个核心原则:记住已经处理到哪个区块高度,而不是每次都从最新块往回翻。这个“已处理高度”通常称为水位线,后面第 6 章会专门展开它的进阶用法。
4.2 写一个区块扫描任务:从最新块到已确认块
扫描任务是整个系统的心脏。先看整体结构:
@Service @Slf4j public class BlockScanTask { private final TronRpcClient tronRpcClient; private final OrderMapper orderMapper; private final AddressCache addressCache; private final NotifyService notifyService; @Value("${tron.node.confirm-blocks}") private int confirmBlocks; @Scheduled(fixedDelayString = "${tron.node.scan-interval-ms}") public void scan() { long lastConfirmed = orderMapper.selectLastConfirmedBlock(); long nowHeight = tronRpcClient.getNowBlockHeight(); if (lastConfirmed == 0L) { lastConfirmed = nowHeight - confirmBlocks - 1; } long scanEnd = nowHeight - confirmBlocks; if (scanEnd <= lastConfirmed) { return; } for (long h = lastConfirmed + 1; h <= scanEnd; h++) { List<String> txIds = tronRpcClient.getBlockTxIds(h); for (String txId : txIds) { handleTx(txId, h); } orderMapper.updateConfirmedBlock(h); } } }这个方法有四个关键设计。第一,lastConfirmed从数据库读取,而不是从内存变量拿,这样应用重启后不会把旧块重新扫一遍,也不会漏掉重启期间的新块。第二,scanEnd = nowHeight - confirmBlocks保证只有达到确认数要求的区块才会被处理,避免把可能被回滚的交易回调出去。第三,每处理完一个区块立即更新水位线,即使中途崩溃,下次启动也能从断点继续。第四,fixedDelayString从配置读取,调轮询间隔不用改代码。
单笔交易的处理逻辑在handleTx里,这里是防漏单的核心:
private void handleTx(String txId, long blockHeight) { TransactionInfo info = tronRpcClient.getTransactionInfo(txId); if (info == null || !"SUCCESS".equals(info.getRet())) { return; } if (!usdtContractAddress.equalsIgnoreCase(info.getContractAddress())) { return; } String toHex = parseTransferToHex(info.getLogs()); if (!addressCache.containsHex(toHex)) { return; } BigInteger rawAmount = parseTransferValue(info.getLogs()); BigDecimal amount = new BigDecimal(rawAmount).movePointLeft(6); int updated = orderMapper.markPaid(txId, toHex, amount, blockHeight); if (updated == 1) { notifyService.notifyPaid(txId, toHex, amount); } }这里的过滤逻辑按顺序做了四道闸门。第一道闸门看交易回执状态,只有SUCCESS才代表转账真正成功,FAILED或REVERT直接丢弃。第二道闸门校验合约地址,不校验的话,用户给地址转的 TRX 或其它 TRC20 代币都会被当成 USDT 入账。第三道闸门解析 Transfer 事件的 to 地址,并且和本地缓存的收款地址做匹配,这一步将大量无关交易挡在门外。第四道闸门用markPaid做条件更新,只有订单状态还是待支付时才会更新成功,返回值是 1 才发回调,这从源头上避免了重复回调。
TronRpcClient 是对三个核心接口的封装,骨架如下:
public long getNowBlockHeight() { String body = httpPost("/wallet/getnowblock", "{}"); return JsonPath.read(body, "$.block_header.raw_data.number"); } public List<String> getBlockTxIds(long height) { String body = httpPost("/wallet/getblockbynum", "{\"num\":" + height + "}"); return JsonPath.read(body, "$.block.transactions[*].txID"); } public TransactionInfo getTransactionInfo(String txId) { String body = httpPost("/wallet/gettransactioninfobyid", "{\"value\":\"" + txId + "\"}"); // 解析 ret、contract_address、log 数组 }实际工程里 HTTP 客户端用 OkHttp 或 Hutool 都行,重点是三个接口的职责要分开。getnowblock 只拿最新高度,getblockbynum 只拿区块里的交易 ID 列表,gettransactioninfobyid 只拿单笔交易的执行回执。
4.3 确认数设多少才不回滚:6 还是 19
确认数这个参数,是链上监听里最讲究的配置之一。TRON 平均 3 秒出一个块,由超级代表轮流出块。一笔交易被打包进区块后,这个区块深度越深,被孤块回滚的概率越低。确认数的定义是当前链高度减去交易所在区块高度,设 6 意味着要等交易所在块后面再长出 6 个新区块,约 18 秒;设 19 约 57 秒。
为什么有人坚持设 19?TRON 一轮有 27 个超级代表,如果某个代表出块后网络出现短暂分叉,被孤块包含的交易会被回滚,回滚后 gettransactioninfobyid 的 ret 会变成 FAILED,或者直接查不到。对支付类业务来说,回调发出去就意味着业务系统要发货或入账,一旦交易回滚,损失难以追回。稳妥的做法是:小额高频场景设 6 作为底线,大额或“发货后不可逆”的业务设 19 甚至更高。
这个参数不要拍脑袋定。上线前用小额交易实测一下从转账到回调的耗时,确认数 6 时约 20 秒内完成,确认数 19 时接近 1 分钟,项目方根据业务容忍度选值即可。
5. 避坑与排查:TRC20 收款系统最常见的五个翻车现场
5.1 金额精度丢失:链上 0.01 入库变 0.009999
现象:用户转账 0.01 USDT,订单表里存的是 0.0099990001,金额对不上账。
原因:链上返回的金额是最小单位。USDT-TRC20 精度是 6,转账 0.01 USDT 在链上表示为 10000。代码里如果图省事用 double 做除法,浮点数误差就会带进数据库。
解决:只在字符串和 BigDecimal 之间转换,全程不碰 double。
// 错误示范 double amount = rawAmount.doubleValue() / 1_000_000; // 正确做法 BigDecimal amount = new BigDecimal(rawAmount).movePointLeft(6);用new BigDecimal(rawAmount)而不是BigDecimal.valueOf(...),是因为前者直接接收整数原始值,不经过浮点转换。落库时再用setScale(2, RoundingMode.HALF_UP)保留两位小数,这样入库的金额永远符合账务习惯。
5.2 重复回调:同一笔交易业务方收到两次已支付
现象:订单状态已经变成已支付,业务系统又收到一次内容相同的支付通知。
原因:扫描任务和超时补单任务同时跑,或者应用重启后水位线没更新,导致同一区块被扫了两遍。
解决:数据库做双重保险。订单表加(tx_id, address)唯一索引,更新时用条件更新,而不是先查再更新:
UPDATE t_order SET status = 'paid', confirm_height = ? WHERE tx_id = ? AND status = 'pending'这条 SQL 的影响行数只有 1 才代表当前线程抢到了更新权,为 0 说明订单已经被处理过,直接跳过回调。配合这个机制,回调逻辑里再加一个“本次更新行数为 0 时不发通知”的判断,重复回调基本能被挡死。
5.3 把 TRX 转账或其它 TRC20 代币当成 USDT 入账
现象:用户给收款地址转了 100 TRX,系统竟然回调“收到 100 USDT”。
原因:监听逻辑只匹配了收款地址,没有校验交易发生的合约地址。TRON 链上所有代币转账都走智能合约,不校验合约地址就会把无关转账收进来。
解决:在 handleTx 里增加强制性校验,用于配对的 USDT 合约地址必须精确匹配:
if (!usdtContractAddress.equalsIgnoreCase(info.getContractAddress())) { return; }注意这里要用 equalsIgnoreCase,因为 API 返回的地址可能大小写和配置不一致。上线前把测试网和主网的合约地址分别准备两份,不要复用同一个常量。
5.4 订单金额 100 USDT,用户转了 99.5 也被当成已支付
现象:用户实际转少了钱,订单被标记为已支付,业务方发了货才发现金额不够,两边扯皮。
原因:TRC20 的二维码本质上只是一个地址码,不携带金额信息,任何拿到二维码的人都可以自由填转账金额。监听逻辑只确认“有入账”,没确认“金额对不对”。
解决:回调确认入账后,拉出订单期望金额做比对。实收金额小于期望金额时,订单状态不要置为已支付,而是置为“金额不符”,等待人工处理;实收金额大于期望金额时按订单金额入账,差额挂账,等用户或业务方协商处理。状态机里至少要有 paid、amount_mismatch、manual_review 三个状态,能省掉很多售后纠纷。
5.5 本地测试全通过,上线一单都收不到
现象:测试网一切正常,切到生产环境后一整天没有一笔回调。
原因:api-url 或合约地址配的是测试网的。TRON 测试网和主网不仅节点地址不同,USDT 合约地址也不一样,在测试网生成的收款地址在主网是无效的,反过来也一样。
解决:把环境配置彻底隔离,一份application-test.yml、一份application-prod.yml,节点地址、合约地址、回调地址、数据库全部分开:
java -jar trc20-pay.jar --spring.profiles.active=prod部署脚本里强制用 prod 配置启动生产环境,不允许手动覆盖配置项。我一般还会加一个启动自检:应用起来后先调用一次节点接口,把当前连接的链 ID 或最新块高度打到日志里,一眼就能看出连的是测试网还是主网,这算是链上应用的血泪经验。
6. 进阶:用已确认区块水位线做增量扫描,根治漏单与重复扫描
水位线这个概念,在第 4 章已经出现过,就是数据库里保存的“最后一个已确认区块高度”。这个值不只承担断点续扫的功能,还能变成每日对账的补偿机制,这是让我受益最多的设计。
常规扫描任务是增量式的:从lastConfirmed + 1扫到nowHeight - confirmBlocks,处理完一个块就更新水位线。这个机制能防止重复扫描,但它有一个盲区——如果某个区块高度被更新了水位线,但该区块内某一笔交易的回调通知因为网络问题发失败了,扫描任务不会主动重发。解决这个盲区的办法是每日对账。
对账任务每天凌晨跑一次,找出状态仍然待支付但创建时间超过一天的订单:
SELECT order_no, address, amount, create_time FROM t_order WHERE status = 'pending' AND create_time >= CURDATE() - INTERVAL 1 DAY拿到这些订单后,用地址反向去链上查这笔时间窗口内的转账记录。查到了就说明链上已经到账,只是回调链路出了问题,触发补偿通知;查不到说明用户确实没转,订单继续等待。这个逻辑弥补了“水位线已过但回调失败”的窗口,是线上保命的兜底手段。
水位线本身也要注意存储方式。建议单独建一张scan_watermark表,字段就三个:id、block_height、update_time。不要复用订单表扩散字段来存水位线,否则 SQL 语义会混乱。更新水位线和标记订单支付要放在同一个事务里:
@Transactional public void blockScanned(long height, List<String> paidTxIds) { // 更新订单状态 // 更新水位线 }事务性保证了要么订单状态和水位线一起更新,要么一起回滚,不会出现“订单已支付但水位线没动”的中间状态。
从那以后,我做任何链上监听系统都强制走三件套:水位线落库、唯一索引保幂等、每日对账兜底。这套 TRC20 收款系统的线上稳定性,就是靠这条小习惯撑起来的,希望帮到你。
本文还有配套的精品资源,点击获取