fix
This commit is contained in:
172
App.tsx
172
App.tsx
@@ -1,5 +1,6 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { Alert, AppState, AppStateStatus, Modal, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import DeviceInfo from 'react-native-device-info';
|
||||||
import {
|
import {
|
||||||
PaytmBusinessBind,
|
PaytmBusinessBind,
|
||||||
PhonePeBusinessBind,
|
PhonePeBusinessBind,
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
PhonePePersonalBind,
|
PhonePePersonalBind,
|
||||||
SmsMessage,
|
SmsMessage,
|
||||||
NotificationMessage,
|
NotificationMessage,
|
||||||
TcpProxy,
|
proxyManager,
|
||||||
} from "rnwalletman";
|
} from "rnwalletman";
|
||||||
|
|
||||||
import BarcodeScanning from '@react-native-ml-kit/barcode-scanning';
|
import BarcodeScanning from '@react-native-ml-kit/barcode-scanning';
|
||||||
@@ -43,7 +44,7 @@ interface AppProps {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface WalletmanAppState {
|
||||||
showPaytmBusinessBind: boolean;
|
showPaytmBusinessBind: boolean;
|
||||||
showPaytmPersonalBind: boolean;
|
showPaytmPersonalBind: boolean;
|
||||||
showPhonePePersonalBind: boolean;
|
showPhonePePersonalBind: boolean;
|
||||||
@@ -99,10 +100,8 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
|
|
||||||
public static readonly BASE_URL = 'http://192.168.1.111:16000';
|
public static readonly BASE_URL = 'http://192.168.1.117:16000';
|
||||||
private static _instance: Api | null = null;
|
private static _instance: Api | null = null;
|
||||||
private ws: WebSocket | null = null;
|
|
||||||
private clientId: string = '';
|
|
||||||
private userId: number = 0;
|
private userId: number = 0;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@@ -123,43 +122,6 @@ class Api {
|
|||||||
return Api._instance;
|
return Api._instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setWebSocket(ws: WebSocket, clientId: string) {
|
|
||||||
this.ws = ws;
|
|
||||||
this.clientId = clientId;
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'proxyRequest') {
|
|
||||||
const { host, port } = msg.data;
|
|
||||||
TcpProxy.createProxy(
|
|
||||||
msg.messageId,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
(data: string) => {
|
|
||||||
ws.send(JSON.stringify({ type: 'proxyData', messageId: msg.messageId, data: { data } }));
|
|
||||||
},
|
|
||||||
() => { ws.send(JSON.stringify({ type: 'proxyClose', messageId: msg.messageId })); },
|
|
||||||
() => { ws.send(JSON.stringify({ type: 'proxyClose', messageId: msg.messageId })); }
|
|
||||||
).then(success => {
|
|
||||||
if (success) ws.send(JSON.stringify({ type: 'proxyReady', messageId: msg.messageId }));
|
|
||||||
}).catch(() => {
|
|
||||||
ws.send(JSON.stringify({ type: 'proxyClose', messageId: msg.messageId }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (msg.type === 'proxyData' && msg.data?.data) {
|
|
||||||
TcpProxy.writeProxy(msg.messageId, msg.data.data).catch(() => {
|
|
||||||
ws.send(JSON.stringify({ type: 'proxyClose', messageId: msg.messageId }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (msg.type === 'proxyClose') {
|
|
||||||
TcpProxy.closeProxy(msg.messageId).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[API]', e, event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private headers(): Record<string, string> {
|
private headers(): Record<string, string> {
|
||||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (this.userId) h['X-User-ID'] = String(this.userId);
|
if (this.userId) h['X-User-ID'] = String(this.userId);
|
||||||
@@ -213,10 +175,11 @@ class Api {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class App extends Component<AppProps, AppState> {
|
export default class App extends Component<AppProps, WalletmanAppState> {
|
||||||
private deviceId: string;
|
private deviceId: string;
|
||||||
private tuneUserId: string;
|
private tuneUserId: string;
|
||||||
private clientId: string;
|
private clientId: string = '';
|
||||||
|
private appStateSubscription?: any;
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -234,10 +197,20 @@ export default class App extends Component<AppProps, AppState> {
|
|||||||
// 临时使用测试成功的固定 ID
|
// 临时使用测试成功的固定 ID
|
||||||
this.deviceId = 'B6C1AB6DA4B659C287EA76AA96EC154B80E8D28D';
|
this.deviceId = 'B6C1AB6DA4B659C287EA76AA96EC154B80E8D28D';
|
||||||
this.tuneUserId = 'b5bfa7df-e571-4ac8-bb51-90afc05d1d59';
|
this.tuneUserId = 'b5bfa7df-e571-4ac8-bb51-90afc05d1d59';
|
||||||
this.clientId = `android_${Date.now()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
|
/* 获取真实 Android ID */
|
||||||
|
try {
|
||||||
|
this.clientId = await DeviceInfo.getAndroidId();
|
||||||
|
console.log('[设备ID]', this.clientId);
|
||||||
|
} catch (error) {
|
||||||
|
this.clientId = `android_${Date.now()}`;
|
||||||
|
console.warn('[设备ID] 获取失败,使用随机ID:', this.clientId);
|
||||||
|
}
|
||||||
|
/* 监听应用状态 */
|
||||||
|
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
|
||||||
|
|
||||||
/* 登录获取 userId */
|
/* 登录获取 userId */
|
||||||
try {
|
try {
|
||||||
await Api.instance.login('test123', '123456');
|
await Api.instance.login('test123', '123456');
|
||||||
@@ -308,78 +281,75 @@ export default class App extends Component<AppProps, AppState> {
|
|||||||
* 初始化代理客户端
|
* 初始化代理客户端
|
||||||
*/
|
*/
|
||||||
async initProxyClient() {
|
async initProxyClient() {
|
||||||
try {
|
const wsUrl = `ws://${Api.BASE_URL.replace('http://', '')}/ws`;
|
||||||
const serverUrl = `ws://${Api.BASE_URL.replace('http://', '')}/ws`;
|
|
||||||
console.log(`[WebSocket] 连接服务器: ${serverUrl}, 客户端ID: ${this.clientId}`);
|
proxyManager.start({
|
||||||
|
wsUrl,
|
||||||
// 创建WebSocket连接
|
clientId: this.clientId,
|
||||||
const ws = new WebSocket(serverUrl);
|
userId: Api.instance.getUserId(),
|
||||||
|
debug: true,
|
||||||
ws.onopen = () => {
|
onConnected: () => {
|
||||||
console.log('[WebSocket] ✅ 已连接');
|
console.log('[代理] ✅ 已连接');
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
console.log('[代理] ❌ 已断开');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('[代理] 错误:', error);
|
||||||
|
},
|
||||||
|
// 自定义注册逻辑(可选)
|
||||||
|
onRegister: (ws, clientId, userId) => {
|
||||||
|
// 可以在这里自定义发送的注册消息
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'register',
|
type: 'register',
|
||||||
clientId: this.clientId,
|
clientId,
|
||||||
messageId: 'register_' + Date.now(),
|
messageId: 'register_' + Date.now(),
|
||||||
data: { userId: Api.instance.getUserId() }
|
data: {
|
||||||
}));
|
userId,
|
||||||
Api.instance.setWebSocket(ws, this.clientId);
|
// 可以添加额外参数
|
||||||
|
deviceInfo: {
|
||||||
// 启动心跳
|
platform: 'android',
|
||||||
this.startHeartbeat(ws);
|
version: '1.0.0'
|
||||||
};
|
}
|
||||||
|
}
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error('[WebSocket] 错误:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('[WebSocket] 连接关闭');
|
|
||||||
this.stopHeartbeat();
|
|
||||||
// 3秒后重连
|
|
||||||
setTimeout(() => this.initProxyClient(), 3000);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[WebSocket] 连接失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动心跳
|
|
||||||
*/
|
|
||||||
startHeartbeat(ws: WebSocket) {
|
|
||||||
this.stopHeartbeat();
|
|
||||||
|
|
||||||
this.wsHeartbeatTimer = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'ping',
|
|
||||||
messageId: 'ping_' + Date.now(),
|
|
||||||
clientId: this.clientId
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, 20000); // 每20秒发送一次心跳
|
}).catch(err => {
|
||||||
|
console.error('[代理] 启动失败:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止心跳
|
* 停止代理客户端
|
||||||
*/
|
*/
|
||||||
stopHeartbeat() {
|
stopProxyClient() {
|
||||||
if (this.wsHeartbeatTimer) {
|
proxyManager.stop();
|
||||||
clearInterval(this.wsHeartbeatTimer);
|
|
||||||
this.wsHeartbeatTimer = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount(): void {
|
componentWillUnmount(): void {
|
||||||
// 停止心跳
|
// 清理应用状态监听
|
||||||
this.stopHeartbeat();
|
this.appStateSubscription?.remove();
|
||||||
|
|
||||||
|
// 停止代理
|
||||||
|
this.stopProxyClient();
|
||||||
|
|
||||||
stopSmsListener();
|
stopSmsListener();
|
||||||
stopNotificationListener();
|
stopNotificationListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理应用状态变化
|
||||||
|
*/
|
||||||
|
handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
||||||
|
console.log('[AppState] 应用进入后台,关闭代理');
|
||||||
|
this.stopProxyClient();
|
||||||
|
} else if (nextAppState === 'active') {
|
||||||
|
console.log('[AppState] 应用回到前台,重连代理');
|
||||||
|
this.initProxyClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
decodeQRFromUrl = async (url: string) => {
|
decodeQRFromUrl = async (url: string) => {
|
||||||
const localPath = `${RNFS.CachesDirectoryPath}/temp_qr_${Date.now()}.jpg`;
|
const localPath = `${RNFS.CachesDirectoryPath}/temp_qr_${Date.now()}.jpg`;
|
||||||
|
|
||||||
|
|||||||
1
android/.idea/gradle.xml
generated
1
android/.idea/gradle.xml
generated
@@ -25,6 +25,7 @@
|
|||||||
<option value="$PROJECT_DIR$/../node_modules/@react-native-cookies/cookies/android" />
|
<option value="$PROJECT_DIR$/../node_modules/@react-native-cookies/cookies/android" />
|
||||||
<option value="$PROJECT_DIR$/../node_modules/@react-native-ml-kit/barcode-scanning/android" />
|
<option value="$PROJECT_DIR$/../node_modules/@react-native-ml-kit/barcode-scanning/android" />
|
||||||
<option value="$PROJECT_DIR$/../node_modules/@react-native/gradle-plugin" />
|
<option value="$PROJECT_DIR$/../node_modules/@react-native/gradle-plugin" />
|
||||||
|
<option value="$PROJECT_DIR$/../node_modules/react-native-device-info/android" />
|
||||||
<option value="$PROJECT_DIR$/../node_modules/react-native-fs/android" />
|
<option value="$PROJECT_DIR$/../node_modules/react-native-fs/android" />
|
||||||
<option value="$PROJECT_DIR$/../node_modules/react-native-tcp-socket/android" />
|
<option value="$PROJECT_DIR$/../node_modules/react-native-tcp-socket/android" />
|
||||||
<option value="$PROJECT_DIR$/../node_modules/react-native-webview/android" />
|
<option value="$PROJECT_DIR$/../node_modules/react-native-webview/android" />
|
||||||
|
|||||||
2
android/.idea/vcs.xml
generated
2
android/.idea/vcs.xml
generated
@@ -5,6 +5,8 @@
|
|||||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/../libs/rnauto" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/../libs/rnauto" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/../libs/rnwalletman" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/../libs/rnwalletman" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/../node_modules/rnauto" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/../node_modules/rnwalletman" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/../servers/walletman" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/../servers/walletman" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.72.10",
|
"react-native": "0.72.10",
|
||||||
|
"react-native-device-info": "14.0.4",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-tcp-socket": "^6.4.1",
|
"react-native-tcp-socket": "^6.4.1",
|
||||||
"react-native-webview": "13.6.2",
|
"react-native-webview": "13.6.2",
|
||||||
|
|||||||
115
servers/coinman/cmd/coinman/admin.html
Normal file
115
servers/coinman/cmd/coinman/admin.html
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Coinman 管理</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 0 auto; padding: 16px; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||||
|
section { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
|
||||||
|
section h2 { font-size: 1rem; margin: 0 0 0.5rem; }
|
||||||
|
input[type="text"], input[type="url"], textarea { width: 100%; padding: 8px; margin: 4px 0; border: 1px solid #ccc; border-radius: 4px; }
|
||||||
|
textarea { min-height: 80px; resize: vertical; }
|
||||||
|
button { padding: 8px 14px; margin: 4px 4px 4px 0; border: none; border-radius: 4px; background: #333; color: #fff; cursor: pointer; }
|
||||||
|
button:hover { background: #555; }
|
||||||
|
button.danger { background: #c00; }
|
||||||
|
button.danger:hover { background: #e00; }
|
||||||
|
.msg { margin-top: 6px; font-size: 12px; color: #666; }
|
||||||
|
.err { color: #c00; }
|
||||||
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
li { padding: 6px 0; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
li:last-child { border-bottom: none; }
|
||||||
|
.addr { word-break: break-all; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Coinman 管理后台</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Webhook 回调地址</h2>
|
||||||
|
<input type="url" id="webhook" placeholder="https://your-server.com/webhook">
|
||||||
|
<button onclick="saveWebhook()">保存</button>
|
||||||
|
<div id="webhookMsg" class="msg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>添加收款地址</h2>
|
||||||
|
<textarea id="addAddrs" placeholder="每行一个 TRC20 地址"></textarea>
|
||||||
|
<button onclick="addAddresses()">添加</button>
|
||||||
|
<div id="addMsg" class="msg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>当前监听地址</h2>
|
||||||
|
<button onclick="loadAddresses()">刷新</button>
|
||||||
|
<ul id="addrList"></ul>
|
||||||
|
<div id="addrMsg" class="msg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const base = location.origin;
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function show(el, text, isErr) {
|
||||||
|
const e = typeof el === 'string' ? document.getElementById(el) : el;
|
||||||
|
if (!e) return;
|
||||||
|
e.textContent = text;
|
||||||
|
e.className = 'msg' + (isErr ? ' err' : '');
|
||||||
|
}
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const r = await fetch(base + path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function loadWebhook() {
|
||||||
|
const j = await api('/config/callback');
|
||||||
|
if (j.ok && j.data) document.getElementById('webhook').value = j.data.callback_url || '';
|
||||||
|
}
|
||||||
|
async function saveWebhook() {
|
||||||
|
const url = document.getElementById('webhook').value.trim();
|
||||||
|
const j = await api('/config/callback', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ callback_url: url })
|
||||||
|
});
|
||||||
|
show('webhookMsg', j.ok ? '已保存' : (j.msg || '失败'), !j.ok);
|
||||||
|
}
|
||||||
|
async function loadAddresses() {
|
||||||
|
const j = await api('/addresses');
|
||||||
|
const ul = document.getElementById('addrList');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
if (!j.ok || !j.data.addresses || !j.data.addresses.length) {
|
||||||
|
ul.innerHTML = '<li>暂无</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
j.data.addresses.forEach(addr => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = '<span class="addr">' + escapeHtml(addr) + '</span><button class="danger">删除</button>';
|
||||||
|
li.querySelector('button').onclick = () => removeOne(addr);
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function addAddresses() {
|
||||||
|
const raw = document.getElementById('addAddrs').value.trim();
|
||||||
|
const addrs = raw.split(/\n/).map(s => s.trim()).filter(Boolean);
|
||||||
|
if (!addrs.length) { show('addMsg', '请输入至少一个地址', true); return; }
|
||||||
|
const j = await api('/addresses/add', { method: 'POST', body: JSON.stringify({ addresses: addrs }) });
|
||||||
|
show('addMsg', j.ok ? '已添加 ' + addrs.length + ' 个' : (j.msg || '失败'), !j.ok);
|
||||||
|
if (j.ok) { document.getElementById('addAddrs').value = ''; loadAddresses(); }
|
||||||
|
}
|
||||||
|
async function removeOne(addr) {
|
||||||
|
const j = await api('/addresses/remove', { method: 'POST', body: JSON.stringify({ addresses: [addr] }) });
|
||||||
|
show('addrMsg', j.ok ? '已删除' : (j.msg || '失败'), !j.ok);
|
||||||
|
if (j.ok) loadAddresses();
|
||||||
|
}
|
||||||
|
loadWebhook();
|
||||||
|
loadAddresses();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
201
servers/coinman/cmd/coinman/main.go
Normal file
201
servers/coinman/cmd/coinman/main.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"rnpay/coinman"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const redisKeyCallback = "coinman:callback_url"
|
||||||
|
|
||||||
|
var (
|
||||||
|
cm *coinman.Coinman
|
||||||
|
rdb *redis.Client
|
||||||
|
ctx = context.Background()
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed admin.html
|
||||||
|
var adminFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||||
|
notifyStream := getEnv("NOTIFY_STREAM", "")
|
||||||
|
port := getEnv("PORT", ":16001")
|
||||||
|
|
||||||
|
rdb = redis.NewClient(&redis.Options{Addr: redisAddr})
|
||||||
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
|
log.Fatalf("Redis: %v", err)
|
||||||
|
}
|
||||||
|
if u := getEnv("CALLBACK_URL", ""); u != "" {
|
||||||
|
rdb.Set(ctx, redisKeyCallback, u, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm = coinman.New(rdb)
|
||||||
|
cm.OnOrderComplete(func(addr string, order coinman.OrderInfo) {
|
||||||
|
url, _ := rdb.Get(ctx, redisKeyCallback).Result()
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"event": "usdt.received", "address": addr, "order": order})
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[coinman] callback err: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("[coinman] callback -> %d", resp.StatusCode)
|
||||||
|
})
|
||||||
|
if notifyStream != "" {
|
||||||
|
cm.SetNotifyStream(notifyStream)
|
||||||
|
}
|
||||||
|
cm.Start()
|
||||||
|
|
||||||
|
http.HandleFunc("/start", cors(handleStart))
|
||||||
|
http.HandleFunc("/addresses", cors(handleAddresses))
|
||||||
|
http.HandleFunc("/addresses/add", cors(handleAddressesAdd))
|
||||||
|
http.HandleFunc("/addresses/remove", cors(handleAddressesRemove))
|
||||||
|
http.HandleFunc("/config/callback", cors(handleConfigCallback))
|
||||||
|
http.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, _ := adminFS.ReadFile("admin.html")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("coinman listen %s (REDIS=%s, STREAM=%s)", port, redisAddr, boolStr(notifyStream != ""))
|
||||||
|
log.Fatal(http.ListenAndServe(port, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
url, _ := rdb.Get(ctx, redisKeyCallback).Result()
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": url})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResp(w, false, "Method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
CallbackURL string `json:"callback_url"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
url := strings.TrimSpace(req.CallbackURL)
|
||||||
|
if url == "" {
|
||||||
|
rdb.Del(ctx, redisKeyCallback)
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rdb.Set(ctx, redisKeyCallback, url, 0)
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"callback_url": url})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cors(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResp(w http.ResponseWriter, ok bool, msg string, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "msg": msg, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResp(w, false, "Method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cm.Start()
|
||||||
|
jsonResp(w, true, "started", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddresses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonResp(w, false, "Method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := cm.ListPaymentAddresses()
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"addresses": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddressesAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResp(w, false, "Method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResp(w, false, "invalid json", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addrs := trimAddrs(req.Addresses)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
jsonResp(w, false, "addresses required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cm.AddPaymentAddress(addrs)
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"added": addrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddressesRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResp(w, false, "Method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResp(w, false, "invalid json", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addrs := trimAddrs(req.Addresses)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
jsonResp(w, false, "addresses required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cm.RemovePaymentAddress(addrs)
|
||||||
|
jsonResp(w, true, "ok", map[string]interface{}{"removed": addrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimAddrs(s []string) []string {
|
||||||
|
out := make([]string, 0, len(s))
|
||||||
|
for _, a := range s {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
BIN
servers/coinman/coinman
Executable file
BIN
servers/coinman/coinman
Executable file
Binary file not shown.
413
servers/coinman/coinman.go
Normal file
413
servers/coinman/coinman.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
// Package coinman:USDT(TRC20) 多地址收款、轮询回调,支持分布式部署
|
||||||
|
package coinman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tronGridBase = "https://api.trongrid.io"
|
||||||
|
usdtContract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||||
|
redisKeyAddrs = "coinman:addresses"
|
||||||
|
redisKeyLastTs = "coinman:last_ts:%s"
|
||||||
|
redisKeyNotified = "coinman:notified:%s" // 分布式去重:仅第一个实例回调
|
||||||
|
redisKeySeen = "coinman:seen:%s" // 已处理 tx_id 集合,避免重复
|
||||||
|
pollDefault = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderInfo 单笔到账订单信息
|
||||||
|
type OrderInfo struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
TxID string `json:"tx_id"`
|
||||||
|
BlockTs int64 `json:"block_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coinman 收款管理(多地址、分布式)
|
||||||
|
type Coinman struct {
|
||||||
|
redis *redis.Client
|
||||||
|
ctx context.Context
|
||||||
|
onComplete func(address string, order OrderInfo)
|
||||||
|
notifyStream string // 可选:到账时 XADD 到此 Stream,供多消费者
|
||||||
|
pollInterval time.Duration
|
||||||
|
mu sync.Mutex
|
||||||
|
started bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
memAddrs map[string]struct{} // redis 为 nil 时使用
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建 Coinman,redis 为 nil 时仅内存模式(单机)
|
||||||
|
func New(redisClient *redis.Client) *Coinman {
|
||||||
|
c := &Coinman{
|
||||||
|
redis: redisClient,
|
||||||
|
ctx: context.Background(),
|
||||||
|
pollInterval: pollDefault,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
memAddrs: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 开启轮询与回调
|
||||||
|
func (c *Coinman) Start() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.started {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.started = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
go c.pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOrderComplete 设置到账回调:每笔新到账调用一次 (收款地址, 订单信息)
|
||||||
|
func (c *Coinman) OnOrderComplete(fn func(address string, order OrderInfo)) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.onComplete = fn
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNotifyStream 设置 Redis Stream:到账时 XADD,多消费者可独立消费,无需额外 MQ
|
||||||
|
func (c *Coinman) SetNotifyStream(stream string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.notifyStream = stream
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPaymentAddress 动态添加收款地址(批量)
|
||||||
|
func (c *Coinman) AddPaymentAddress(addrs []string) {
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.redis != nil {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a != "" {
|
||||||
|
c.redis.SAdd(c.ctx, redisKeyAddrs, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a != "" {
|
||||||
|
c.memAddrs[a] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePaymentAddress 动态删除收款地址(批量)
|
||||||
|
func (c *Coinman) RemovePaymentAddress(addrs []string) {
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.SRem(c.ctx, redisKeyAddrs, addrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
for _, a := range addrs {
|
||||||
|
delete(c.memAddrs, a)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaymentAddresses 返回当前监听的收款地址列表
|
||||||
|
func (c *Coinman) ListPaymentAddresses() []string {
|
||||||
|
return c.getAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) getAddresses() []string {
|
||||||
|
if c.redis != nil {
|
||||||
|
list, _ := c.redis.SMembers(c.ctx, redisKeyAddrs).Result()
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(c.memAddrs))
|
||||||
|
for a := range c.memAddrs {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) getLastTs(addr string) int64 {
|
||||||
|
if c.redis != nil {
|
||||||
|
s, _ := c.redis.Get(c.ctx, fmt.Sprintf(redisKeyLastTs, addr)).Int64()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return 0 // 内存模式在 poll 里用本地 map 存 lastTs,见 pollLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) setLastTs(addr string, ts int64) {
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.Set(c.ctx, fmt.Sprintf(redisKeyLastTs, addr), ts, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分布式去重:仅一个实例能“认领”该 tx 并回调
|
||||||
|
func (c *Coinman) claimNotify(txID string) bool {
|
||||||
|
if c.redis != nil {
|
||||||
|
key := fmt.Sprintf(redisKeyNotified, txID)
|
||||||
|
ok, _ := c.redis.SetNX(c.ctx, key, "1", 7*24*time.Hour).Result()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) seen(addr, txID string) bool {
|
||||||
|
if c.redis != nil {
|
||||||
|
key := fmt.Sprintf(redisKeySeen, addr)
|
||||||
|
n, _ := c.redis.SAdd(c.ctx, key, txID).Result()
|
||||||
|
return n == 0 // 已存在
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) markSeen(addr, txID string) {
|
||||||
|
if c.redis != nil {
|
||||||
|
key := fmt.Sprintf(redisKeySeen, addr)
|
||||||
|
c.redis.SAdd(c.ctx, key, txID)
|
||||||
|
c.redis.Expire(c.ctx, key, 7*24*time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式下的 lastTs / seen 存在 Coinman 里
|
||||||
|
type addrState struct {
|
||||||
|
lastTs int64
|
||||||
|
seen map[string]bool
|
||||||
|
}
|
||||||
|
var memState = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
m map[string]*addrState
|
||||||
|
}{m: make(map[string]*addrState)}
|
||||||
|
|
||||||
|
func (c *Coinman) getLastTsMem(addr string) int64 {
|
||||||
|
memState.RLock()
|
||||||
|
defer memState.RUnlock()
|
||||||
|
if s, ok := memState.m[addr]; ok {
|
||||||
|
return s.lastTs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) setLastTsMem(addr string, ts int64) {
|
||||||
|
memState.Lock()
|
||||||
|
defer memState.Unlock()
|
||||||
|
if memState.m[addr] == nil {
|
||||||
|
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||||
|
}
|
||||||
|
memState.m[addr].lastTs = ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) seenMem(addr, txID string) bool {
|
||||||
|
memState.Lock()
|
||||||
|
defer memState.Unlock()
|
||||||
|
if memState.m[addr] == nil {
|
||||||
|
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||||
|
}
|
||||||
|
_, ok := memState.m[addr].seen[txID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) markSeenMem(addr, txID string) {
|
||||||
|
memState.Lock()
|
||||||
|
defer memState.Unlock()
|
||||||
|
if memState.m[addr] == nil {
|
||||||
|
memState.m[addr] = &addrState{seen: make(map[string]bool)}
|
||||||
|
}
|
||||||
|
memState.m[addr].seen[txID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) publishOrder(stream, addr string, order OrderInfo) {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{"address": addr, "order": order})
|
||||||
|
c.redis.XAdd(c.ctx, &redis.XAddArgs{
|
||||||
|
Stream: stream,
|
||||||
|
Values: map[string]interface{}{"address": addr, "data": string(payload)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) pollLoop() {
|
||||||
|
ticker := time.NewTicker(c.pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.pollOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coinman) pollOnce() {
|
||||||
|
addrs := c.getAddresses()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
onComplete := c.onComplete
|
||||||
|
notifyStream := c.notifyStream
|
||||||
|
c.mu.Unlock()
|
||||||
|
if onComplete == nil && notifyStream == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
var lastTs int64
|
||||||
|
if c.redis != nil {
|
||||||
|
lastTs = c.getLastTs(addr)
|
||||||
|
} else {
|
||||||
|
lastTs = c.getLastTsMem(addr)
|
||||||
|
}
|
||||||
|
transfers, err := fetchIncoming(addr, lastTs, 50)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var maxTs int64
|
||||||
|
for _, t := range transfers {
|
||||||
|
if !t.Confirmed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := c.redis != nil && c.seen(addr, t.TxID)
|
||||||
|
if !seen && c.redis == nil {
|
||||||
|
seen = c.seenMem(addr, t.TxID)
|
||||||
|
}
|
||||||
|
if seen {
|
||||||
|
if t.BlockTs > maxTs {
|
||||||
|
maxTs = t.BlockTs
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.redis != nil {
|
||||||
|
c.markSeen(addr, t.TxID)
|
||||||
|
} else {
|
||||||
|
c.markSeenMem(addr, t.TxID)
|
||||||
|
}
|
||||||
|
if t.BlockTs > maxTs {
|
||||||
|
maxTs = t.BlockTs
|
||||||
|
}
|
||||||
|
if !c.claimNotify(t.TxID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
order := OrderInfo{From: t.From, To: t.To, Value: t.Value, TxID: t.TxID, BlockTs: t.BlockTs}
|
||||||
|
if notifyStream != "" && c.redis != nil {
|
||||||
|
c.publishOrder(notifyStream, addr, order)
|
||||||
|
}
|
||||||
|
if onComplete != nil {
|
||||||
|
onComplete(addr, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxTs > 0 {
|
||||||
|
if c.redis != nil {
|
||||||
|
c.setLastTs(addr, maxTs)
|
||||||
|
} else {
|
||||||
|
c.setLastTsMem(addr, maxTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type transfer struct {
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Value string
|
||||||
|
TxID string
|
||||||
|
BlockTs int64
|
||||||
|
Confirmed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchIncoming 拉取某地址 TRC20-USDT 转入记录(供 HTTP 查询接口)
|
||||||
|
func FetchIncoming(address string, sinceTs int64, limit int) ([]OrderInfo, error) {
|
||||||
|
list, err := fetchIncoming(address, sinceTs, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]OrderInfo, 0, len(list))
|
||||||
|
for _, t := range list {
|
||||||
|
if t.Confirmed {
|
||||||
|
out = append(out, OrderInfo{From: t.From, To: t.To, Value: t.Value, TxID: t.TxID, BlockTs: t.BlockTs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTxStatus 查询单笔交易是否成功
|
||||||
|
func GetTxStatus(txID string) (confirmed bool, blockTs int64, err error) {
|
||||||
|
url := fmt.Sprintf("%s/wallet/gettransactionbyid?value=%s", tronGridBase, txID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var raw struct {
|
||||||
|
Ret []struct{ ContractRet string } `json:"ret"`
|
||||||
|
BlockTimestamp int64 `json:"block_timestamp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
if len(raw.Ret) > 0 {
|
||||||
|
confirmed = raw.Ret[0].ContractRet == "SUCCESS"
|
||||||
|
}
|
||||||
|
return confirmed, raw.BlockTimestamp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIncoming(address string, sinceTs int64, limit int) ([]transfer, error) {
|
||||||
|
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_to=true&contract_address=%s&limit=%d",
|
||||||
|
tronGridBase, address, usdtContract, limit)
|
||||||
|
if sinceTs > 0 {
|
||||||
|
url += "&min_timestamp=" + fmt.Sprintf("%d", sinceTs)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var raw struct {
|
||||||
|
Data []struct {
|
||||||
|
TxID string `json:"transaction_id"`
|
||||||
|
BlockTs int64 `json:"block_timestamp"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
ContractRet string `json:"contract_ret"`
|
||||||
|
} `json:"data"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil || !raw.Success {
|
||||||
|
return nil, fmt.Errorf("trongrid api err")
|
||||||
|
}
|
||||||
|
list := make([]transfer, 0, len(raw.Data))
|
||||||
|
for _, d := range raw.Data {
|
||||||
|
val := d.Value
|
||||||
|
if len(val) > 6 {
|
||||||
|
val = val[:len(val)-6] + "." + val[len(val)-6:]
|
||||||
|
} else {
|
||||||
|
val = "0." + val
|
||||||
|
}
|
||||||
|
list = append(list, transfer{
|
||||||
|
From: d.From,
|
||||||
|
To: d.To,
|
||||||
|
Value: val,
|
||||||
|
TxID: d.TxID,
|
||||||
|
BlockTs: d.BlockTs,
|
||||||
|
Confirmed: d.ContractRet == "SUCCESS",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
10
servers/coinman/go.mod
Normal file
10
servers/coinman/go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module rnpay/coinman
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/redis/go-redis/v9 v9.5.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
)
|
||||||
10
servers/coinman/go.sum
Normal file
10
servers/coinman/go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
||||||
|
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||||
BIN
servers/coinman/main
Executable file
BIN
servers/coinman/main
Executable file
Binary file not shown.
Submodule servers/walletman updated: 3bf3050270...134c37cceb
Reference in New Issue
Block a user