413 lines
9.8 KiB
Go
413 lines
9.8 KiB
Go
// 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
|
||
} |