增加主动查询 api

This commit is contained in:
2026-02-03 15:05:06 +08:00
parent 922f6df4e8
commit 753bd4a4d6
6 changed files with 358 additions and 47 deletions

109
README.md
View File

@@ -1,75 +1,94 @@
# USDTMan # USDTMan
TRON USDT TRC20 收款监听服务 - 基于交易记录扫描 + 区块确认数验证 TRON USDT TRC20 收款监听服务
## 功能 ## 特性
- 实时监听多个 TRON 地址的 USDT 收款 - 扫描交易记录 + 区块确认数验证(默认 >= 6
- 区块确认数验证默认6个确认 - 支持 `big.Int` 处理任意金额
- WebSocket 实时推送收款通知 - WebSocket 实时推送
- HTTP API 管理监听地址 - 主动查询历史交易(时间/金额/确认数过滤)
- 代理支持
## API 使用方式 ## 使用
```go ```go
// 创建监听器(配置对象方式)
uman := usdtman.NewUSDTMan(usdtman.Config{ uman := usdtman.NewUSDTMan(usdtman.Config{
Addresses: []string{"地址1", "地址2"}, Addresses: []string{"TN8nJ...", "TXYZo..."},
APIKey: "YOUR_API_KEY", APIKey: "YOUR_API_KEY",
QueryInterval: 5 * time.Second, // 查询间隔(可选,默认 5 秒) QueryInterval: 5 * time.Second, // 查询间隔
MinConfirmations: 6, // 最小确认数(可选,默认 6 MinConfirmations: 6, // 最小确认数
MaxHistoryTxns: 20, // 查询历史交易数(可选,默认 20 MaxHistoryTxns: 20, // 监听查询数量
ProxyURL: "http://127.0.0.1:7890", // HTTP/SOCKS5 代理(可选 ProxyURL: "http://127.0.0.1:7890", // 可选
}) })
// 或者使用自定义 Transport
uman := usdtman.NewUSDTMan(usdtman.Config{
Addresses: []string{"地址1"},
APIKey: "YOUR_API_KEY",
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
// 其他自定义配置...
},
})
// 设置收款回调
uman.OnPaymentComplete(func(payment *usdtman.USDTPayment) { uman.OnPaymentComplete(func(payment *usdtman.USDTPayment) {
fmt.Printf("收到 %.6f USDT确认: %d\n", payment.Amount, payment.Confirmations) fmt.Printf("收到 %s USDT (确认: %d)\n",
payment.GetAmountString(), payment.Confirmations)
}) })
// 启动监听
uman.Start() uman.Start()
defer uman.Stop()
// 停止监听 // 主动查询历史
uman.Stop() payments, _ := uman.QueryTransactions(usdtman.QueryFilter{
Address: "TN8nJ...",
// 动态添加/移除地址 StartTime: startTimestamp, // 毫秒
uman.AddAddress("新地址") EndTime: endTimestamp, // 毫秒
uman.RemoveAddress("旧地址") MinAmount: big.NewInt(10000000), // >= 10 USDT
MinConfirmations: 6,
Limit: 50,
})
``` ```
## 运行 ## 运行 HTTP Server
```bash ```bash
cd cmd/server cd cmd/server
go run main.go PROXY_URL=http://127.0.0.1:7890 go run main.go
``` ```
访问 http://localhost:8084 访问 http://localhost:8084
## 接口 ## HTTP API
- `POST /start` - 启动监听 - `POST /start` - 启动监听
- `POST /stop` - 停止监听 - `POST /stop` - 停止监听
- `POST /add-address` - 添加监听地址 - `POST /add-address` - 添加地址
- `POST /remove-address` - 移除地址 - `POST /remove-address` - 移除地址
- `GET /list-addresses` - 列出所有地址 - `GET /list-addresses` - 地址列表
- `GET /payments` - 获取收款历史 - `GET /payments` - 监听缓存记录最多100条
- `WS /ws` - WebSocket 连接 - `POST /query` - 主动查询历史
- `WS /ws` - WebSocket 推送
## 确认机制 ### 主动查询示例
- 扫描地址的最近交易记录 ```bash
- 计算区块确认数(当前区块 - 交易区块) curl -X POST http://localhost:8084/query \
- 仅在确认数 >= 6 时触发回调 -H "Content-Type: application/json" \
- 自动去重,避免重复处理 -d '{
"address": "TN8nJ...",
"startTime": 1770000000000,
"endTime": 1770100000000,
"minAmount": "10000000",
"minConfirmations": 6,
"limit": 50
}'
```
## 配置项
| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `Addresses` | []string | [] | 监听地址 |
| `APIKey` | string | - | TronGrid API Key |
| `QueryInterval` | time.Duration | 5s | 查询间隔 |
| `MinConfirmations` | int64 | 6 | 最小确认数 |
| `MaxHistoryTxns` | int | 20 | 监听查询数量 |
| `ProxyURL` | string | - | HTTP/SOCKS5 代理 |
| `Transport` | http.RoundTripper | nil | 自定义 Transport |
## 监听 vs 查询
- **监听** (`/payments`): 自动轮询最近交易,达到确认数后触发回调,缓存最多 100 条
- **查询** (`/query`): 主动查询链上历史,支持条件过滤,不触发回调

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math/big"
"net/http" "net/http"
"os" "os"
"sync" "sync"
@@ -79,6 +80,7 @@ func main() {
http.HandleFunc("/remove-address", removeAddress) http.HandleFunc("/remove-address", removeAddress)
http.HandleFunc("/list-addresses", listAddresses) http.HandleFunc("/list-addresses", listAddresses)
http.HandleFunc("/payments", getPayments) http.HandleFunc("/payments", getPayments)
http.HandleFunc("/query", queryTransactions)
http.HandleFunc("/ws", handleWebSocket) http.HandleFunc("/ws", handleWebSocket)
http.HandleFunc("/", serveIndex) http.HandleFunc("/", serveIndex)
@@ -196,6 +198,59 @@ func getPayments(w http.ResponseWriter, r *http.Request) {
}) })
} }
func queryTransactions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, false, "Method not allowed", nil)
return
}
var req struct {
Address string `json:"address"`
StartTime int64 `json:"startTime"` // 毫秒时间戳
EndTime int64 `json:"endTime"` // 毫秒时间戳
MinAmount string `json:"minAmount"` // 字符串格式的金额
MinConfirmations int64 `json:"minConfirmations"` // 最小确认数
Limit int `json:"limit"` // 查询数量
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, false, "invalid request", nil)
return
}
if req.Address == "" {
jsonResponse(w, false, "address is required", nil)
return
}
filter := usdtman.QueryFilter{
Address: req.Address,
StartTime: req.StartTime,
EndTime: req.EndTime,
MinConfirmations: req.MinConfirmations,
Limit: req.Limit,
}
// 解析最小金额
if req.MinAmount != "" {
minAmount := new(big.Int)
if _, ok := minAmount.SetString(req.MinAmount, 10); ok {
filter.MinAmount = minAmount
}
}
payments, err := uman.QueryTransactions(filter)
if err != nil {
jsonResponse(w, false, fmt.Sprintf("查询失败: %v", err), nil)
return
}
jsonResponse(w, true, "success", map[string]interface{}{
"payments": payments,
"count": len(payments),
})
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) { func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {

View File

@@ -34,11 +34,23 @@
</div> </div>
<div class="section"> <div class="section">
<h3>收款记录</h3> <h3>收款记录(监听缓存)</h3>
<button onclick="getPayments()">刷新</button> <button onclick="getPayments()">刷新</button>
<div id="payments"></div> <div id="payments"></div>
</div> </div>
<div class="section">
<h3>主动查询交易记录</h3>
<input type="text" id="queryAddress" placeholder="TRON 地址">
<input type="datetime-local" id="startTime" placeholder="开始时间">
<input type="datetime-local" id="endTime" placeholder="结束时间">
<input type="number" id="minAmount" placeholder="最小金额 (USDT)" step="0.01">
<input type="number" id="minConfirmations" placeholder="最小确认数" value="6">
<input type="number" id="queryLimit" placeholder="查询数量" value="50">
<button onclick="queryTransactions()">查询</button>
<div id="queryResults"></div>
</div>
<div class="section"> <div class="section">
<h3>实时日志 (WebSocket)</h3> <h3>实时日志 (WebSocket)</h3>
<div id="log"></div> <div id="log"></div>
@@ -113,7 +125,7 @@
data.data.payments.map(p => data.data.payments.map(p =>
'<tr><td>' + new Date(p.Timestamp).toLocaleString() + '</td>' + '<tr><td>' + new Date(p.Timestamp).toLocaleString() + '</td>' +
'<td>' + p.Address.substring(0, 10) + '...</td>' + '<td>' + p.Address.substring(0, 10) + '...</td>' +
'<td>' + (p.Amount / 1000000).toFixed(6) + ' USDT</td>' + '<td>' + p.Amount.toFixed(6) + ' USDT</td>' +
'<td>' + p.From.substring(0, 10) + '...</td>' + '<td>' + p.From.substring(0, 10) + '...</td>' +
'<td>' + p.Confirmations + '</td>' + '<td>' + p.Confirmations + '</td>' +
'<td><a href="https://tronscan.org/#/transaction/' + p.TxID + '" target="_blank">' + '<td><a href="https://tronscan.org/#/transaction/' + p.TxID + '" target="_blank">' +
@@ -125,6 +137,52 @@
} }
} }
async function queryTransactions() {
const address = document.getElementById('queryAddress').value.trim();
if (!address) {
alert('请输入地址');
return;
}
const startTime = document.getElementById('startTime').value;
const endTime = document.getElementById('endTime').value;
const minAmount = document.getElementById('minAmount').value;
const minConfirmations = document.getElementById('minConfirmations').value;
const limit = document.getElementById('queryLimit').value;
const body = {
address: address,
startTime: startTime ? new Date(startTime).getTime() : 0,
endTime: endTime ? new Date(endTime).getTime() : 0,
minAmount: minAmount ? (parseFloat(minAmount) * 1000000).toString() : '',
minConfirmations: parseInt(minConfirmations) || 0,
limit: parseInt(limit) || 50,
};
const res = await fetch('/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success && data.data.payments && data.data.payments.length > 0) {
const html = '<div class="success">找到 ' + data.data.count + ' 条记录</div>' +
'<table><tr><th>时间</th><th>金额</th><th>来源</th><th>确认数</th><th>TxID</th></tr>' +
data.data.payments.map(p =>
'<tr><td>' + new Date(p.Timestamp).toLocaleString() + '</td>' +
'<td>' + p.Amount.toFixed(6) + ' USDT</td>' +
'<td>' + p.From.substring(0, 10) + '...</td>' +
'<td>' + p.Confirmations + '</td>' +
'<td><a href="https://tronscan.org/#/transaction/' + p.TxID + '" target="_blank">' +
p.TxID.substring(0, 10) + '...</a></td></tr>'
).join('') + '</table>';
document.getElementById('queryResults').innerHTML = html;
} else {
document.getElementById('queryResults').innerHTML = '<div class="error">' + (data.message || '未找到记录') + '</div>';
}
}
listAddresses(); listAddresses();
getPayments(); getPayments();
</script> </script>

