diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..58a6948 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.fontFamily": "Fira Code, 'Fira Code', monospace" +} \ No newline at end of file diff --git a/etc/blockchain.yaml b/etc/blockchain.yaml index 2145177..85cea11 100644 --- a/etc/blockchain.yaml +++ b/etc/blockchain.yaml @@ -4,3 +4,7 @@ Etcd: Hosts: - 127.0.0.1:2379 Key: blockchain.rpc +Binance: + Key: "" + Secret: "" + TestMode: true \ No newline at end of file diff --git a/go.mod b/go.mod index 3e9cfd7..a428363 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,22 @@ module blockchain go 1.24.4 require ( + github.com/alicebob/miniredis/v2 v2.35.0 github.com/zeromicro/go-zero v1.8.5 google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.6 ) require ( + github.com/bitly/go-simplejson v0.5.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jpillora/backoff v1.0.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect +) + +require ( + github.com/adshao/go-binance/v2 v2.8.3 github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 3b74c6c..d5ebbe1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ +github.com/adshao/go-binance/v2 v2.8.3 h1:jwPRcX2u7FIO1pPoXgocyXpXhBI81A41kcmSDzS6uzo= +github.com/adshao/go-binance/v2 v2.8.3/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 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= @@ -60,6 +66,8 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJY github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/pyroscope-go v1.2.2 h1:uvKCyZMD724RkaCEMrSTC38Yn7AnFe8S2wiAIYdDPCE= github.com/grafana/pyroscope-go v1.2.2/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= @@ -70,6 +78,8 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -125,6 +135,8 @@ github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUA github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/internal/config/config.go b/internal/config/config.go index c1f85b9..c469c10 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,4 +4,11 @@ import "github.com/zeromicro/go-zero/zrpc" type Config struct { zrpc.RpcServerConf + Binance +} + +type Binance struct { + Key string + Secret string + TestMode bool } diff --git a/internal/domain/blockchain/const.go b/internal/domain/blockchain/const.go new file mode 100644 index 0000000..362b037 --- /dev/null +++ b/internal/domain/blockchain/const.go @@ -0,0 +1,5 @@ +package blockchain + +const RedisKeySymbolList = "symbol:all" + +const SymbolExpire = 3600 // 秒(目前為一小時) diff --git a/internal/domain/entity/symbol.go b/internal/domain/entity/symbol.go new file mode 100644 index 0000000..efe6f31 --- /dev/null +++ b/internal/domain/entity/symbol.go @@ -0,0 +1,38 @@ +package entity + +// Symbol 代表交易對資訊 +type Symbol struct { + Symbol string `json:"symbol"` // 交易對名稱 (BTCUSDT) + Status string `json:"status"` // 狀態(如 "TRADING" 表示可交易) + BaseAsset string `json:"base_asset"` // 主幣種(如 BTCUSDT 的 BTC) + BaseAssetPrecision int `json:"base_asset_precision"` // 主幣的小數點精度 + QuoteAsset string `json:"quote_asset"` // 報價幣種(如 BTCUSDT 的 USDT) + QuoteAssetPrecision int `json:"quote_asset_precision"` // 報價資產顯示的小數位數 +} + +func (s *Symbol) TableName() string { + return "symbol" +} + + +// Symbol 這個是幣安的 +//type Symbol struct { +// Symbol string `json:"symbol"` // 交易對名稱(如 "BTCUSDT") +// Status string `json:"status"` // 狀態(如 "TRADING" 表示可交易) +// BaseAsset string `json:"baseAsset"` // 主幣種(如 BTCUSDT 的 BTC) +// BaseAssetPrecision int `json:"baseAssetPrecision"` // 主幣的小數點精度 +// QuoteAsset string `json:"quoteAsset"` // 報價幣種(如 BTCUSDT 的 USDT) +// QuotePrecision int `json:"quotePrecision"` // 報價幣的小數點精度 +// QuoteAssetPrecision int `json:"quoteAssetPrecision"` // 報價資產顯示的小數位數 +// BaseCommissionPrecision int32 `json:"baseCommissionPrecision"` // 主幣手續費精度 +// QuoteCommissionPrecision int32 `json:"quoteCommissionPrecision"` // 報價幣手續費精度 +// OrderTypes []string `json:"orderTypes"` // 支援的下單類型(如 "LIMIT", "MARKET", "STOP_LOSS" 等) +// IcebergAllowed bool `json:"icebergAllowed"` // 是否允許冰山單 +// OcoAllowed bool `json:"ocoAllowed"` // 是否允許 OCO 單(條件單組合) +// QuoteOrderQtyMarketAllowed bool `json:"quoteOrderQtyMarketAllowed"` // 市價單是否可直接輸入報價幣金額 +// IsSpotTradingAllowed bool `json:"isSpotTradingAllowed"` // 是否允許現貨交易 +// IsMarginTradingAllowed bool `json:"isMarginTradingAllowed"` // 是否允許槓桿/融資交易 +// Filters []map[string]interface{} `json:"filters"` // 下單限制規則(如最小下單量、價格間隔等) +// Permissions []string `json:"permissions"` // 可用權限(如 "SPOT", "MARGIN") +// PermissionSets [][]string `json:"permissionSets"` // 權限組合(通常不常用) +//} diff --git a/internal/domain/repository/data_source.go b/internal/domain/repository/data_source.go new file mode 100644 index 0000000..2600efb --- /dev/null +++ b/internal/domain/repository/data_source.go @@ -0,0 +1,10 @@ +package repository + +import ( + "blockchain/internal/domain/entity" + "context" +) + +type DataSourceRepository interface { + GetSymbols(ctx context.Context) ([]entity.Symbol, error) +} diff --git a/internal/repository/data_source_binance.go b/internal/repository/data_source_binance.go new file mode 100644 index 0000000..58f2a36 --- /dev/null +++ b/internal/repository/data_source_binance.go @@ -0,0 +1,124 @@ +package repository + +import ( + "blockchain/internal/config" + "blockchain/internal/domain/blockchain" + "blockchain/internal/domain/entity" + "blockchain/internal/domain/repository" + "context" + "encoding/json" + "fmt" + + "github.com/adshao/go-binance/v2" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/core/syncx" +) + +type BinanceRepositoryParam struct { + Conf *config.Binance + Redis *redis.Redis +} + +type BinanceRepository struct { + Client *binance.Client + rds *redis.Redis + barrier syncx.SingleFlight +} + +func MustBinanceRepository(param BinanceRepositoryParam) repository.DataSourceRepository { + apiKey := "" + secret := "" + if param.Conf.TestMode { + binance.UseTestnet = true + } + client := binance.NewClient(apiKey, secret) + + return &BinanceRepository{ + Client: client, + rds: param.Redis, + barrier: syncx.NewSingleFlight(), + } +} + +func (repo *BinanceRepository) GetSymbols(ctx context.Context) ([]entity.Symbol, error) { + // 優先從 redis hash 拿 + cached, err := repo.rds.Hgetall(blockchain.RedisKeySymbolList) + if err == nil && len(cached) > 0 { + symbols := make([]entity.Symbol, 0, len(cached)) + canUseCache := true + for _, v := range cached { + var symbol entity.Symbol + if err := json.Unmarshal([]byte(v), &symbol); err == nil { + symbols = append(symbols, symbol) + } else { + // 如果任何一個反序列化失敗,代表快取可能已損壞,最好是回源重新拉取 + canUseCache = false + break + } + } + if canUseCache { + return symbols, nil + } + } + // 用 SingleFlight 保證只有一個請求真的去 Binance + val, err := repo.barrier.Do(blockchain.RedisKeySymbolList, func() (any, error) { + // 拉 source + srcSymbols, err := repo.getSymbolsFromSource(ctx) + if err != nil { + return nil, err + } + result := make([]entity.Symbol, 0, len(srcSymbols)) + hashData := make(map[string]string, len(srcSymbols)) + for _, s := range srcSymbols { + // 只挑目前需要的欄位 + symbolEntity := entity.Symbol{ + Symbol: s.Symbol, + Status: s.Status, + BaseAsset: s.BaseAsset, + BaseAssetPrecision: s.BaseAssetPrecision, + QuoteAsset: s.QuoteAsset, + QuoteAssetPrecision: s.QuoteAssetPrecision, + } + result = append(result, symbolEntity) + + // 將單一 symbol 序列化,準備寫入 hash + raw, err := json.Marshal(symbolEntity) + if err != nil { + logx.Error("failed to marshal symbol entity") + + continue + } + hashData[symbolEntity.Symbol] = string(raw) + } + + if len(hashData) > 0 { + // 使用 HMSET 一次寫入多個欄位到 hash + if err := repo.rds.Hmset(blockchain.RedisKeySymbolList, hashData); err == nil { + // 再對整個 key 設置過期時間 + _ = repo.rds.Expire(blockchain.RedisKeySymbolList, blockchain.SymbolExpire) + } + } + + return result, nil + }) + if err != nil { + return nil, err + } + + return val.([]entity.Symbol), nil +} + +func (repo *BinanceRepository) getSymbolsFromSource(ctx context.Context) ([]binance.Symbol, error) { + if repo.Client == nil { + return nil, fmt.Errorf("binance client not initialized") + } + + // 取得幣安交易所資訊 + info, err := repo.Client.NewExchangeInfoService().Do(ctx) + if err != nil { + return nil, err + } + + return info.Symbols, nil +} diff --git a/internal/repository/data_source_binance_test.go b/internal/repository/data_source_binance_test.go new file mode 100644 index 0000000..cd5c7f4 --- /dev/null +++ b/internal/repository/data_source_binance_test.go @@ -0,0 +1,83 @@ +package repository + +import ( + "blockchain/internal/config" + "context" + "fmt" + "sync" + "testing" + + miniredis "github.com/alicebob/miniredis/v2" + "github.com/zeromicro/go-zero/core/stores/redis" +) + +func setupMiniRedis() (*miniredis.Miniredis, *redis.Redis) { + // 啟動 setupMiniRedis 作為模擬的 Redis 服務 + mr, err := miniredis.Run() + if err != nil { + panic("failed to start miniRedis: " + err.Error()) + } + + // 使用 setupMiniRedis 的地址配置 go-zero Redis 客戶端 + redisConf := redis.RedisConf{ + Host: mr.Addr(), + Type: "node", + } + r := redis.MustNewRedis(redisConf) + + return mr, r +} + +func TestGetSymbolsFromSource_TableDriven(t *testing.T) { + mr, rdb := setupMiniRedis() + defer mr.Close() + + cases := []struct { + name string + wantSymbols []string + expectErr bool + }{ + { + name: "ok", + //wantSymbols: []string{"BTCUSDT", "ETHUSDT"}, + expectErr: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := MustBinanceRepository(BinanceRepositoryParam{ + Conf: &config.Binance{ + Key: "", + Secret: "", + TestMode: true, + }, + Redis: rdb, + }) + + var wg sync.WaitGroup + total := 10 + + for range total { + wg.Add(1) + go func() { + defer wg.Done() + symbols, err := repo.GetSymbols(context.Background()) + fmt.Println(symbols) + if err != nil { + return + } + }() + } + wg.Wait() + //got, err := repo.GetSymbolsFromSource(context.Background()) + //if tc.expectErr { + // assert.Error(t, err) + // assert.Nil(t, got) + //} else { + // assert.NoError(t, err) + // assert.Equal(t, tc.wantSymbols, got) + //} + }) + } +}