DeEnergy是一个去中心化能源交易的概念平台,目标是让分布式光伏电站、储能设备、家庭用户直接在链上完成电力买卖,去掉中间售电公司这一层。要实现这样一个平台,Node.js是非常合适的技术选型:它天然适合处理大量长连接(行情推送、设备心跳),事件驱动模型也和区块链监听事件的模式高度契合。这篇文章就从架构设计开始,一步步把整个平台的搭建思路和关键代码讲清楚。

一、平台整体架构设计
一个完整的能源交易平台,至少要包含五层:设备接入层、业务服务层、撮合引擎、区块链交互层和数据持久层。设备接入层负责和智能电表、光伏逆变器通信,通常走MQTT或者WebSocket;业务服务层处理用户注册、订单提交、账务管理等;撮合引擎是核心,负责把买单和卖单按价格优先、时间优先的原则配对成交;区块链交互层负责把成交结果上链存证;数据持久层用Redis做订单簿缓存,MySQL存历史记录。
这里有一个架构决策需要提前想清楚:撮合到底放在链上还是链下?如果把撮合逻辑全部写成智能合约,每一笔挂单都要上链,Gas费用会高到没法用,而且区块链出块速度限制了撮合性能。主流做法是链下撮合加链上结算,撮合引擎在Node.js内存里完成,只有最终成交结果才打包上链。这种混合架构既保留了区块链不可篡改的结算特性,又能支撑每秒上千笔的挂单请求。
项目目录可以按功能模块划分,大致结构如下:
deenergy-platform/ ├── src/ │ ├── server.js # 入口文件 │ ├── config/ # 配置管理 │ ├── routes/ # HTTP路由 │ ├── services/ # 业务逻辑 │ ├── matching/ # 撮合引擎 │ ├── chain/ # 区块链交互 │ └── websocket/ # 实时推送 ├── contracts/ # 智能合约Solidity源码 └── package.json
依赖方面,除了Express之外,还需要Web3.js负责链上交互,ioredis做订单簿缓存,ws处理WebSocket推送。安装命令如下:
npm install express web3 ioredis ws mysql2 sequelize
二、用户账户与钱包绑定模块
去中心化平台和传统平台最大的区别在于账户体系。传统平台用用户名密码,而DeEnergy的账户直接绑定区块链钱包地址。用户注册时提交一个钱包地址,服务端不保管私钥,只做地址合法性校验和绑定关系存储。签名验证是关键环节:用户提交订单时需要用私钥对订单内容签名,服务端用椭圆曲线算法从签名反推出地址,比对是否和注册地址一致,这样就不需要传密码,也没有密码泄露风险。
地址校验和签名恢复的代码实现如下:
const Web3 = require('web3');
const web3 = new Web3('https://你的节点地址');
// 校验钱包地址是否合法
function isValidAddress(address) {
return web3.utils.isAddress(address);
}
// 从签名恢复地址,验证订单真实性
function verifySignature(message, signature) {
try {
const recovered = web3.eth.accounts.recover(message, signature);
return recovered; // 返回恢复出的地址,与注册地址比对
} catch (err) {
return null;
}
}
// 注册接口的核心逻辑
async function register(req, res) {
const { address, userType } = req.body; // userType: 发电方/用电方/储能方
if (!isValidAddress(address)) {
return res.status(400).json({ code: 1, msg: '钱包地址不合法' });
}
// 检查地址是否已被注册,写入用户表
const exists = await UserModel.findOne({ where: { address } });
if (exists) {
return res.status(409).json({ code: 2, msg: '地址已注册' });
}
await UserModel.create({ address, userType, balance: 0 });
res.json({ code: 0, msg: '注册成功' });
}
需要特别提醒一点:web3.eth.accounts.recover的message参数必须和用户签名时的原文完全一致,包括一个空格都不能差,否则恢复出的地址就是错的。实际项目中建议前后端约定好一个固定的拼接模板,比如把订单字段按字典序拼接成JSON字符串再签名,避免因为格式差异导致验证失败。
三、撮合引擎的实现
撮合引擎是整个平台的心脏。能源交易和股票交易类似,卖方挂单卖出一定数量的电力(单位可以是千瓦时),买方挂单买入,引擎按照价格优先、时间优先的规则自动匹配。实现上用两个有序列表分别维护买盘和卖盘:买盘按价格从高到低排,卖盘按价格从低到高排。新订单进来时,如果是买单,就去卖盘头部找价格小于等于买价的挂单成交;成交不完的剩余量进入买盘等待。
下面是一个简化版但可以直接跑起来的撮合实现:
class MatchingEngine {
constructor() {
this.bids = []; // 买盘,价格降序
this.asks = []; // 卖盘,价格升序
this.tradeCounter = 0;
}
// 提交订单入口
submitOrder(order) {
order.timestamp = Date.now();
if (order.side === 'buy') {
this.matchOrder(order, this.asks, (a, b) => a.price <= b.price);
if (order.amount > 0) this.insertSort(this.bids, order, (a, b) => a.price > b.price);
} else {
this.matchOrder(order, this.asks_bak = this.bids, (a, b) => a.price >= b.price);
if (order.amount > 0) this.insertSort(this.asks, order, (a, b) => a.price < b.price);
}
}
matchOrder(taker, oppositeBook, canTrade) {
while (taker.amount > 0 && oppositeBook.length > 0) {
const maker = oppositeBook[0];
if (!canTrade(taker, maker)) break; // 价格不满足则停止
const dealAmount = Math.min(taker.amount, maker.amount);
const dealPrice = maker.price; // 按挂单方价格成交
this.tradeCounter++;
const trade = {
tradeId: this.tradeCounter,
price: dealPrice,
amount: dealAmount,
buyer: taker.address,
seller: maker.address,
time: Date.now()
};
console.log('成交:', trade);
taker.amount -= dealAmount;
maker.amount -= dealAmount;
if (maker.amount === 0) oppositeBook.shift(); // 挂单全部成交则移出
}
}
insertSort(book, order, compare) {
let i = book.length;
while (i > 0 && !compare(order, book[i - 1])) i--; // 同价按时间优先
book.splice(i, 0, order);
}
}
module.exports = MatchingEngine;
这段代码有两个细节值得注意。第一,成交价取的是挂单方(maker)的价格,这是撮合系统的通用惯例,对后来者(taker)更有利。第二,Node.js是单线程的,撮合逻辑天然不存在多线程竞争问题,这是它做撮合引擎的一个隐藏优势。但如果撮合耗时过长会阻塞事件循环,影响其他请求,所以数据量大时应该把撮合引擎放到独立的子进程中,通过进程间通信传订单。
成交结果出来之后不能只存在内存里,要做两件事:一是写入MySQL作为成交记录,二是推送到区块链上链存证。上链操作是异步的,不应该阻塞撮合主流程,推荐用一个队列缓冲,由独立的Worker进程消费:
const { Worker } = require('worker_threads');
// 主进程中把成交结果交给上链Worker
const chainWorker = new Worker('./src/chain/settleWorker.js');
chainWorker.postMessage(trade);
// settleWorker.js 内部负责调用智能合约的结算方法
// 上链成功后回写交易哈希到成交记录表
四、智能合约交互与链上结算
链上部分的核心是一份结算合约,功能包括托管买卖双方的保证金、在成交确认后划转电费、记录成交哈希。合约用Solidity编写,Node.js端通过Web3.js调用。这里展示Node.js侧调用的写法:
const Web3 = require('web3');
const web3 = new Web3('https://你的节点地址');
const contractABI = require('../contracts/SettlementABI.json');
const contract = new web3.eth.Contract(contractABI, '合约部署地址');
// 平台结算账户,私钥从环境变量读取,切勿硬编码
const platformAccount = web3.eth.accounts.privateKeyToAccount(
process.env.PLATFORM_PRIVATE_KEY
);
web3.eth.accounts.wallet.add(platformAccount);
async function settleTrade(trade) {
const tx = contract.methods.settle(
trade.buyer,
trade.seller,
web3.utils.toWei(String(trade.price * trade.amount), 'ether'),
trade.tradeId
);
const gas = await tx.estimateGas({ from: platformAccount.address });
const receipt = await tx.send({
from: platformAccount.address,
gas: Math.floor(gas * 1.2) // 预留20%余量防止Gas不足
});
return receipt.transactionHash; // 返回交易哈希用于对账
}
结算对账是个容易被忽视的坑。链上交易可能因为Gas价格波动而长时间pending,甚至失败。所以成交记录表里必须有一个状态字段标记上链进度(待上链、已上链、上链失败),配合定时任务扫描超时未确认的记录做补偿重试。另外要监听合约事件,把链上最终状态同步回来,防止本地状态和链状态出现分叉:
contract.events.TradeSettled({
fromBlock: 'latest'
}, (error, event) => {
if (error) return console.error('事件监听异常', error);
const { tradeId, txHash } = event.returnValues;
// 根据tradeId更新本地记录为最终确认状态
TradeModel.update({ status: 'settled', txHash }, { where: { tradeId } });
});
五、行情实时推送与性能优化
交易平台必须把最新成交价、盘口深度实时推给用户,HTTP轮询的方式效率太低,WebSocket才是正确选择。Node.js的ws库可以轻松搭建推送服务,每个用户连接后订阅行情频道,有新成交时广播出去:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8081 });
function broadcastMarket(trade) {
const payload = JSON.stringify({
type: 'trade',
price: trade.price,
amount: trade.amount,
time: trade.time
});
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
性能层面有几个实操建议。第一,订单簿用Redis的Sorted Set存一份快照,服务重启后可以从Redis恢复内存订单簿,避免挂单丢失;第二,广播行情时做频率限制,每100毫秒合并推送一次,防止高频成交把客户端打爆;第三,MySQL写入成交记录时用批量插入而不是逐条插入,降低数据库压力。压测时重点观察两个指标:事件循环延迟(用perf_hooks监控)和内存占用,如果事件循环延迟持续超过100毫秒,说明撮合或上链逻辑阻塞了主线程,必须拆分到Worker线程。
安全方面还有三点不能省:所有接口必须验证钱包签名而不是依赖登录态;私钥统一放在环境变量或密钥管理服务中,代码和日志里绝不能出现;上链金额计算要小心浮点误差,统一转为最小单位整数再运算,用web3.utils.toWei处理就是出于这个考虑。把这些环节都做到位,一个能支撑几百到上千并发用户的能源交易平台雏形就成型了,后续再根据业务量逐步水平扩展即可。