27
log/blocks.json Normal file
View File

@@ -0,0 +1,27 @@
{
"id": "2bf9c73deb56166ad38906e732a898646cfc3db15e0daab0e33a6c4e3bf0509e",
"blockNumber": 79809798,
"blockTimeStamp": 1770093843000,
"contractResult": [
"0000000000000000000000000000000000000000000000000000000000000000"
],
"contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c",
"receipt": {
"energy_usage": 130285,
"energy_usage_total": 130285,
"net_usage": 345,
"result": "SUCCESS",
"energy_penalty_total": 100635
},
"log": [
{
"address": "a614f803b6fd780986a42c78ec9c7f77e6ded13c",
"topics": [
"ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0000000000000000000000006158fe96b950111b8f1f31249fedda484e941312",
"000000000000000000000000e5fcae42ed4ecf7e23e4a561aded72c69aeeabc0"
],
"data": "0000000000000000000000000000000000000000000000000000000000ca54e0"
}
]
}

23
log/transactions.json Normal file
View File

@@ -0,0 +1,23 @@
{
"data": [
{
"transaction_id": "2bf9c73deb56166ad38906e732a898646cfc3db15e0daab0e33a6c4e3bf0509e",
"token_info": {
"symbol": "USDT",
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"decimals": 6,
"name": "Tether USD"
},
"block_timestamp": 1770093843000,
"from": "TJqwA7SoZnERE4zW5uDEiPkbz4B66h9TFj",
"to": "TWwGSYwpSzT6GTBr4AQw9QF6m4VVui3UGc",
"type": "Transfer",
"value": "13260000"
}
],
"success": true,
"meta": {
"at": 1770094054104,
"page_size": 1
}
}

