简介:这份运营级Java综合交易所源码以zip压缩包形式发布,包体约325.36MB,面向具备Java部署能力的开发者、技术团队及金融科技学习者。系统支持13国语言,PC端与H5端一体化设计,自带客服系统,目前开放外汇与区块链两大交易模块,并包含C2C、理财、质押、交割、永续等业务功能(不含期货),覆盖常见数字资产交易场景。包内为完整前后端工程及客服系统组件,部署依赖MySQL 5.6与Redis,可直接搭建测试环境进行二次开发;由于上游未给出文件总数和类型清单,实际内容以源码、配置及前端资源为主,整体结构围绕交易核心展开,便于按模块理解C2C、理财、质押、交割等业务逻辑。目前已有128人学习下载,适合想低成本获取可运行交易系统原型、研究多语言国际化实现或扩展交易所功能的Java开发者。
1. 一套 Java 写的运营级交易所:股票、区块链、外汇同台跑,还内置客服
拿到一套能同时跑股票、区块链币币和外汇的 Java 源码,第一反应是找它的订单撮合模块和资金账本在哪。拆过单市场的项目都知道,股票和外汇的账户体系、行情频率、手续费模式差得远,区块链现货还要单独处理充提币和链上确认,硬塞进一个系统里很容易变成四不像。这套运营级综合交易所源码的看点是三类业务共用同一套撮合引擎、统一账本和客服后台,13 国语言做在框架层而不是硬编码页面。适合需要快速搭起多资产交易平台验证业务的团队,也适合想研究主流交易所核心模块怎么组织的 Java 工程师。下面按架构、账本、行情接入、部署避坑的顺序拆开讲。
2. 先拆架构:模块边界、撮合引擎与账本一致性从哪下手
2.1 工程结构看系统分层:gateway、market、trade、account 四块
我拿到源码第一件事是看根目录的 pom.xml 和 modules 标签。运营级系统和练手项目最大的区别是模块边界清楚,每块能独立部署、独立扩缩容。这套源码按常规组织方式拆成四个核心模块:gateway 管接入,market 管行情,trade 管订单和撮合,account 管钱包账户。客服系统挂在账号体系下,消息走 gateway 的 WebSocket 通道。
先别急着跑,把根 pom.xml 的 modules 列出来,核对每块是不是独立的 Spring Boot Application,而不是互相耦合的 jar。我见过不少源码把 service 层直接塞在 controller 里,号称微服务其实是一个单体包结构,这也不算硬伤,但扩容和灰度发布时会很痛苦。另一个要看的是中间件:Redis 用在哪几个模块、MySQL 的分库分表到了没到账本层、消息队列是 Kafka 还是 RocketMQ,这些决定团队能不能自己运维。
| 模块 | 职责 | 典型内容 |
|---|---|---|
| gateway | 接入层 | 登录、鉴权、限流、WebSocket 网关 |
| market | 行情系统 | K 线生成、订单簿快照、行情推送 |
| trade | 交易核心 | 撮合引擎、订单生命周期、手续费计算 |
| account | 资金账本 | 钱包余额、冻结、流水、充提币确认 |
模块之间通信用 REST 还是异步事件,从接口调用关系能看出来。订单提交走 REST 进 trade 模块,成交回报应该走 Kafka 或 RocketMQ 通知 account 和 market,而不是同步 RPC 等结账完成——那样会把撮合的 TPS 拉低一个数量级。源码里如果看到下单接口直接 RPC 调用 account 扣款再本地撮合,属于还有优化空间的实现,能跑但不耐压。
注意:部署文件里如果只有 docker-compose.yml 而缺少数据库初始化脚本,大概率要在 docs/sql 或 resource/sql 下找建表语句,找不到就要做好补表的心理准备。
2.2 撮合引擎:内存订单簿、快照恢复与延迟指标
撮合引擎这部分,运营级项目都有一个共同选择:内存撮合。所有订单簿和活跃订单放在 JVM 内存里,数据库只做最终落库和账本流水,撮合过程中不碰数据库。为什么?因为磁盘 IO 和行锁会让撮合吞吐掉到每秒几百笔,内存撮合配无锁队列能上千。代价是宕机恢复要依赖快照,所以源码里一般会有定期的订单簿快照加事务日志重放机制。
看撮合代码时先找订单簿的数据结构。Java 里常用两组优先队列:买盘价格降序、卖盘价格升序,同价格按到达序号排。两个细节决定性能:一是比较器是否包含 sequence 字段,不加它的话同价订单不能保证先来先成交,用户会投诉“我挂的单为什么被后面的人先成交”;二是订单数量级上去后 PriorityQueue 的入队出队是否成为瓶颈,有的实现会在热点交易对上改成红黑树或跳表来避免频繁堆操作。绝大多数场景 PriorityQueue 够用,先跑通再优化是正路。
public class MatchingEngine { // 买盘:价格降序,同一价格按到达顺序排 private final PriorityQueue<Order> buys = new PriorityQueue<>( Comparator.comparing(Order::getPrice).reversed() .thenComparing(Order::getSequence)); // 卖盘:价格升序,同一价格按到达顺序排 private final PriorityQueue<Order> sells = new PriorityQueue<>( Comparator.comparing(Order::getPrice) .thenComparing(Order::getSequence)); public List<Trade> match(Order incoming) { List<Trade> trades = new ArrayList<>(); while (!incoming.isFilled()) { Order counter = incoming.isBuy() ? sells.peek() : buys.peek(); if (counter == null || !priceCross(incoming, counter)) break; long qty = Math.min(incoming.getRemaining(), counter.getRemaining()); counter.reduce(qty); incoming.reduce(qty); trades.add(new Trade(symbol, counter.getPrice(), qty, System.currentTimeMillis())); if (counter.getRemaining() == 0) { if (incoming.isBuy()) sells.poll(); else buys.poll(); } } if (incoming.getRemaining() > 0) { if (incoming.isBuy()) buys.add(incoming); else sells.add(incoming); } return trades; } }这是撮合引擎的最小骨架。priceCross 处理方向:买单一侧只匹配卖盘价格小于等于自身出价的单子,卖单只匹配买盘价格大于等于自身报价的单子。成交价取对手单的价格,这在限价单场景叫 maker 优先,避免频繁改价。getRemaining() 返回剩余未成交数量,撮合循环里每次取 min 保证双方都不超量,最后剩余部分重新进订单簿。运营级实现会进一步处理撤单中断、盘口冷启动、策略交易等细节,但看懂这一个循环,再去看源码里带各种优化的版本就不会迷路。
快照恢复是内存撮合的命门。源码里如果没有订单簿快照和事件重放机制,宕机后订单簿和账本会对不上,用户余额和持仓都得手动核对。我一般会查 trade 模块有没有定时任务把订单簿序列化到 Redis 或本地文件,以及启动时是否先加载快照再消费增量事件。
2.3 订单生命周期:状态机、撤单竞态与 T+1 结算适配
订单状态机决定账本和用户感知的一致性。标准链路是 NEW 到 PARTIALLY_FILLED 再到 FILLED,或者转向 CANCELLED / EXPIRED。源码里每个状态转移都应该收敛到 OrderService 的固定方法:受理、成交回调、撤单、过期。如果直接把状态字段 set 成新值却缺少校验,高并发下会出现“已成交的订单被当成可撤单处理”的假撤单。
| 前置状态 | 事件 | 后置状态 | 账本动作 |
|---|---|---|---|
| NEW | 部分成交 | PARTIALLY_FILLED | 成交部分解冻并结转入账 |
| PARTIALLY_FILLED | 继续成交 | FILLED | 剩余冻结解冻、资产入账 |
| NEW / PARTIALLY_FILLED | 撤单 | CANCELLED | 剩余冻结全部释放 |
| NEW / PARTIALLY_FILLED | 超时 | EXPIRED | 同上 |
撤单和成交同时发生的竞态,是测试中最容易复现的 bug:用户看到订单还挂着,点击撤单,接口返回成功,但成交回报也同时到达。源码如果对外部 API 先判断状态再更新,没有对订单行加锁或使用乐观锁,这条竞态就堵不住。我用的一条铁律:撤单接口必须带 order_id、user_id、expected_status 做条件更新,如UPDATE orders SET status='CANCELLED' WHERE id=? AND user_id=? AND status IN ('NEW','PARTIALLY_FILLED'),影响行数为 0 说明状态已变,直接返回“订单已成交”。
股票 T+1 与区块链 T+0 在这里的映射是“可卖数量”的差异。股票版的成交不会立刻增加 available 余额,而是先进待解禁数量;区块链现货则是实时解冻。源码如果在 wallet_ledger 的业务类型里区分了 TST_AVAILABLE 和 SPOT_AVAILABLE,说明它是按交易类型而不是代码分支处理差异,这是较成熟的映射方式。
3. 13 国语言与统一账本:i18n 框架和多资产资金模型如何匹配
3.1 语言切换:Spring MessageSource、动态加载与回退策略
13 国语言最先要解决的是配置文件混乱。有人把文案直接写在 HTML 或前端 JS 里,那不可能支持多语言。这套源码走的是 Spring 标准 MessageSource 方案:每个语言一个 messages_xx_YY.properties,后端根据请求头或用户设置返回对应文案,前端不做翻译,只负责把后端返回的 key 对应的文案原样展示。
@Configuration public class I18nConfig { @Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource(); source.setBasenames( "classpath:i18n/messages_zh_CN", "classpath:i18n/messages_en_US", "classpath:i18n/messages_ja_JP", // 其余语言按 i18n 目录实际文件补,兜底包最后 "classpath:i18n/messages" ); source.setDefaultEncoding("UTF-8"); source.setFallbackToSystemLocale(false); source.setUseCodeAsDefaultMessage(true); source.setCacheMillis(300_000); return source; } }setBasenames 的顺序决定了查找优先级:请求带 Accept-Language: zh-CN 时先查 messages_zh_CN.properties,找不到 key 就回退到兜底 messages.properties。setFallbackToSystemLocale(false) 必须设,否则服务器跑在系统 locale 为 en_US 的容器里时,所有语言缺失的 key 都会静默变成英文,上线后某个阿拉伯语用户看到一半英文一半阿拉伯文,排查起来极其费劲。setCacheMillis(300_000) 让语言包修改后 5 分钟自动生效,运营期加文案时改完 properties 等五分钟刷新即可,不需要重启服务。
语言包编码有个隐性坑:properties 文件如果存成 GBK 或带 BOM 的 UTF-8,阿拉伯语、俄语这类文字会乱码。规范做法是统一存无 BOM 的 UTF-8,并在打包脚本里强制校验。检查源码时可以把 i18n 目录下每个语言的 key 数量横向对比,缺 key 的范围集中在哪个模块——通常客服系统最容易被忽略,用户用西班牙语提问、客服却在阿拉伯语界面读不到消息标题,体验直接崩。
3.2 统一账本:钱包余额、资金流水与充值提币确认
股票、区块链、外汇的资产不需要做三套账本。统一模型是 wallet_balance 存“用户 + 币种 + 可用 + 冻结”,wallet_ledger 存每一笔变更流水。股票里的币种是 USD、CNY,区块链里的币种是 BTC、ETH、USDT,外汇里的是交易对本币和报价币,全部落在同一个 currency 字段上,不需要为每种资产类型建不同的账户表。
CREATE TABLE wallet_balance ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, currency VARCHAR(16) NOT NULL COMMENT 'USD/BTC/ETH/USDT...', available DECIMAL(36, 18) NOT NULL DEFAULT 0, frozen DECIMAL(36, 18) NOT NULL DEFAULT 0, updated_at BIGINT NOT NULL, UNIQUE KEY uk_user_currency (user_id, currency) ); CREATE TABLE wallet_ledger ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, account_id BIGINT UNSIGNED NOT NULL, change_amount DECIMAL(36, 18) NOT NULL, balance_after DECIMAL(36, 18) NOT NULL, biz_type VARCHAR(32) NOT NULL COMMENT 'TRADE/BLOCKCHAIN_DEPOSIT...', ref_id VARCHAR(64) NOT NULL COMMENT '订单号或链上交易哈希', created_at BIGINT NOT NULL, UNIQUE KEY uk_ref (biz_type, ref_id) );wallet_ledger 的 uk_ref 唯一索引是防御资金重复入账的第一道防线:充值入账时 biz_type 传 BLOCKCHAIN_DEPOSIT、ref_id 传链上交易哈希,同一笔哈希在数据库层面就不可能写两遍。balance_after 一定要存,没有它,发生异常时只能靠重新累加流水推余额,没法直接比对账实差异。金额精度用 DECIMAL(36, 18):BTC 的 satoshi 是 1e-8,外汇 pip 精度各不相同,统一到 18 位小数可以覆盖所有币种,换来的是代码里不用到处写精度转换。
区块链充提币和股票、外汇的最大差异在“确认”。股票充值来自券商划转,外部数据权威;区块链充值是等链上确认数到达阈值,源码里一般有一个 DepositConfirmService 定时扫描节点 API 的未确认交易,达到确认数后才调账本入账。常见的错误实现是节点同步不到位就放行——确认数要求 3,节点只同步了 1 个块就回调,会造成双花风险。运营级做法是确认数阈值做成按币种配置,测试网调低、主网调高,上线初期宁高勿低。
账本对账任务也是必检项。运营级系统通常有每日对账定时任务:遍历 wallet_balance.available + frozen,按用户和币种聚合 wallet_ledger.balance_after 的最大值并比较,不一致就告警。这个任务要从上线第一天就开,跑满 30 天能发现很多只在特殊行情波动时才暴露的边界问题。
4. 三市联动:行情源适配、订单路由与外部交易差异的落地位置
4.1 行情源统一抽象:一个接口接股票、区块链、外汇
三个市场的行情来源完全不同。区块链走交易所 WebSocket 或自建全节点,股票接券商行情网关或第三方数据商,外汇接流动性提供商的报价流。协议五花八门,但下游消费方只关心一件事:某个交易对的最新价、买一卖一、时间戳。所以要使源码的 market 模块有统一 MarketDataProvider 接口,每种行情源一个实现类,内部做协议适配,对外产出统一 Tick。
public interface MarketDataProvider { void subscribe(List<String> symbols); void onTick(Consumer<Tick> consumer); void disconnect(); } public record Tick( String symbol, // BTC/USDT 或 AAPL 或 EUR/USD long timestamp, // epoch millis,UTC long price, // 最小精度整数表示 long bidPrice, long askPrice ) {}price 用 long 不用 double 是刻意设计:行情源返回的价格直接乘上该 symbol 的价格精度(BTC/USDT 精度 1e-2、AAPL 精度 1e-2、EUR/USD 精度 1e-5),统一转成整型参与撮合计算,避免浮点误差。这个精度值放在 symbol_meta 表或配置文件里存着每个交易对的 pricePrecision 和 qtyPrecision,K 线模块和撮合模块共用。检查时重点看一致性:如果行情接口的价格精度和撮合模块不一致,同一笔订单在盘口显示和成交回报里会出现“价差 0.01”的肉眼可见差异。
三路行情源的连接管理各有一个容易踩的坑。区块链 WebSocket 断线后要重新订阅;股票网关一般要求固定 IP 白名单;外汇报价流是高频推送,如果直接拿 Java 的 ObjectMapper 解析 JSON,延迟会超过 10ms。合格实现会针对不同数据源区分 JSON、Protobuf 或字节缓冲截取字段。源码统一走 JSON 也没关系,先确认 disconnect 和 reconnect 幂等——重连后订阅全集而不是依赖服务端恢复订阅,是这类系统最常见的隐性 bug。
4.2 订单路由与结算差异:从统一入口到各市场规则映射
订单路由的核心很简单:订单带 marketType 字段,进 OrderRouter 后分发到对应撮合引擎。真正的复杂度在结算规则的差异映射。区块链现货实时到账,股票涉及 T+1 可卖数量和交易日历,外汇涉及隔夜利息 swap 和杠杆保证金。这些差异如果塞进撮合引擎里,撮合会变成一团乱麻;成熟做法是把差异全部下沉到 accountService 的结算层,撮合引擎只负责生成成交回报。
public void route(Order order) { MatchingEngine engine = engines.get(order.getMarketType()); if (engine == null) { throw new UnsupportedMarketException(order.getMarketType()); } accountService.freeze(order.getUserId(), order.getMarketType(), order.getFrozenAmount()); try { List<Trade> trades = engine.match(order); accountService.settle(order.getMarketType(), trades); } catch (RuntimeException e) { accountService.unfreeze(order.getUserId(), order.getMarketType(), order.getFrozenAmount()); throw e; } }freeze 和 settle 之间的失败补偿最容易被忽视。freeze 成功、match 抛异常时如果忘记 unfreeze,用户资金被冻结但对应订单已经没了,客服工单会淹没后台。所以 try/catch 里第一个动作就是解冻,宁可解冻重复(解冻接口幂等)也不要漏解冻。settle 接收 marketType 而不是直接按币种处理,T+1 和 T+0 的差异由 accountService 内部按市场规则分发,形成清楚的映射表。
| 业务类型 | 成交后资金动作 | 是否支持 T+1 卖出 | 额外费用 |
|---|---|---|---|
| 区块链现货 | 可用余额实时增加 | 是(实时) | 网络手续费 |
| 股票 | 增加持仓但当日不可卖 | 否 | 佣金、印花税 |
| 外汇 | 按杠杆占用保证金 | 视规则 | swap 隔夜利息 |
这张表在代码里的对应物是 market_type_rule 配置表或 MarketSettlementRule 枚举,包含结算方式、最小下单量、手续费率、杠杆倍数上限。很多二次开发团队把规则写死在 if/else 里,加一个交易对就要发版,这是和运营级源码拉开差距的地方。
4.3 客服系统与交易模块的联动:消息可靠性和工单状态
自带客服系统在这个场景里必须和交易模块联动。用户咨询“我明明充值了为什么没到账”时,客服端要能看到该用户的充值记录、链上确认状态、账本流水。源码的客服模块一般由四部分组成:WebSocket 在线聊天、工单系统、操作日志和后台关联查询。聊天走 gateway 的 WebSocket 网关,工单落 MySQL,用户 ID 直接关联 account 模块数据。
消息可靠性用“先落库再推送 + ack 补拉”的模式。服务端收到客服回复的消息,先写 chat_message 表,再推给用户;用户端收到后回 ack,服务端标记已读。用户重连后,网关从 Redis 拉取该用户 30 天内未 ack 的消息重新推送。这套逻辑和区块链充值的“先入账再发通知”是同一套路,都要求事件先落盘再消费。
5. 部署避坑:跑运营级交易所最容易翻车的 5 个位置
5.1 并发下单死锁:锁顺序不一致就全体卡死
现象:压测进行 5 分钟后,下单接口 QPS 突然从 800 掉到 0,线程 dump 显示 Thread A 持有 user_10086 的锁等 user_10087,Thread B 持有 user_10087 等 user_10086,两个线程互相持有对方需要的锁。
原因:账本模块加锁是“先锁 A 后锁 B”,撮合的结算路径可能以“先处理 B 单再处理 A 单”的顺序获取锁,两边顺序相反。Java 的 synchronized 不会自动解除死锁,线上只能重启,但重启后同样的问题还会复现。
解决:账户锁全局按 user_id 升序获取,任何服务、任何线程都不允许打破这个顺序。我在 AccountService 入口封装了一个 AccountLocks 工具,内部维护 ConcurrentHashMap 到 ReentrantLock 的映射,对外只暴露 lockAll(List userIds) 方法,由工具类排序后加锁,后续同事写底层方法也不容易绕过它。验证方式是压测时用jstack 进程ID抓两次线程快照,确认没有互相等待的锁对。
5.2 资金流水重复入账:先查后插的充值逻辑
现象:应用发布后,后台对账发现某个用户 BTC 余额比实际多了一倍,查 wallet_ledger 看到同一个链上交易哈希入账了两次,ref_id 完全相同。
原因:充值确认服务的代码是“先查 wallet_ledger 有没有这笔 ref_id,没有就插入新流水并加余额”。两个线程同时执行查询时都查不到,于是都执行了插入,数据库表没有唯一约束,重复就产生了。
解决:wallet_ledger 的 biz_type + ref_id 上唯一索引,插入用INSERT ... ON DUPLICATE KEY UPDATE让数据库拦重复。如果源码没这个索引,加一条 DDL:ALTER TABLE wallet_ledger ADD UNIQUE KEY uk_ref (biz_type, ref_id);然后把业务代码改成受影响行数为 0 时直接返回“已入账”。从那以后我对所有带“先查后写”的资金逻辑都会条件反射式地上唯一约束。
5.3 时区与语言包:13 国用户看到的 K 线错位
现象:阿拉伯和北美用户反馈日线 K 线的日期边界不对,美东用户显示收盘时间是凌晨 4 点而不是当地 16 点,同一个 K 线用户在手机和网页上看到的日期不一致。
原因:后端用 LocalDateTime.now() 存行情和 K 线时间戳,服务器时区是 UTC+8;展示层根据浏览器时区格式化,但数据库里存的“本地时间”已经把 UTC+8 当作绝对时间,用户时区一变换日期边界就算错了。
解决:数据库时间列只存 BIGINT epoch millis 或 MySQL 的 TIMESTAMP(内部按 UTC 存,展示时才转时区)。Java 代码里时间传递全部用 Instant 或 long,展示层通过用户偏好时区格式化。这条要写进项目规范,因为 13 国语言环境下任何一个“本地时间”都会成为运营事故。
5.4 客服消息丢失:WebSocket 断线发生在推送和落库之间
现象:用户和客服聊到一半,手机从 Wi-Fi 切到 4G 导致连接断开,重连后消息列表里缺了中间某一条客服回复,双方都以为对方没收到。
原因:服务端收到客服回复,先通过 WebSocket 推送给用户,然后写数据库标记已发送。断线恰好发生在推送之后、落库之前,消息就从用户视角消失了,数据库里也没有记录。
解决:顺序反过来:先写 chat_message 表落库,再推送;客户端收到后回 ack 标记已读。重连时网关查 Redis 里该用户最近 30 天未 ack 的消息列表补推。注意落库和推送不能在同一个事务里——推送是外部 IO,不能占用事务时长。正确做法是消息先走 MQ 到网关,网关落库后再推送,落库失败和推送失败分开补偿。
5.5 语言包乱码与回退失效:多语言界面一半英文一半本地文
现象:俄语、阿拉伯语用户看到界面部分文案变成乱码框,或者某些模块显示英文、另一些模块显示本地语言,同一个页面里语言混着来。
原因:properties 文件带 BOM 或被转成 GBK 存储,Spring 加载后 key 匹配不到;另外 setFallbackToSystemLocale 没配置,容器系统 locale 是 en_US,缺失的 key 全走了系统 locale 兜底而不是统一走默认包。
解决:语言包统一存无 BOM 的 UTF-8,打包脚本里加 file 命令校验编码;Maven resources 插件强制 encoding 为 UTF-8。代码里把 setFallbackToSystemLocale(false) 加上,setUseCodeAsDefaultMessage(true) 打开,这样缺翻译时前端能看到 key 字符串而不是混入英文。上线前用语言切换脚本逐个语言验证每个模块的关键文案,不用等用户拍照反馈。
6. 上线前最后三件事:压测指标、权限审计与冷热钱包切换
把交易模块联调通、界面跑顺,离上线还差好几步。我每次部署这套源码都会强制过三件事,少一件都不敢开实盘。
压测不是看 QPS 峰值。我用 wrk 打 10 分钟 1000 并发下单,同时盯三个指标:撮合引擎订单队列的 P99 延迟是否超过 500ms;wallet_ledger 表的行锁等待率是否升高;GC 日志里 Full GC 是否在压测期间出现。前两个不过说明锁粒度太粗,第三个不过要看 jstat -gcutil 里 Eden 区晋升情况。压测报告三页纸,作为上线评审的第一张附件。
权限审计经常被演示版糊弄。把代码里所有接口扫一遍,重点找充提币、撤单、客服后台、用户资产明细四个域。充提币接口必须有二次验证;撤单接口必须在 Service 层校验订单归属用户,不能只靠前端传 orderId 就撤;客服后台若没有独立 RBAC,普通用户登录后可能直接打开客服管理页面。我用脚本扫描所有 @RequestMapping 路径,对比权限配置表,高危接口逐一确认是否有 @PreAuthorize 或 @RequiresPermissions。
冷热钱包切换是区块链资产的保命设计。不能把所有币都放热钱包,我按“热钱包余额超过阈值的部分转冷钱包”做成定时任务,阈值按币种配置:BTC 主网 5 个起步,USDT 可以大一些。转出时私钥签名在独立离线机器完成,转出交易的 txid 记录到 wallet_transfer_log,服务端等链上确认后再更新内部地址映射。
#!/bin/bash # 每 10 分钟检查热钱包余额,超过阈值就触发转冷钱包 for symbol in BTC ETH USDT; do balance=$(curl -s "http://wallet-admin/api/v1/hot/$symbol/balance") threshold_key="THRESHOLD_$symbol" threshold=${!threshold_key:-10} if awk "BEGIN{exit !($balance > $threshold)}"; then curl -X POST "http://wallet-admin/api/v1/hot/$symbol/transfer-out" \ -H "Content-Type: application/json" \ -d "{\"amount\":\"$balance\",\"to\":\"$COLD_WALLET_$symbol\"}" fi done这个脚本里的钱包管理服务只接收转账指令,真正私钥放在签名服务里,同一笔转账请求带唯一 requestId 防重。转出后等链上确认,确认数够了才扣减热钱包余额。部署这套源码前,我会先在测试网跑一次“充值入账到转出冷钱包”全流程,确认数值和状态流转都对,才考虑上主网。从那以后我每次部署交易所都强制把这三件事走一遍,确认报告归档了才把域名切到生产。希望帮到你。
本文还有配套的精品资源,点击获取