
简介一套基于Java开发的TRC20USDT收款系统完整工程面向支付开发人员、区块链应用学习者以及需要快速接入泰达币收款能力的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.cmdMaven Wrapper 的 Windows 执行脚本把 Maven 版本锁死在项目配置里bootstrap.min.css后台页面的 UI 基础框架栅格、卡片、表格样式全靠它materialdesignicons.min.cssMaterial 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.skiptrue。本地验证阶段用 -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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 用 utf8serverTimezone 用 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 入账txIdxxxamountxxx”的日志然后业务系统收到回调 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) { ListString 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 ListString 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 秒。为什么有人坚持设 19TRON 一轮有 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.activeprod部署脚本里强制用 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, ListString paidTxIds) { // 更新订单状态 // 更新水位线 }事务性保证了要么订单状态和水位线一起更新要么一起回滚不会出现“订单已支付但水位线没动”的中间状态。从那以后我做任何链上监听系统都强制走三件套水位线落库、唯一索引保幂等、每日对账兜底。这套 TRC20 收款系统的线上稳定性就是靠这条小习惯撑起来的希望帮到你。本文还有配套的精品资源点击获取