View File

@@ -41,6 +41,16 @@ type USDTMan struct {
httpClient *http.Client // HTTP 客户端 httpClient *http.Client // HTTP 客户端
} }
// QueryFilter 查询过滤条件
type QueryFilter struct {
Address string // 地址(必填)
StartTime int64 // 开始时间戳毫秒0 表示不限制
EndTime int64 // 结束时间戳毫秒0 表示不限制
MinAmount *big.Int // 最小金额micro USDTnil 表示不限制
MinConfirmations int64 // 最小确认数0 表示不限制
Limit int // 查询数量限制0 使用默认值
}
// USDTPayment USDT 收款信息 // USDTPayment USDT 收款信息
type USDTPayment struct { type USDTPayment struct {
Address string Address string
@@ -351,6 +361,43 @@ func (m *USDTMan) getUSDTTransactions(address string) ([]TronGridTransaction, er
return result.Data, nil return result.Data, nil
} }
// getUSDTTransactionsWithLimit 获取指定数量的交易
func (m *USDTMan) getUSDTTransactionsWithLimit(address string, limit int) ([]TronGridTransaction, error) {
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?limit=%d&contract_address=%s&only_to=true",
TronGridAPI, address, limit, USDTContractAddress)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if m.apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", m.apiKey)
}
resp, err := m.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("TronGrid API error: %d %s", resp.StatusCode, string(body))
}
var result TronGridAccountTransactions
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result.Data, nil
}
// getTransactionBlock 获取交易的区块号 // getTransactionBlock 获取交易的区块号
func (m *USDTMan) getTransactionBlock(txID string) (int64, error) { func (m *USDTMan) getTransactionBlock(txID string) (int64, error) {
url := fmt.Sprintf("%s/wallet/gettransactioninfobyid?value=%s", TronGridAPI, txID) url := fmt.Sprintf("%s/wallet/gettransactioninfobyid?value=%s", TronGridAPI, txID)
@@ -466,3 +513,85 @@ func (m *USDTMan) RemoveAddress(address string) {
} }
} }
} }
// QueryTransactions 主动查询交易记录(带过滤条件)
func (m *USDTMan) QueryTransactions(filter QueryFilter) ([]*USDTPayment, error) {
if filter.Address == "" {
return nil, fmt.Errorf("address is required")
}
// 设置默认 limit
limit := filter.Limit
if limit == 0 {
limit = m.maxHistoryTxns
}
// 获取交易列表
transactions, err := m.getUSDTTransactionsWithLimit(filter.Address, limit)
if err != nil {
return nil, err
}
// 获取当前区块(用于计算确认数)
currentBlock, err := m.getCurrentBlock()
if err != nil {
return nil, err
}
var results []*USDTPayment
for _, txn := range transactions {
// 时间过滤
if filter.StartTime > 0 && txn.BlockTimestamp < filter.StartTime {
continue
}
if filter.EndTime > 0 && txn.BlockTimestamp > filter.EndTime {
continue
}
// 只处理转入该地址的交易
if !strings.EqualFold(txn.To, filter.Address) {
continue
}
// 解析金额
amount := new(big.Int)
amount, ok := amount.SetString(txn.Value, 10)
if !ok || amount.Sign() <= 0 {
continue
}
// 金额过滤
if filter.MinAmount != nil && amount.Cmp(filter.MinAmount) < 0 {
continue
}
// 获取区块号
blockNumber, err := m.getTransactionBlock(txn.TransactionID)
if err != nil {
continue
}
// 计算确认数
confirmations := currentBlock - blockNumber
// 确认数过滤
if filter.MinConfirmations > 0 && confirmations < filter.MinConfirmations {
continue
}
payment := &USDTPayment{
Address: filter.Address,
Amount: amount,
TxID: txn.TransactionID,
BlockNumber: blockNumber,
Timestamp: txn.BlockTimestamp,
From: txn.From,
Confirmations: confirmations,
}
results = append(results, payment)
}
return results, nil
}