This commit is contained in:
2026-02-02 02:23:21 +08:00
parent 795ccbfd51
commit d4cd1cecee
12 changed files with 825 additions and 102 deletions

View 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>

View 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

Binary file not shown.

413
servers/coinman/coinman.go Normal file
View File

@@ -0,0 +1,413 @@
// Package coinmanUSDT(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 创建 Coinmanredis 为 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
View 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
View 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

Binary file not shown.