fix
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user