
简介基于 Java 开发的 TRC20 收款系统面向需要接入波场链稳定币收款的 Java 后端开发者、支付服务商或中小型项目团队是一套可以直接部署运行、也便于二次开发的完整收款服务方案。无论是个人开发者搭建简易收款工具还是团队内快速验证链上收款方案都可以以此项目为起点资源压缩包内共包含 377 个文件包体仅 6.15MB代码主体为 32 个 Java 源码文件同时配有 166 个 JavaScript、35 个 CSS 与 14 个 HTML 等前端资源用于后台页面展示与交互另有 22 个 JSON 配置、13 个 Markdown 说明文档与 1 个 SQL 数据库脚本帮助理解项目结构、初始化数据表。当前已有 274 人学习适合有一定 Java Web 或 Spring Boot 经验的读者参考。通过这套系统可以学习 TRC20 地址生成、链上交易监听与回调、订单状态同步等关键实现思路还能直接复用现成的 Maven 构建脚本与前端监控页面项目自带构建包装脚本导入常用开发环境后即可按说明运行能明显减少从零搭建支付模块的工作量。1. 基于Java的TRC20收款系统先用一句话说清它解决什么问题做跨境收款、海外电商、多商户商城或者个人独立站的朋友大概率都遇到过同一个需求让客户用 USDT-TRC20 直接付款然后自己的业务系统能实时知道“谁付了、付了多少、确认没有”。所谓基于 Java 的 TRC20 收款系统本质就是一个这样的链上收款网关它负责生成收款地址、监听链上转账事件、确认交易、回调业务系统、最后归集资金到主钱包。第一个反直觉的结论是这个系统最核心的难点不在“生成钱包地址”而在“监听和回调的幂等设计”——链上的交易通知不会恰好只来一次你的回调接口必须做好重复请求和丢单补偿。这篇文章不是讲钱包原理而是直接给出一套可以用 Spring Boot MyBatis Plus 落地的工程方案从架构到建表、从监听参数到踩坑记录让新人和熟手都能照着做。2. 为什么用 Java 接 TRC20链上模型与工程选型一起定2.1 TRC20 转账的链上模型事件、确认数与时间戳TRC20 是波场链上的代币标准类似以太坊的 ERC20。一次 TRC20 转账会触发一次Transfer事件事件里包含from、to、value三个核心字段。收款系统要做的监听本质上是持续扫描某个地址收到的Transfer事件然后解析出金额和发送方。这里有个关键前提链上的事件不是“即时最终”的波场出块约 3 秒一个交易被打包后还需要一定数量的区块确认确认数越深交易被回滚的概率越低。工程上最常见的做法是设置 19 个确认约 57 秒或 29 个确认约 87 秒。选多少取决于你对资金风险的接受度如果金额小、追求体验19 个确认够用如果单笔金额大、或者你是做交易所侧的对账建议 29 个以上。确认数不是玄学它直接决定了“回调商户”的时机也决定了你的订单状态机里WAIT_CONFIRM这个状态要挂多久。另一个必须想清楚的是监听窗口。TronGrid 或自建节点的 API 允许你按min_timestamp拉取某个地址的交易记录。你不能每次全量扫必须记住上一次扫到的时间戳或区块号下次从那里继续。否则交易多了以后查询耗时会线性上涨最终拖垮回调的实时性。用 Java 做这块时常见做法是维护一张scan_record表把每个监听地址的最后扫描时间持久化重启不丢。这也决定了你后续的补偿任务该怎么写。2.2 技术选型Spring Boot MyBatis Plus Quartz 的搭配理由既然标题是“基于 Java 开发”我默认你的团队主力语言是 Java落地的骨架选 Spring Boot 是最省力的它的 Starter 生态能快速把 Web 服务、数据源、定时任务都串起来。持久层我用 MyBatis Plus因为这里的订单和地址表结构很固定CRUD 占大头MyBatis Plus 的BaseMapper能少写大量 XML它还支持根据实体类自动生成建表 SQL 的思路配合FieldFill自动填充创建时间和更新时间很适合这种业务表。比语言本身更重要的是两个选型判断。第一监听链上交易这件事优先走 HTTP 轮询 TronGrid API而不是自己搭全节点。自建节点的运维成本高Java 团队未必有区块链运维经验TronGrid 的免费额度对中小规模收款场景够用等量大了再换自建节点也来得及。第二回调通知一定要用本地消息表 定时任务补偿。商户系统可能刚好在重启HTTP 回调可能超时你不能把业务正确性押在一次请求上。用 Quartz 每分钟扫一次未回调成功的订单重发通知这是我最推荐的做法。这套选型的边界也要说清楚它适合“中等频率收款、单机即可支撑”的务场景比如每天几千笔以内的商户收款或电商订单。如果量级到每秒几十笔就需要把监听任务换成消息队列再引入 Redis 做去重复杂度会明显上升。先用简单方案跑通再按瓶颈升级比一开始就上分布式更现实。3. 用 Java 把 TRC20 收款跑通地址生成、交易监听与回调的三段式实现3.1 生成收款地址助记词派生与数据库落表生成 TRC20 收款地址核心是生成波场钱包的私钥和地址。波场地址由公钥经 Keccak-256 哈希后取后 20 字节加上0x41前缀再做 Base58Check 编码得到。工程上不会每次随机生成一个私钥而是用 BIP39 助记词派生保存一份助记词派生出多个收款地址这样备份和恢复都方便。下面是一个生成地址并落库的最小实现。// 生成一个 TRC20 收款地址并写入收款地址表 public Trc20Address createCollectAddress(String merchantId) { // 1. 生成或复用助记词实际项目中助记词应加密后存配置中心或离线保险柜 String mnemonic loadOrCreateMnemonic(merchantId); // 2. 根据助记词派生第 N 个收款地址 int index nextAddressIndex(merchantId); Wallet wallet Wallet.fromMnemonic(mnemonic, , index); String privateKey wallet.getPrivateKey(); String address wallet.getBase58CheckAddress(); // 3. 私钥不落库只把地址和商户关联存入业务表私钥交给归集模块使用 Trc20Address record new Trc20Address(); record.setMerchantId(merchantId); record.setAddress(address); record.setStatus(0); // 0 可用1 已停用 trc20AddressMapper.insert(record); return record; }这段代码里最关键的是私钥不落业务库。收款地址表里存地址、商户 ID、状态即可私钥单独加密存放或者直接交给归集服务持有。原因很现实一旦业务库被拖库私钥泄露等于资金全丢。另一个注意点是nextAddressIndex要保证并发安全不要让两个请求派生出同一个地址。常见做法是在表里加一个address_index唯一约束插入失败就重试下一个索引。参数上还有个容易被忽略的点派生路径。BIP39 本身只定义助记词到种子的规则具体地址派生还要约定路径。TRC20 收款场景多数沿用 BIP44 的风格但不同钱包工具的默认路径可能不同。如果你将来要导入到其他钱包路径不一致会导出一批“看起来对不上”的地址。我一般会在生成地址时把派生路径也存下来避免日后换工具时对不上账。3.2 监听链上交易用 TronGrid API 按时间窗口拉取 Transfer 事件监听 TRC20 交易不需要也没必要实时连节点。TronGrid 提供了按地址查询 TRC20 转账记录的接口支持min_timestamp参数。我们只需要周期性地拉取增量数据然后解析并入库。下面是一个用 RestTemplate 拉取增量交易的示例省掉了一大堆与业务无关的签名逻辑。// 按时间窗口拉取某个地址的 TRC20 转账记录 public ListTrc20Transfer fetchTrc20Transfers(String address, long startTime, long endTime) { String url https://api.trongrid.io/v1/accounts/ address /transactions/trc20; UriComponentsBuilder builder UriComponentsBuilder.fromUriString(url) .queryParam(limit, 50) .queryParam(min_timestamp, startTime) .queryParam(max_timestamp, endTime) .queryParam(only_confirmed, true) .queryParam(contract_address, USDT_TRC20_CONTRACT); ResponseEntityJsonNode resp restTemplate.exchange( builder.toUriString(), HttpMethod.GET, null, JsonNode.class); // 解析 data 数组映射成 Trc20Transfer 对象 return parseTransferList(resp.getBody()); }这段代码有两个参数要特别注意。only_confirmedtrue是为了只拉已确认的交易避免把还在打包中的交易当成成功入账这也是确认数策略的接口侧实现。contract_address必须显式指定 USDT-TRC20 的合约地址否则拉回来的可能是这个地址上的所有 TRC20 代笔转账比如某些空气币也往你地址转了一笔你会误判成收款。另一个参数limit是单页大小如果某个时间窗口内转账特别多需要根据meta里的fingerprint翻页拉完不能只取第一页。轮询频率上我一般建议 10 到 15 秒一次。波场出块 3 秒一个19 个确认需要约 57 秒轮询太频繁只会增加 API 消耗实时性并不会提升多少。处理完一批交易后把max_timestamp写入scan_record表作为下一次的min_timestamp。这里有个边界坑同一个区块内可能有多个交易时间戳相同直接拿最后一条的时间作为游标可能漏掉同一秒内的其他交易。我处理的办法是游标加一个偏移量每次往回多扫 5 秒宁可重复不可缺失。3.3 回调通知与补偿用本地消息表保证商户系统不丢单拉取到交易、解析出金额后最关键的一步是回调商户系统。这里不能把回调写成同步调用否则商户接口慢会拖垮你的监听任务。正确做法是先把回调请求写入notify_record表状态为PENDING然后立即返回。后台由 Quartz 定时任务扫描PENDING的记录向商户的 notify URL 发送 HTTP POST成功则改状态为SUCCESS失败则重试。回调消息体至少要包含订单号、链上交易哈希、金额、确认数和签名。// 回调商户发送签名后的通知并更新本地通知状态 public void sendNotify(NotifyRecord record) { String payload buildNotifyBody(record); // 包含 amount, txHash, merchantId, timestamp String sign hmacSha256(payload, merchantSecretKey); // 商户密钥做 HMAC 签名 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(X-Sign, sign); try { restTemplate.postForEntity(record.getNotifyUrl(), new HttpEntity(payload, headers), String.class); notifyRecordMapper.updateStatus(record.getId(), NotifyStatus.SUCCESS); } catch (Exception e) { // 失败不抛异常保持 PENDING 状态等待定时任务补偿 log.warn(notify failed, id{}, txHash{}, error{}, record.getId(), record.getTxHash(), e.getMessage()); } }这段代码里有两个设计点值得展开。第一是签名商户侧会配置一个密钥你的通知带上时间戳和 HMAC 签名商户验签后才知道这条通知确实来自你的系统而不是有人拿着假交易哈希来撞库。第二是失败处理sendNotify里 catch 住所有异常让记录留在PENDING由 Quartz 下一轮重试。重试次数要设上限我一般默认 5 次超过上限进入FAILED人工介入查证。回调里的一个重点参数是重复回调开关。因为监听逻辑里时间窗口有重叠同一笔交易可能被扫到两次或者补偿任务在成功前又发了一次。解决方式是在notify_record表里对交易哈希加唯一约束入库时冲突就跳过同时在通知体里加txHash商户用自己的订单状态判断是否已处理过。双向幂等才是这个回调链路不出乱子的保证。4. 订单表与状态机设计MyBatis Plus 建表 SQL 与幂等回调解法4.1 核心表结构与 MyBatis Plus 生成建表 SQL收款系统一共就两类核心表地址表和订单通知表。地址表上一节已经提过这里重点说通知表和回调记录的字段设计。通知表我习惯叫trc20_payment它同时承担“订单”和“通知”两个职责一条记录代表一笔收款包含收款地址、金额、发送方、交易哈希和通知状态。字段设计如下。CREATE TABLE trc20_payment ( id bigint NOT NULL AUTO_INCREMENT, merchant_id varchar(64) NOT NULL COMMENT 商户号, address varchar(64) NOT NULL COMMENT 收款地址, from_address varchar(64) DEFAULT NULL COMMENT 付款方地址, amount decimal(30,8) NOT NULL COMMENT 代币金额, tx_hash varchar(128) NOT NULL COMMENT 链上交易哈希, confirmations int NOT NULL DEFAULT 0 COMMENT 确认数, status tinyint NOT NULL DEFAULT 0 COMMENT 0待确认 1已确认 2已回调成功 3回调失败待人工, notify_count int NOT NULL DEFAULT 0 COMMENT 已回调次数, notify_url varchar(512) NOT NULL COMMENT 商户回调地址, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_tx_hash (tx_hash) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTTRC20收款记录表;如果你用的是 MyBatis Plus更省事的做法是先写实体类再让工具生成建表 SQL。MyBatis Plus 本身不直接提供 DDL 生成器但社区里有很多根据实体类注解生成 SQL 的脚本。核心是利用TableName、TableField的注解信息拼接CREATE TABLE语句。这样做的好处是实体类永远和表结构同步不会出现改了字段忘了改库的情况。我一般只在项目初期用一次之后表的变更都走正式的迁移脚本避免自动生成把线上表搞乱。表里amount字段特别提醒用定点数decimal绝不要用float或double。USDT 的最小单位是 6 位小数但某些 TRC20 代币可能有更高的精度用浮点数会在金额累加时产生误差。decimal(30,8)是我这边的保守选择足够容纳大额转账和精度扩展。uk_tx_hash唯一约束是幂等的第一道防线同一笔交易无论被监听任务扫到几次插入都会冲突后续逻辑直接忽略。4.2 订单状态机从待确认到回调成功的三段流转收款订单的状态机不需要设计得太复杂三个阶段足够0 待确认、1 已确认、2 已回调成功。监听任务拉到交易记录后先判断确认数是否达到阈值没达到就更新确认数达到才把状态从0改为1并插入回调记录。这里最容易犯的错是拿到交易就直接回调商户不等待确认。如果链上出现孤立块或重组这笔交易可能被回滚而你已经通知商户“收款成功”了后续对账会非常被动。状态机的核心参数是确认数阈值。我在配置文件里单独放一个trc20.confirm-threshold19不要硬编码在代码里。这样上线初期可以先用较小阈值跑通流程再逐步调大。状态迁移必须是有条件的更新比如“从 0 改 1”要带上WHERE status 0防止同一笔交易被两个线程同时从待确认改成已确认重复触发回调。用 MyBatis Plus 写时UpdateWrapper加上eq(status, 0)条件即可。处理完一笔已确认的收款后下一个动作是创建回调记录。注意回调记录和状态更新要放在同一个事务里如果回调记录插入失败状态更新也要回滚。否则会出现订单已确认、但没有回调任务商户永远收不到通知的情况。这一步的事务边界很多人忽略等线上丢单了才回头看日志发现是两笔写操作没在一处。4.3 幂等回调的两种兜底唯一约束与商户端去重链上交易的唯一约束解决了“同一笔交易入账两次”的问题但补偿任务本身还会触发重复回调。Quartz 每 10 秒扫一次未回调成功的记录如果上一次回调实际成功了但响应超时被 catch 住了这条记录会留在PENDING下一轮还会再发一次。这就是为什么必须在回调通知体里带txHash由商户侧判断这个哈希是否已处理过。代码里我用一个notify_count字段记录回调次数超过 5 次就标记为3 回调失败待人工。同时把最近一次回调时间和响应码记录下来人工介入时能快速判断是商户接口坏了、还是我们的签名没过。另外一个细节是回调超时时间要设短一点我一般用 3 秒连接超时和 5 秒读取超时。回调是为了通知商户不是等着商户做后续业务处理长时间占用线程没必要。5. TRC20 收款系统常见的 5 个坑从丢单、重复回调到归集失败5.1 监听与回调环节的坑坑一监听游标用最后一条交易的时间戳导致同块内漏单。现象是某段时间的收款数量与商户后台对不上总少那么几笔。原因是同一个区块内多笔交易共享相同的block_timestamp你把游标推进到最后一笔的时间下一轮从这一刻开始扫恰好把同一时刻的较早交易漏掉。解决方法是游标每次回拨 5 秒或者干脆用block_number作为游标而不是时间戳。用时间戳的话min_timestamp需要留出重叠窗口。坑二收到非 USDT 的 TRC20 代币转账也被当成收款。现象是商户后台出现一笔金额离谱的“收款”链上确实有这笔转账但不是 USDT 合约发的。原因是拉取 TRC20 交易时没过滤contract_address任何部署在这个链上的代币转账都会被拉回来。解决方法是请求参数显式带contract_addressUSDT 合约地址同时在代码里再校验一次代币类型双保险。这个坑在测试环境特别容易踩因为测试网上的空气币很多。坑三回调通知里金额精度被截断。现象是商户收到回调后对不上账金额差 0.000001。原因是decimal转 JSON 时被 Jackson 默认按科学计数法或 double 处理丢失了精度。解决方法是让金额字段在实体类里用BigDecimal并在 Jackson 配置里写ToStringSerializer或者关闭科学计数法。所有涉及金额的序列化都要走同一套配置不要有的字段用BigDecimal有的用String乱。5.2 地址安全与归集环节的坑坑四私钥和地址表放在同一个库里一次拖库全没。现象听上去极端但真的有不少团队这么干。原因是开发图省事生成地址时直接把私钥字段加在了地址表上。解决方法是私钥从生成那一刻起就与业务库隔离可以写入独立的加密存储或者干脆由归集模块单独管理助记词。业务库的地址表只存地址、商户 ID 和状态即使泄露攻击者也只能看到地址清单动不了资金。坑五归集时没预留足 TRX 矿工费导致归集交易一直卡住。现象是归集任务把地址里的 USDT 都转走了但链上交易没确认因为地址里的 TRX 不够支付这次转账的能量和带宽费用。原因是转 TRC20 代币也需要消耗 TRX地址里只剩 USDT 没有 TRX 就无法发起交易。解决方法是归集前先检查地址的 TRX 余额留足矿工费再转或者用一个专门的“矿工费地址”定期给收款地址打 TRX保证归集永远能发出交易。这个小细节最容易在上线后被忽略等到第一笔归集失败才想起来。6. 灰度上线前先做的验证模拟交易、对账脚本与冷备地址系统写完后别急着把主钱包地址放上去先用一组小额验证手段跑通全链路。我的习惯是准备两个测试地址一个作为付款方一个作为收款方从交易所或钱包里转一笔最小金额的 USDT比如 1.5 USDT故意带个小数位验证金额解析的精度。转完以后盯着监听任务的日志看从交易被打包到回调发出经过了多久、确认数走了几次、回调有没有重试。第一次跑通后再连续转三笔验证监听游标的推进和uk_tx_hash对重复扫描的拦截。验证完链路写一个对账脚本。对账的思路很简单把波场浏览器上某个收款地址的 USDT 转账记录拉下来和本地trc20_payment表的记录做比对看数量、金额和哈希能否一一对上。我一般按小时对一次用 SQL 聚合出本地记录数和链上记录数的差异。这个脚本不一定要很复杂但必须能输出差异明细否则对账就流于形式。特别是从测试转灰度的那几天每天跑一次对账心里才有底。最后提一个进阶做法冷备地址。主钱包地址不要频繁动用私钥把私钥和助记词离线保存归集目标地址可以设为主钱包地址收款地址每日归集到主钱包后再由冷钱包签名转出。如果你不想在服务器上碰冷钱包私钥可以退一步用“观察钱包”模式只导入地址不导入私钥定期用脚本检查余额需要动用资金时再人工操作。这个做法牺牲了一点自动化程度但换来了私钥不落服务器的安全性。我自己的习惯是永远在服务器上保留一把只读观察地址任何需要私钥的操作都走人工双人复核。这套系统的每一步从地址生成到回调再到归集都应该留出人工干预的位置而不是全自动黑匣子。希望帮到你。本文还有配套的精品资源点击获取